kollabor 0.4.9__py3-none-any.whl → 0.4.15__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.
Files changed (192) hide show
  1. agents/__init__.py +2 -0
  2. agents/coder/__init__.py +0 -0
  3. agents/coder/agent.json +4 -0
  4. agents/coder/api-integration.md +2150 -0
  5. agents/coder/cli-pretty.md +765 -0
  6. agents/coder/code-review.md +1092 -0
  7. agents/coder/database-design.md +1525 -0
  8. agents/coder/debugging.md +1102 -0
  9. agents/coder/dependency-management.md +1397 -0
  10. agents/coder/git-workflow.md +1099 -0
  11. agents/coder/refactoring.md +1454 -0
  12. agents/coder/security-hardening.md +1732 -0
  13. agents/coder/system_prompt.md +1448 -0
  14. agents/coder/tdd.md +1367 -0
  15. agents/creative-writer/__init__.py +0 -0
  16. agents/creative-writer/agent.json +4 -0
  17. agents/creative-writer/character-development.md +1852 -0
  18. agents/creative-writer/dialogue-craft.md +1122 -0
  19. agents/creative-writer/plot-structure.md +1073 -0
  20. agents/creative-writer/revision-editing.md +1484 -0
  21. agents/creative-writer/system_prompt.md +690 -0
  22. agents/creative-writer/worldbuilding.md +2049 -0
  23. agents/data-analyst/__init__.py +30 -0
  24. agents/data-analyst/agent.json +4 -0
  25. agents/data-analyst/data-visualization.md +992 -0
  26. agents/data-analyst/exploratory-data-analysis.md +1110 -0
  27. agents/data-analyst/pandas-data-manipulation.md +1081 -0
  28. agents/data-analyst/sql-query-optimization.md +881 -0
  29. agents/data-analyst/statistical-analysis.md +1118 -0
  30. agents/data-analyst/system_prompt.md +928 -0
  31. agents/default/__init__.py +0 -0
  32. agents/default/agent.json +4 -0
  33. agents/default/dead-code.md +794 -0
  34. agents/default/explore-agent-system.md +585 -0
  35. agents/default/system_prompt.md +1448 -0
  36. agents/kollabor/__init__.py +0 -0
  37. agents/kollabor/analyze-plugin-lifecycle.md +175 -0
  38. agents/kollabor/analyze-terminal-rendering.md +388 -0
  39. agents/kollabor/code-review.md +1092 -0
  40. agents/kollabor/debug-mcp-integration.md +521 -0
  41. agents/kollabor/debug-plugin-hooks.md +547 -0
  42. agents/kollabor/debugging.md +1102 -0
  43. agents/kollabor/dependency-management.md +1397 -0
  44. agents/kollabor/git-workflow.md +1099 -0
  45. agents/kollabor/inspect-llm-conversation.md +148 -0
  46. agents/kollabor/monitor-event-bus.md +558 -0
  47. agents/kollabor/profile-performance.md +576 -0
  48. agents/kollabor/refactoring.md +1454 -0
  49. agents/kollabor/system_prompt copy.md +1448 -0
  50. agents/kollabor/system_prompt.md +757 -0
  51. agents/kollabor/trace-command-execution.md +178 -0
  52. agents/kollabor/validate-config.md +879 -0
  53. agents/research/__init__.py +0 -0
  54. agents/research/agent.json +4 -0
  55. agents/research/architecture-mapping.md +1099 -0
  56. agents/research/codebase-analysis.md +1077 -0
  57. agents/research/dependency-audit.md +1027 -0
  58. agents/research/performance-profiling.md +1047 -0
  59. agents/research/security-review.md +1359 -0
  60. agents/research/system_prompt.md +492 -0
  61. agents/technical-writer/__init__.py +0 -0
  62. agents/technical-writer/agent.json +4 -0
  63. agents/technical-writer/api-documentation.md +2328 -0
  64. agents/technical-writer/changelog-management.md +1181 -0
  65. agents/technical-writer/readme-writing.md +1360 -0
  66. agents/technical-writer/style-guide.md +1410 -0
  67. agents/technical-writer/system_prompt.md +653 -0
  68. agents/technical-writer/tutorial-creation.md +1448 -0
  69. core/__init__.py +0 -2
  70. core/application.py +343 -88
  71. core/cli.py +229 -10
  72. core/commands/menu_renderer.py +463 -59
  73. core/commands/registry.py +14 -9
  74. core/commands/system_commands.py +2461 -14
  75. core/config/loader.py +151 -37
  76. core/config/service.py +18 -6
  77. core/events/bus.py +29 -9
  78. core/events/executor.py +205 -75
  79. core/events/models.py +27 -8
  80. core/fullscreen/command_integration.py +20 -24
  81. core/fullscreen/components/__init__.py +10 -1
  82. core/fullscreen/components/matrix_components.py +1 -2
  83. core/fullscreen/components/space_shooter_components.py +654 -0
  84. core/fullscreen/plugin.py +5 -0
  85. core/fullscreen/renderer.py +52 -13
  86. core/fullscreen/session.py +52 -15
  87. core/io/__init__.py +29 -5
  88. core/io/buffer_manager.py +6 -1
  89. core/io/config_status_view.py +7 -29
  90. core/io/core_status_views.py +267 -347
  91. core/io/input/__init__.py +25 -0
  92. core/io/input/command_mode_handler.py +711 -0
  93. core/io/input/display_controller.py +128 -0
  94. core/io/input/hook_registrar.py +286 -0
  95. core/io/input/input_loop_manager.py +421 -0
  96. core/io/input/key_press_handler.py +502 -0
  97. core/io/input/modal_controller.py +1011 -0
  98. core/io/input/paste_processor.py +339 -0
  99. core/io/input/status_modal_renderer.py +184 -0
  100. core/io/input_errors.py +5 -1
  101. core/io/input_handler.py +211 -2452
  102. core/io/key_parser.py +7 -0
  103. core/io/layout.py +15 -3
  104. core/io/message_coordinator.py +111 -2
  105. core/io/message_renderer.py +129 -4
  106. core/io/status_renderer.py +147 -607
  107. core/io/terminal_renderer.py +97 -51
  108. core/io/terminal_state.py +21 -4
  109. core/io/visual_effects.py +816 -165
  110. core/llm/agent_manager.py +1063 -0
  111. core/llm/api_adapters/__init__.py +44 -0
  112. core/llm/api_adapters/anthropic_adapter.py +432 -0
  113. core/llm/api_adapters/base.py +241 -0
  114. core/llm/api_adapters/openai_adapter.py +326 -0
  115. core/llm/api_communication_service.py +167 -113
  116. core/llm/conversation_logger.py +322 -16
  117. core/llm/conversation_manager.py +556 -30
  118. core/llm/file_operations_executor.py +84 -32
  119. core/llm/llm_service.py +934 -103
  120. core/llm/mcp_integration.py +541 -57
  121. core/llm/message_display_service.py +135 -18
  122. core/llm/plugin_sdk.py +1 -2
  123. core/llm/profile_manager.py +1183 -0
  124. core/llm/response_parser.py +274 -56
  125. core/llm/response_processor.py +16 -3
  126. core/llm/tool_executor.py +6 -1
  127. core/logging/__init__.py +2 -0
  128. core/logging/setup.py +34 -6
  129. core/models/resume.py +54 -0
  130. core/plugins/__init__.py +4 -2
  131. core/plugins/base.py +127 -0
  132. core/plugins/collector.py +23 -161
  133. core/plugins/discovery.py +37 -3
  134. core/plugins/factory.py +6 -12
  135. core/plugins/registry.py +5 -17
  136. core/ui/config_widgets.py +128 -28
  137. core/ui/live_modal_renderer.py +2 -1
  138. core/ui/modal_actions.py +5 -0
  139. core/ui/modal_overlay_renderer.py +0 -60
  140. core/ui/modal_renderer.py +268 -7
  141. core/ui/modal_state_manager.py +29 -4
  142. core/ui/widgets/base_widget.py +7 -0
  143. core/updates/__init__.py +10 -0
  144. core/updates/version_check_service.py +348 -0
  145. core/updates/version_comparator.py +103 -0
  146. core/utils/config_utils.py +685 -526
  147. core/utils/plugin_utils.py +1 -1
  148. core/utils/session_naming.py +111 -0
  149. fonts/LICENSE +21 -0
  150. fonts/README.md +46 -0
  151. fonts/SymbolsNerdFont-Regular.ttf +0 -0
  152. fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
  153. fonts/__init__.py +44 -0
  154. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
  155. kollabor-0.4.15.dist-info/RECORD +228 -0
  156. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
  157. plugins/agent_orchestrator/__init__.py +39 -0
  158. plugins/agent_orchestrator/activity_monitor.py +181 -0
  159. plugins/agent_orchestrator/file_attacher.py +77 -0
  160. plugins/agent_orchestrator/message_injector.py +135 -0
  161. plugins/agent_orchestrator/models.py +48 -0
  162. plugins/agent_orchestrator/orchestrator.py +403 -0
  163. plugins/agent_orchestrator/plugin.py +976 -0
  164. plugins/agent_orchestrator/xml_parser.py +191 -0
  165. plugins/agent_orchestrator_plugin.py +9 -0
  166. plugins/enhanced_input/box_styles.py +1 -0
  167. plugins/enhanced_input/color_engine.py +19 -4
  168. plugins/enhanced_input/config.py +2 -2
  169. plugins/enhanced_input_plugin.py +61 -11
  170. plugins/fullscreen/__init__.py +6 -2
  171. plugins/fullscreen/example_plugin.py +1035 -222
  172. plugins/fullscreen/setup_wizard_plugin.py +592 -0
  173. plugins/fullscreen/space_shooter_plugin.py +131 -0
  174. plugins/hook_monitoring_plugin.py +436 -78
  175. plugins/query_enhancer_plugin.py +66 -30
  176. plugins/resume_conversation_plugin.py +1494 -0
  177. plugins/save_conversation_plugin.py +98 -32
  178. plugins/system_commands_plugin.py +70 -56
  179. plugins/tmux_plugin.py +154 -78
  180. plugins/workflow_enforcement_plugin.py +94 -92
  181. system_prompt/default.md +952 -886
  182. core/io/input_mode_manager.py +0 -402
  183. core/io/modal_interaction_handler.py +0 -315
  184. core/io/raw_input_processor.py +0 -946
  185. core/storage/__init__.py +0 -5
  186. core/storage/state_manager.py +0 -84
  187. core/ui/widget_integration.py +0 -222
  188. core/utils/key_reader.py +0 -171
  189. kollabor-0.4.9.dist-info/RECORD +0 -128
  190. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
  191. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
  192. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1183 @@
1
+ """
2
+ LLM Profile Manager.
3
+
4
+ Manages named LLM configuration profiles that define:
5
+ - API endpoint URL
6
+ - Model name
7
+ - Temperature and other parameters
8
+ - Tool calling format (OpenAI vs Anthropic)
9
+ - API token environment variable
10
+
11
+ Profiles can be defined in config.json under core.llm.profiles
12
+ or use built-in defaults.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import logging
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ from .api_adapters import BaseAPIAdapter, OpenAIAdapter, AnthropicAdapter
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class EnvVarHint:
30
+ """Information about a profile's env var."""
31
+ name: str # e.g., "KOLLABOR_CLAUDE_TOKEN"
32
+ is_set: bool # True if env var exists and is non-empty
33
+
34
+
35
+ @dataclass
36
+ class LLMProfile:
37
+ """
38
+ Configuration profile for LLM settings.
39
+
40
+ Attributes:
41
+ name: Profile identifier
42
+ api_url: Base URL for the LLM API
43
+ model: Model name/identifier
44
+ temperature: Sampling temperature (0.0-1.0)
45
+ max_tokens: Maximum tokens to generate (None = no limit)
46
+ tool_format: Tool calling format ("openai" or "anthropic")
47
+ native_tool_calling: Enable native API tool calling (True) or XML-only mode (False)
48
+ timeout: Request timeout in milliseconds (0 = no timeout)
49
+ description: Human-readable description
50
+ extra_headers: Additional HTTP headers to include
51
+
52
+ API tokens are now resolved via environment variables using the pattern:
53
+ KOLLABOR_{PROFILE_NAME}_TOKEN (e.g., KOLLABOR_CLAUDE_TOKEN)
54
+ """
55
+
56
+ name: str
57
+ api_url: str
58
+ model: str
59
+ temperature: float = 0.7
60
+ max_tokens: Optional[int] = None
61
+ tool_format: str = "openai"
62
+ native_tool_calling: bool = True # True = native API tools, False = XML tags only
63
+ timeout: int = 0
64
+ description: str = ""
65
+ extra_headers: Dict[str, str] = field(default_factory=dict)
66
+ # Internal storage for API token from config (not from env var)
67
+ api_token: str = field(default="", repr=False)
68
+
69
+ def _get_env_key(self, field: str) -> str:
70
+ """Generate env var key for this profile and field.
71
+
72
+ Normalizes profile name: strip whitespace, all non-alphanumeric chars become underscore.
73
+ Examples:
74
+ my-local-llm -> KOLLABOR_MY_LOCAL_LLM_{FIELD}
75
+ my.profile -> KOLLABOR_MY_PROFILE_{FIELD}
76
+ My Profile! -> KOLLABOR_MY_PROFILE__{FIELD}
77
+ " fast " -> KOLLABOR_FAST_{FIELD}
78
+ """
79
+ # Strip whitespace, replace all non-alphanumeric with underscore, then uppercase
80
+ name_stripped = self.name.strip()
81
+ name_normalized = re.sub(r'[^a-zA-Z0-9]', '_', name_stripped).upper()
82
+ return f"KOLLABOR_{name_normalized}_{field}"
83
+
84
+ def _get_env_value(self, field: str) -> Optional[str]:
85
+ """Get env var value, treating empty/whitespace-only as unset.
86
+
87
+ Returns:
88
+ The env var value if set and non-empty, None otherwise.
89
+ Note: "0" is a valid value and will be returned (not treated as falsy).
90
+ """
91
+ env_key = self._get_env_key(field)
92
+ env_val = os.environ.get(env_key)
93
+ # Check for None (unset) or empty/whitespace-only
94
+ if env_val is None or not env_val.strip():
95
+ return None
96
+ return env_val
97
+
98
+ def get_endpoint(self) -> str:
99
+ """Get API endpoint, checking env var first. REQUIRED field."""
100
+ env_val = self._get_env_value("ENDPOINT")
101
+ if env_val:
102
+ return env_val
103
+ if self.api_url:
104
+ return self.api_url
105
+ # Both sources empty - warn user
106
+ logger.warning(f"Profile '{self.name}': No endpoint configured. "
107
+ f"Set {self._get_env_key('ENDPOINT')} or configure in config.json")
108
+ return ""
109
+
110
+ def get_token(self) -> Optional[str]:
111
+ """Get API token from env var or config. REQUIRED field."""
112
+ env_val = self._get_env_value("TOKEN")
113
+ if env_val:
114
+ return env_val
115
+ if self.api_token:
116
+ return self.api_token
117
+ # Both sources empty - warn user
118
+ logger.warning(f"Profile '{self.name}': No API token configured. "
119
+ f"Set {self._get_env_key('TOKEN')} in your environment")
120
+ return None
121
+
122
+ def get_model(self) -> str:
123
+ """Get model, checking env var first. REQUIRED field."""
124
+ env_val = self._get_env_value("MODEL")
125
+ if env_val:
126
+ return env_val
127
+ if self.model:
128
+ return self.model
129
+ # Both sources empty - warn user
130
+ logger.warning(f"Profile '{self.name}': No model configured. "
131
+ f"Set {self._get_env_key('MODEL')} or configure in config.json")
132
+ return ""
133
+
134
+ def get_max_tokens(self) -> Optional[int]:
135
+ """Get max tokens, checking env var first. OPTIONAL field."""
136
+ env_key = self._get_env_key("MAX_TOKENS")
137
+ env_val = self._get_env_value("MAX_TOKENS")
138
+ if env_val:
139
+ try:
140
+ return int(env_val)
141
+ except ValueError:
142
+ logger.warning(f"Profile '{self.name}': {env_key}='{env_val}' is not a valid integer, "
143
+ f"using config value")
144
+ return self.max_tokens # Returns None if not configured (uses API default)
145
+
146
+ def get_temperature(self) -> float:
147
+ """Get temperature, checking env var first. OPTIONAL field (default: 0.7)."""
148
+ env_key = self._get_env_key("TEMPERATURE")
149
+ env_val = self._get_env_value("TEMPERATURE")
150
+ if env_val:
151
+ try:
152
+ return float(env_val)
153
+ except ValueError:
154
+ logger.warning(f"Profile '{self.name}': {env_key}='{env_val}' is not a valid float, "
155
+ f"using config value")
156
+ return self.temperature if self.temperature is not None else 0.7
157
+
158
+ def get_timeout(self) -> int:
159
+ """Get timeout, checking env var first. OPTIONAL field (default: 30000ms).
160
+
161
+ Note: 0 means no timeout (infinity), not a fallback value.
162
+ """
163
+ env_key = self._get_env_key("TIMEOUT")
164
+ env_val = self._get_env_value("TIMEOUT")
165
+ if env_val is not None:
166
+ try:
167
+ return int(env_val)
168
+ except ValueError:
169
+ logger.warning(f"Profile '{self.name}': {env_key}='{env_val}' is not a valid integer, "
170
+ f"using config value")
171
+ # 0 is valid (no timeout), only use default if truly None
172
+ if self.timeout is not None:
173
+ return self.timeout
174
+ return 30000
175
+
176
+ def get_tool_format(self) -> str:
177
+ """Get tool format, checking env var first. OPTIONAL field (default: openai)."""
178
+ env_key = self._get_env_key("TOOL_FORMAT")
179
+ env_val = self._get_env_value("TOOL_FORMAT")
180
+ valid_formats = ("openai", "anthropic")
181
+ if env_val:
182
+ if env_val in valid_formats:
183
+ return env_val
184
+ logger.warning(f"Profile '{self.name}': {env_key}='{env_val}' is invalid "
185
+ f"(must be one of {valid_formats}), using 'openai'")
186
+ config_val = self.tool_format
187
+ if config_val and config_val in valid_formats:
188
+ return config_val
189
+ return "openai"
190
+
191
+ def get_native_tool_calling(self) -> bool:
192
+ """Get native_tool_calling, checking env var first. OPTIONAL field (default: True).
193
+
194
+ When True, tools are passed to the API for native function calling.
195
+ When False, the LLM uses XML tags (<terminal>, <tool>, etc.) instead.
196
+ """
197
+ env_val = self._get_env_value("NATIVE_TOOL_CALLING")
198
+ if env_val is not None:
199
+ # Accept common truthy/falsy values
200
+ return env_val.lower() in ("true", "1", "yes", "on")
201
+ return self.native_tool_calling
202
+
203
+ def get_env_var_hints(self) -> Dict[str, EnvVarHint]:
204
+ """Get env var names and status for this profile."""
205
+ fields = ["ENDPOINT", "TOKEN", "MODEL", "MAX_TOKENS", "TEMPERATURE", "TIMEOUT", "TOOL_FORMAT", "NATIVE_TOOL_CALLING"]
206
+ return {
207
+ field.lower(): EnvVarHint(
208
+ name=self._get_env_key(field),
209
+ is_set=self._get_env_value(field) is not None
210
+ )
211
+ for field in fields
212
+ }
213
+
214
+ def get_api_token(self) -> Optional[str]:
215
+ """
216
+ Get API token from environment variable.
217
+
218
+ DEPRECATED: Use get_token() instead. Tokens are now resolved via
219
+ KOLLABOR_{PROFILE_NAME}_TOKEN environment variables.
220
+
221
+ Returns:
222
+ None (deprecated method, use get_token() instead)
223
+ """
224
+ # Deprecated - use get_token() which follows the new env var pattern
225
+ return None
226
+
227
+ def to_dict(self) -> Dict[str, Any]:
228
+ """Convert profile to dictionary representation."""
229
+ result = {
230
+ "name": self.name,
231
+ "api_url": self.api_url,
232
+ "model": self.model,
233
+ "temperature": self.temperature,
234
+ "max_tokens": self.max_tokens,
235
+ "tool_format": self.tool_format,
236
+ "native_tool_calling": self.native_tool_calling,
237
+ "timeout": self.timeout,
238
+ "description": self.description,
239
+ "extra_headers": self.extra_headers,
240
+ }
241
+ # Only include api_token if set (to avoid empty string in config)
242
+ if self.api_token:
243
+ result["api_token"] = self.api_token
244
+ return result
245
+
246
+ @classmethod
247
+ def from_dict(cls, name: str, data: Dict[str, Any]) -> "LLMProfile":
248
+ """
249
+ Create profile from dictionary.
250
+
251
+ Silently ignores unknown fields for forward compatibility.
252
+
253
+ Args:
254
+ name: Profile name
255
+ data: Profile configuration dictionary
256
+
257
+ Returns:
258
+ LLMProfile instance
259
+ """
260
+ return cls(
261
+ name=name,
262
+ api_url=data.get("api_url", "http://localhost:1234"),
263
+ model=data.get("model", "default"),
264
+ temperature=data.get("temperature", 0.7),
265
+ max_tokens=data.get("max_tokens"),
266
+ tool_format=data.get("tool_format", "openai"),
267
+ native_tool_calling=data.get("native_tool_calling", True),
268
+ timeout=data.get("timeout", 0),
269
+ description=data.get("description", ""),
270
+ extra_headers=data.get("extra_headers", {}),
271
+ api_token=data.get("api_token", ""),
272
+ )
273
+
274
+
275
+ class ProfileManager:
276
+ """
277
+ Manages LLM configuration profiles.
278
+
279
+ Features:
280
+ - Built-in default profiles (default, fast, claude, openai)
281
+ - User-defined profiles from config.json
282
+ - Active profile switching
283
+ - Adapter instantiation for profiles
284
+ """
285
+
286
+ # Built-in default profiles
287
+ DEFAULT_PROFILES: Dict[str, Dict[str, Any]] = {
288
+ "default": {
289
+ "api_url": "http://localhost:1234",
290
+ "model": "qwen/qwen3-4b",
291
+ "temperature": 0.7,
292
+ "tool_format": "openai",
293
+ "description": "Local LLM for general use",
294
+ },
295
+ "fast": {
296
+ "api_url": "http://localhost:1234",
297
+ "model": "qwen/qwen3-0.6b",
298
+ "temperature": 0.3,
299
+ "tool_format": "openai",
300
+ "description": "Fast local model for quick queries",
301
+ },
302
+ "claude": {
303
+ "api_url": "https://api.anthropic.com",
304
+ "model": "claude-sonnet-4-20250514",
305
+ "temperature": 0.7,
306
+ "max_tokens": 4096,
307
+ "tool_format": "anthropic",
308
+ "description": "Anthropic Claude for complex tasks",
309
+ },
310
+ "openai": {
311
+ "api_url": "https://api.openai.com",
312
+ "model": "gpt-4-turbo",
313
+ "temperature": 0.7,
314
+ "max_tokens": 4096,
315
+ "tool_format": "openai",
316
+ "description": "OpenAI GPT-4 for general tasks",
317
+ },
318
+ }
319
+
320
+ def __init__(self, config=None):
321
+ """
322
+ Initialize profile manager.
323
+
324
+ Args:
325
+ config: Configuration object with get() method
326
+ """
327
+ self.config = config
328
+ self._profiles: Dict[str, LLMProfile] = {}
329
+ self._active_profile_name: str = "default"
330
+ self._load_profiles()
331
+ # Note: Default profile initialization is now handled by config_utils.initialize_config()
332
+ # which runs earlier in app startup and creates global/local config with profiles
333
+
334
+ def _load_profiles(self) -> None:
335
+ """Load profiles from defaults and config file.
336
+
337
+ Reads directly from config FILE (not cached config object) to ensure
338
+ we always get the latest saved values.
339
+ """
340
+ # Start with built-in defaults
341
+ for name, data in self.DEFAULT_PROFILES.items():
342
+ self._profiles[name] = LLMProfile.from_dict(name, data)
343
+
344
+ # Read profiles directly from config file (not cached config object)
345
+ # This ensures we get the latest saved values after save_profile_values_to_config
346
+ user_profiles, active_profile, default_profile = self._read_profiles_from_file()
347
+
348
+ if user_profiles:
349
+ for name, data in user_profiles.items():
350
+ if isinstance(data, dict):
351
+ self._profiles[name] = LLMProfile.from_dict(name, data)
352
+ logger.debug(f"Loaded user profile: {name}")
353
+
354
+ # Load active profile (last used) - takes priority
355
+ if active_profile and active_profile in self._profiles:
356
+ self._active_profile_name = active_profile
357
+ elif default_profile and default_profile in self._profiles:
358
+ self._active_profile_name = default_profile
359
+
360
+ logger.info(
361
+ f"Loaded {len(self._profiles)} profiles, active: {self._active_profile_name}"
362
+ )
363
+
364
+ def _read_profiles_from_file(self) -> tuple:
365
+ """Read profiles directly from global config file.
366
+
367
+ Profiles are user-level settings and only stored globally.
368
+
369
+ Returns:
370
+ Tuple of (profiles_dict, active_profile, default_profile)
371
+ """
372
+ global_config = Path.home() / ".kollabor-cli" / "config.json"
373
+
374
+ if global_config.exists():
375
+ try:
376
+ config_data = json.loads(global_config.read_text(encoding="utf-8"))
377
+ llm_config = config_data.get("core", {}).get("llm", {})
378
+ profiles = llm_config.get("profiles", {})
379
+ active = llm_config.get("active_profile")
380
+ default = llm_config.get("default_profile", "default")
381
+
382
+ if profiles:
383
+ logger.debug(f"Loaded profiles from: {global_config}")
384
+ return profiles, active, default
385
+ except Exception as e:
386
+ logger.warning(f"Failed to read profiles from {global_config}: {e}")
387
+
388
+ # Fallback to config object if file read fails
389
+ if self.config:
390
+ return (
391
+ self.config.get("core.llm.profiles", {}),
392
+ self.config.get("core.llm.active_profile"),
393
+ self.config.get("core.llm.default_profile", "default")
394
+ )
395
+
396
+ return {}, None, "default"
397
+
398
+ def get_profile(self, name: str) -> Optional[LLMProfile]:
399
+ """
400
+ Get a profile by name.
401
+
402
+ Args:
403
+ name: Profile name
404
+
405
+ Returns:
406
+ LLMProfile or None if not found
407
+ """
408
+ return self._profiles.get(name)
409
+
410
+ def get_active_profile(self) -> LLMProfile:
411
+ """
412
+ Get the currently active profile.
413
+
414
+ Returns:
415
+ Active LLMProfile (falls back to "default" if needed)
416
+ """
417
+ profile = self._profiles.get(self._active_profile_name)
418
+ if not profile:
419
+ logger.warning(
420
+ f"Active profile '{self._active_profile_name}' not found, "
421
+ "falling back to 'default'"
422
+ )
423
+ profile = self._profiles.get("default")
424
+ if not profile:
425
+ # Create minimal default profile
426
+ profile = LLMProfile(
427
+ name="default",
428
+ api_url="http://localhost:1234",
429
+ model="default",
430
+ )
431
+ return profile
432
+
433
+ def set_active_profile(self, name: str, persist: bool = True) -> bool:
434
+ """
435
+ Set the active profile.
436
+
437
+ If profile doesn't exist but env vars are set (KOLLABOR_{NAME}_ENDPOINT
438
+ and KOLLABOR_{NAME}_TOKEN), auto-creates the profile from env vars.
439
+
440
+ Args:
441
+ name: Profile name to activate
442
+ persist: If True, save the selection to config for next startup
443
+
444
+ Returns:
445
+ True if successful, False if profile not found and can't be created
446
+ """
447
+ if name not in self._profiles:
448
+ # Try to auto-create from env vars
449
+ if self._try_create_profile_from_env(name):
450
+ logger.info(f"Auto-created profile '{name}' from environment variables")
451
+ else:
452
+ logger.error(f"Profile not found: {name}")
453
+ return False
454
+
455
+ old_profile = self._active_profile_name
456
+ self._active_profile_name = name
457
+ logger.info(f"Switched profile: {old_profile} -> {name}")
458
+
459
+ # Persist to config so it survives restart
460
+ if persist:
461
+ self._save_active_profile_to_config(name)
462
+
463
+ return True
464
+
465
+ def _try_create_profile_from_env(self, name: str) -> bool:
466
+ """
467
+ Try to create a profile from environment variables.
468
+
469
+ Checks for KOLLABOR_{NAME}_ENDPOINT and KOLLABOR_{NAME}_TOKEN.
470
+ If both are set, creates a minimal profile that will read all
471
+ values from env vars at runtime.
472
+
473
+ Args:
474
+ name: Profile name to create
475
+
476
+ Returns:
477
+ True if profile was created, False if required env vars missing
478
+ """
479
+ # Normalize name for env var lookup (same logic as LLMProfile._get_env_key)
480
+ name_normalized = re.sub(r'[^a-zA-Z0-9]', '_', name.strip()).upper()
481
+ endpoint_key = f"KOLLABOR_{name_normalized}_ENDPOINT"
482
+ token_key = f"KOLLABOR_{name_normalized}_TOKEN"
483
+
484
+ endpoint = os.environ.get(endpoint_key, "").strip()
485
+ token = os.environ.get(token_key, "").strip()
486
+
487
+ # Require at least endpoint to create profile
488
+ if not endpoint:
489
+ logger.debug(f"Cannot auto-create profile '{name}': {endpoint_key} not set")
490
+ return False
491
+
492
+ # Create minimal profile - it will read all values from env vars at runtime
493
+ profile = LLMProfile(
494
+ name=name,
495
+ api_url=endpoint, # Fallback if env var unset later
496
+ model="", # Will be read from env var
497
+ description=f"Auto-created from environment variables",
498
+ )
499
+
500
+ self._profiles[name] = profile
501
+ logger.info(f"Created profile '{name}' from env vars ({endpoint_key})")
502
+ return True
503
+
504
+ def _save_active_profile_to_config(self, name: str) -> bool:
505
+ """
506
+ Save the active profile name to global config.json.
507
+
508
+ Profiles are user-wide settings, so they're saved to global config
509
+ (~/.kollabor-cli/config.json) to be available across all projects.
510
+
511
+ Args:
512
+ name: Profile name to save as active
513
+
514
+ Returns:
515
+ True if saved successfully
516
+ """
517
+ try:
518
+ # Profiles are user-wide, always save to global config
519
+ config_path = Path.home() / ".kollabor-cli" / "config.json"
520
+
521
+ if not config_path.exists():
522
+ logger.warning(f"Config file not found: {config_path}")
523
+ return False
524
+
525
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
526
+
527
+ # Ensure core.llm exists
528
+ if "core" not in config_data:
529
+ config_data["core"] = {}
530
+ if "llm" not in config_data["core"]:
531
+ config_data["core"]["llm"] = {}
532
+
533
+ # Save active profile
534
+ config_data["core"]["llm"]["active_profile"] = name
535
+
536
+ config_path.write_text(
537
+ json.dumps(config_data, indent=2, ensure_ascii=False),
538
+ encoding="utf-8"
539
+ )
540
+
541
+ logger.debug(f"Saved active profile to config: {name}")
542
+ return True
543
+
544
+ except Exception as e:
545
+ logger.error(f"Failed to save active profile to config: {e}")
546
+ return False
547
+
548
+ def list_profiles(self) -> List[LLMProfile]:
549
+ """
550
+ List all available profiles.
551
+
552
+ Returns:
553
+ List of LLMProfile instances
554
+ """
555
+ return list(self._profiles.values())
556
+
557
+ def get_profile_names(self) -> List[str]:
558
+ """
559
+ Get list of profile names.
560
+
561
+ Returns:
562
+ List of profile name strings
563
+ """
564
+ return list(self._profiles.keys())
565
+
566
+ def add_profile(self, profile: LLMProfile) -> bool:
567
+ """
568
+ Add a new profile.
569
+
570
+ Args:
571
+ profile: LLMProfile to add
572
+
573
+ Returns:
574
+ True if added, False if name already exists
575
+ """
576
+ if profile.name in self._profiles:
577
+ logger.warning(f"Profile already exists: {profile.name}")
578
+ return False
579
+
580
+ self._profiles[profile.name] = profile
581
+ logger.info(f"Added profile: {profile.name}")
582
+ return True
583
+
584
+ def remove_profile(self, name: str) -> bool:
585
+ """
586
+ Remove a profile.
587
+
588
+ Cannot remove built-in profiles or the current active profile.
589
+
590
+ Args:
591
+ name: Profile name to remove
592
+
593
+ Returns:
594
+ True if removed, False if protected or not found
595
+ """
596
+ if name in self.DEFAULT_PROFILES:
597
+ logger.error(f"Cannot remove built-in profile: {name}")
598
+ return False
599
+
600
+ if name == self._active_profile_name:
601
+ logger.error(f"Cannot remove active profile: {name}")
602
+ return False
603
+
604
+ if name not in self._profiles:
605
+ logger.error(f"Profile not found: {name}")
606
+ return False
607
+
608
+ del self._profiles[name]
609
+ logger.info(f"Removed profile: {name}")
610
+ return True
611
+
612
+ def delete_profile(self, name: str) -> bool:
613
+ """
614
+ Delete a profile from memory and config file.
615
+
616
+ Cannot delete built-in profiles or the current active profile.
617
+
618
+ Args:
619
+ name: Profile name to delete
620
+
621
+ Returns:
622
+ True if deleted successfully, False otherwise
623
+ """
624
+ if name in self.DEFAULT_PROFILES:
625
+ logger.error(f"Cannot delete built-in profile: {name}")
626
+ return False
627
+
628
+ if name == self._active_profile_name:
629
+ logger.error(f"Cannot delete active profile: {name}")
630
+ return False
631
+
632
+ if name not in self._profiles:
633
+ logger.error(f"Profile not found: {name}")
634
+ return False
635
+
636
+ # Remove from memory
637
+ del self._profiles[name]
638
+
639
+ # Remove from config file
640
+ self._delete_profile_from_config(name)
641
+
642
+ logger.info(f"Deleted profile: {name}")
643
+ return True
644
+
645
+ def _delete_profile_from_config(self, name: str) -> bool:
646
+ """
647
+ Delete a profile from global config.json.
648
+
649
+ Profiles are user-wide settings, so they're deleted from global config
650
+ (~/.kollabor-cli/config.json).
651
+
652
+ Args:
653
+ name: Profile name to delete
654
+
655
+ Returns:
656
+ True if deleted successfully from config
657
+ """
658
+ try:
659
+ # Profiles are user-wide, always use global config
660
+ config_path = Path.home() / ".kollabor-cli" / "config.json"
661
+
662
+ if not config_path.exists():
663
+ logger.warning(f"Config file not found: {config_path}")
664
+ return True # No config file, nothing to delete
665
+
666
+ # Load current config
667
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
668
+
669
+ # Check if profile exists in config
670
+ profiles = config_data.get("core", {}).get("llm", {}).get("profiles", {})
671
+ if name not in profiles:
672
+ logger.debug(f"Profile '{name}' not in config file")
673
+ return True # Not in config, nothing to delete
674
+
675
+ # Remove profile from config
676
+ del config_data["core"]["llm"]["profiles"][name]
677
+
678
+ # Write back
679
+ config_path.write_text(
680
+ json.dumps(config_data, indent=2, ensure_ascii=False),
681
+ encoding="utf-8"
682
+ )
683
+
684
+ logger.info(f"Deleted profile from config: {name}")
685
+ return True
686
+
687
+ except Exception as e:
688
+ logger.error(f"Failed to delete profile from config: {e}")
689
+ return False
690
+
691
+ def get_adapter_for_profile(
692
+ self, profile: Optional[LLMProfile] = None
693
+ ) -> BaseAPIAdapter:
694
+ """
695
+ Get the appropriate API adapter for a profile.
696
+
697
+ Args:
698
+ profile: Profile to get adapter for (default: active profile)
699
+
700
+ Returns:
701
+ Configured API adapter instance
702
+ """
703
+ if profile is None:
704
+ profile = self.get_active_profile()
705
+
706
+ if profile.tool_format == "anthropic":
707
+ return AnthropicAdapter(base_url=profile.api_url)
708
+ else:
709
+ return OpenAIAdapter(base_url=profile.api_url)
710
+
711
+ def get_active_adapter(self) -> BaseAPIAdapter:
712
+ """
713
+ Get adapter for the active profile.
714
+
715
+ Returns:
716
+ Configured API adapter instance
717
+ """
718
+ return self.get_adapter_for_profile(self.get_active_profile())
719
+
720
+ def is_active(self, name: str) -> bool:
721
+ """
722
+ Check if a profile is the active one.
723
+
724
+ Args:
725
+ name: Profile name
726
+
727
+ Returns:
728
+ True if this is the active profile
729
+ """
730
+ return name == self._active_profile_name
731
+
732
+ @property
733
+ def active_profile_name(self) -> str:
734
+ """Get the name of the active profile."""
735
+ return self._active_profile_name
736
+
737
+ def _get_normalized_name(self, name: str) -> str:
738
+ """Get normalized profile name for env var prefix.
739
+
740
+ Strips whitespace and replaces all non-alphanumeric characters with
741
+ underscores, then uppercases the result.
742
+
743
+ Args:
744
+ name: The profile name to normalize
745
+
746
+ Returns:
747
+ Normalized name suitable for env var prefix
748
+
749
+ Examples:
750
+ "my-profile" -> "MY_PROFILE"
751
+ "my.profile" -> "MY_PROFILE"
752
+ "My Profile!" -> "MY_PROFILE_"
753
+ " fast " -> "FAST"
754
+ """
755
+ return re.sub(r'[^a-zA-Z0-9]', '_', name.strip()).upper()
756
+
757
+ def _check_name_collision(self, new_name: str, exclude_name: Optional[str] = None) -> Optional[str]:
758
+ """Check if new profile name would collide with existing profiles.
759
+
760
+ Two profile names collide if they normalize to the same env var prefix,
761
+ which would cause them to share the same environment variables.
762
+
763
+ Args:
764
+ new_name: The proposed profile name
765
+ exclude_name: Profile name to exclude from check (for renames)
766
+
767
+ Returns:
768
+ Name of colliding profile if collision found, None otherwise.
769
+ """
770
+ new_normalized = self._get_normalized_name(new_name)
771
+ for existing_name in self._profiles:
772
+ if existing_name == exclude_name:
773
+ continue
774
+ if self._get_normalized_name(existing_name) == new_normalized:
775
+ return existing_name
776
+ return None
777
+
778
+ def get_profile_summary(self, name: Optional[str] = None) -> str:
779
+ """
780
+ Get a human-readable summary of a profile.
781
+
782
+ Args:
783
+ name: Profile name (default: active profile)
784
+
785
+ Returns:
786
+ Formatted summary string
787
+ """
788
+ profile = self._profiles.get(name) if name else self.get_active_profile()
789
+ if not profile:
790
+ return f"Profile '{name}' not found"
791
+
792
+ hints = profile.get_env_var_hints()
793
+ token_status = "[set]" if hints["token"].is_set else "[not set]"
794
+
795
+ native_mode = "native" if profile.get_native_tool_calling() else "xml"
796
+ lines = [
797
+ f"Profile: {profile.name}",
798
+ f" Endpoint: {profile.get_endpoint() or '(not configured)'}",
799
+ f" Model: {profile.get_model() or '(not configured)'}",
800
+ f" Token: {hints['token'].name} {token_status}",
801
+ f" Temperature: {profile.get_temperature()}",
802
+ f" Max Tokens: {profile.get_max_tokens() or '(API default)'}",
803
+ f" Timeout: {profile.get_timeout()}ms",
804
+ f" Tool Format: {profile.get_tool_format()}",
805
+ f" Tool Calling: {native_mode}",
806
+ ]
807
+ if profile.description:
808
+ lines.append(f" Description: {profile.description}")
809
+
810
+ return "\n".join(lines)
811
+
812
+ def create_profile(
813
+ self,
814
+ name: str,
815
+ api_url: str,
816
+ model: str,
817
+ api_token: Optional[str] = None,
818
+ temperature: float = 0.7,
819
+ max_tokens: Optional[int] = None,
820
+ tool_format: str = "openai",
821
+ native_tool_calling: bool = True,
822
+ timeout: int = 0,
823
+ description: str = "",
824
+ save_to_config: bool = True,
825
+ ) -> Optional[LLMProfile]:
826
+ """
827
+ Create a new profile and optionally save to config.
828
+
829
+ Args:
830
+ name: Profile name
831
+ api_url: API endpoint URL
832
+ model: Model identifier
833
+ api_token: API token (optional, can use env var instead)
834
+ temperature: Sampling temperature
835
+ max_tokens: Max tokens (None for unlimited)
836
+ tool_format: Tool calling format (openai/anthropic)
837
+ native_tool_calling: Enable native API tool calling (True) or XML mode (False)
838
+ timeout: Request timeout
839
+ description: Human-readable description
840
+ save_to_config: Whether to persist to config.json
841
+
842
+ Returns:
843
+ Created LLMProfile or None on failure
844
+ """
845
+ if name in self._profiles:
846
+ logger.warning(f"Profile already exists: {name}")
847
+ return None
848
+
849
+ # Check for env var prefix collision
850
+ collision = self._check_name_collision(name)
851
+ if collision:
852
+ logger.error(f"Cannot create profile '{name}': env var prefix collides with "
853
+ f"existing profile '{collision}' (both normalize to "
854
+ f"KOLLABOR_{self._get_normalized_name(name)}_*)")
855
+ return None
856
+
857
+ profile = LLMProfile(
858
+ name=name,
859
+ api_url=api_url,
860
+ model=model,
861
+ api_token=api_token,
862
+ temperature=temperature,
863
+ max_tokens=max_tokens,
864
+ tool_format=tool_format,
865
+ native_tool_calling=native_tool_calling,
866
+ timeout=timeout,
867
+ description=description,
868
+ )
869
+
870
+ self._profiles[name] = profile
871
+ logger.info(f"Created profile: {name}")
872
+
873
+ if save_to_config:
874
+ self._save_profile_to_config(profile)
875
+
876
+ return profile
877
+
878
+ def _save_profile_to_config(self, profile: LLMProfile) -> bool:
879
+ """
880
+ Save a profile to global config.json.
881
+
882
+ Profiles are user-wide settings, so they're saved to global config
883
+ (~/.kollabor-cli/config.json) to be available across all projects.
884
+
885
+ Args:
886
+ profile: Profile to save
887
+
888
+ Returns:
889
+ True if saved successfully
890
+ """
891
+ try:
892
+ # Profiles are user-wide, always save to global config
893
+ config_path = Path.home() / ".kollabor-cli" / "config.json"
894
+
895
+ if not config_path.exists():
896
+ logger.error(f"Config file not found: {config_path}")
897
+ return False
898
+
899
+ # Load current config
900
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
901
+
902
+ # Ensure core.llm.profiles exists
903
+ if "core" not in config_data:
904
+ config_data["core"] = {}
905
+ if "llm" not in config_data["core"]:
906
+ config_data["core"]["llm"] = {}
907
+ if "profiles" not in config_data["core"]["llm"]:
908
+ config_data["core"]["llm"]["profiles"] = {}
909
+
910
+ # Add profile (without name field, as it's the key)
911
+ profile_data = profile.to_dict()
912
+ del profile_data["name"] # Name is the key
913
+ config_data["core"]["llm"]["profiles"][profile.name] = profile_data
914
+
915
+ # Write back
916
+ config_path.write_text(
917
+ json.dumps(config_data, indent=2, ensure_ascii=False),
918
+ encoding="utf-8"
919
+ )
920
+
921
+ logger.info(f"Saved profile to config: {profile.name}")
922
+ return True
923
+
924
+ except Exception as e:
925
+ logger.error(f"Failed to save profile to config: {e}")
926
+ return False
927
+
928
+ def save_profile_values_to_config(self, profile: LLMProfile) -> Dict[str, bool]:
929
+ """
930
+ Save a profile's RESOLVED values (from env vars) to global config.
931
+
932
+ Profiles are user-level settings and stored globally only.
933
+ This reads current values using the profile's getter methods
934
+ (which resolve env vars first) and saves them to config.
935
+
936
+ Args:
937
+ profile: Profile whose resolved values to save
938
+
939
+ Returns:
940
+ Dict with "global" key indicating success
941
+ """
942
+ # Build profile data from resolved getters (reads env vars)
943
+ profile_data = {
944
+ "api_url": profile.get_endpoint(),
945
+ "model": profile.get_model(),
946
+ "temperature": profile.get_temperature(),
947
+ "max_tokens": profile.get_max_tokens(),
948
+ "timeout": profile.get_timeout(),
949
+ "tool_format": profile.get_tool_format(),
950
+ "native_tool_calling": profile.get_native_tool_calling(),
951
+ }
952
+
953
+ # Only include token if it's set (don't save None)
954
+ token = profile.get_token()
955
+ if token:
956
+ profile_data["api_token"] = token
957
+
958
+ # Include description if set
959
+ if profile.description:
960
+ profile_data["description"] = profile.description
961
+
962
+ # Include extra_headers if set
963
+ if profile.extra_headers:
964
+ profile_data["extra_headers"] = profile.extra_headers
965
+
966
+ # Profiles are user-level settings, always save to global
967
+ global_config = Path.home() / ".kollabor-cli" / "config.json"
968
+
969
+ result = {"global": False, "local": False}
970
+ result["global"] = self._save_profile_data_to_file(
971
+ global_config, profile.name, profile_data
972
+ )
973
+
974
+ if result["global"]:
975
+ logger.info(f"Saved profile '{profile.name}' to global config")
976
+ else:
977
+ logger.error(f"Failed to save profile '{profile.name}' to config")
978
+
979
+ return result
980
+
981
+ def _save_profile_data_to_file(self, config_path: Path, profile_name: str,
982
+ profile_data: Dict[str, Any]) -> bool:
983
+ """
984
+ Save profile data to a specific config file.
985
+
986
+ Args:
987
+ config_path: Path to config.json file
988
+ profile_name: Name of the profile (used as key)
989
+ profile_data: Profile data dictionary to save
990
+
991
+ Returns:
992
+ True if saved successfully
993
+ """
994
+ try:
995
+ if not config_path.exists():
996
+ logger.debug(f"Config file not found, skipping: {config_path}")
997
+ return False
998
+
999
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
1000
+
1001
+ # Ensure core.llm.profiles exists
1002
+ if "core" not in config_data:
1003
+ config_data["core"] = {}
1004
+ if "llm" not in config_data["core"]:
1005
+ config_data["core"]["llm"] = {}
1006
+ if "profiles" not in config_data["core"]["llm"]:
1007
+ config_data["core"]["llm"]["profiles"] = {}
1008
+
1009
+ config_data["core"]["llm"]["profiles"][profile_name] = profile_data
1010
+
1011
+ config_path.write_text(
1012
+ json.dumps(config_data, indent=2, ensure_ascii=False),
1013
+ encoding="utf-8"
1014
+ )
1015
+
1016
+ logger.debug(f"Saved profile to: {config_path}")
1017
+ return True
1018
+
1019
+ except Exception as e:
1020
+ logger.error(f"Failed to save profile to {config_path}: {e}")
1021
+ return False
1022
+
1023
+ def reload(self) -> None:
1024
+ """Reload profiles from config file, preserving current active profile."""
1025
+ # Preserve current active profile name
1026
+ current_active = self._active_profile_name
1027
+ self._profiles.clear()
1028
+ self._load_profiles()
1029
+ # Restore active profile if it still exists, otherwise keep what _load_profiles set
1030
+ if current_active in self._profiles:
1031
+ self._active_profile_name = current_active
1032
+ logger.debug(f"Reloaded {len(self._profiles)} profiles, active: {self._active_profile_name}")
1033
+
1034
+ def update_profile(
1035
+ self,
1036
+ original_name: str,
1037
+ new_name: str = None,
1038
+ api_url: str = None,
1039
+ model: str = None,
1040
+ api_token: str = None,
1041
+ temperature: float = None,
1042
+ max_tokens: Optional[int] = None,
1043
+ tool_format: str = None,
1044
+ native_tool_calling: bool = None,
1045
+ timeout: int = None,
1046
+ description: str = None,
1047
+ save_to_config: bool = True,
1048
+ ) -> bool:
1049
+ """
1050
+ Update an existing profile.
1051
+
1052
+ Args:
1053
+ original_name: Current name of the profile to update
1054
+ new_name: New name for the profile (optional, for renaming)
1055
+ api_url: New API endpoint URL
1056
+ model: New model identifier
1057
+ api_token: New API token
1058
+ temperature: New sampling temperature
1059
+ max_tokens: New max tokens
1060
+ tool_format: New tool calling format
1061
+ native_tool_calling: Enable native API tool calling (True) or XML mode (False)
1062
+ timeout: New request timeout
1063
+ description: New description
1064
+ save_to_config: Whether to persist to config.json
1065
+
1066
+ Returns:
1067
+ True if updated successfully, False otherwise
1068
+ """
1069
+ if original_name not in self._profiles:
1070
+ logger.error(f"Profile not found: {original_name}")
1071
+ return False
1072
+
1073
+ profile = self._profiles[original_name]
1074
+ target_name = new_name or original_name
1075
+
1076
+ # If renaming, check for collision and warn about env var change
1077
+ if new_name and new_name != original_name:
1078
+ collision = self._check_name_collision(new_name, exclude_name=original_name)
1079
+ if collision:
1080
+ logger.error(f"Cannot rename to '{new_name}': env var prefix collides with "
1081
+ f"existing profile '{collision}'")
1082
+ return False
1083
+
1084
+ # Warn user about env var change
1085
+ old_prefix = self._get_normalized_name(original_name)
1086
+ new_prefix = self._get_normalized_name(new_name)
1087
+ if old_prefix != new_prefix:
1088
+ logger.warning(f"Profile renamed: env vars changed from KOLLABOR_{old_prefix}_* "
1089
+ f"to KOLLABOR_{new_prefix}_*. Update your environment variables.")
1090
+
1091
+ # Update profile fields
1092
+ if api_url is not None:
1093
+ profile.api_url = api_url
1094
+ if model is not None:
1095
+ profile.model = model
1096
+ if api_token is not None:
1097
+ profile.api_token = api_token
1098
+ if temperature is not None:
1099
+ profile.temperature = temperature
1100
+ if max_tokens is not None:
1101
+ profile.max_tokens = max_tokens
1102
+ if tool_format is not None:
1103
+ profile.tool_format = tool_format
1104
+ if native_tool_calling is not None:
1105
+ profile.native_tool_calling = native_tool_calling
1106
+ if timeout is not None:
1107
+ profile.timeout = timeout
1108
+ if description is not None:
1109
+ profile.description = description
1110
+
1111
+ # Handle renaming
1112
+ if new_name and new_name != original_name:
1113
+ profile.name = new_name
1114
+ del self._profiles[original_name]
1115
+ self._profiles[new_name] = profile
1116
+
1117
+ # Update active profile name if this was the active one
1118
+ if self._active_profile_name == original_name:
1119
+ self._active_profile_name = new_name
1120
+
1121
+ logger.info(f"Renamed profile: {original_name} -> {new_name}")
1122
+
1123
+ logger.info(f"Updated profile: {target_name}")
1124
+
1125
+ if save_to_config:
1126
+ self._update_profile_in_config(original_name, profile)
1127
+
1128
+ return True
1129
+
1130
+ def _update_profile_in_config(self, original_name: str, profile: LLMProfile) -> bool:
1131
+ """
1132
+ Update a profile in global config.json.
1133
+
1134
+ Profiles are user-wide settings, so they're saved to global config
1135
+ (~/.kollabor-cli/config.json) to be available across all projects.
1136
+
1137
+ Args:
1138
+ original_name: Original profile name (for removal if renamed)
1139
+ profile: Updated profile to save
1140
+
1141
+ Returns:
1142
+ True if saved successfully
1143
+ """
1144
+ try:
1145
+ # Profiles are user-wide, always save to global config
1146
+ config_path = Path.home() / ".kollabor-cli" / "config.json"
1147
+
1148
+ if not config_path.exists():
1149
+ logger.error(f"Config file not found: {config_path}")
1150
+ return False
1151
+
1152
+ # Load current config
1153
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
1154
+
1155
+ # Ensure core.llm.profiles exists
1156
+ if "core" not in config_data:
1157
+ config_data["core"] = {}
1158
+ if "llm" not in config_data["core"]:
1159
+ config_data["core"]["llm"] = {}
1160
+ if "profiles" not in config_data["core"]["llm"]:
1161
+ config_data["core"]["llm"]["profiles"] = {}
1162
+
1163
+ # Remove old profile if it was renamed
1164
+ if original_name != profile.name and original_name in config_data["core"]["llm"]["profiles"]:
1165
+ del config_data["core"]["llm"]["profiles"][original_name]
1166
+
1167
+ # Add/update profile (without name field, as it's the key)
1168
+ profile_data = profile.to_dict()
1169
+ del profile_data["name"] # Name is the key
1170
+ config_data["core"]["llm"]["profiles"][profile.name] = profile_data
1171
+
1172
+ # Write back
1173
+ config_path.write_text(
1174
+ json.dumps(config_data, indent=2, ensure_ascii=False),
1175
+ encoding="utf-8"
1176
+ )
1177
+
1178
+ logger.info(f"Updated profile in config: {profile.name}")
1179
+ return True
1180
+
1181
+ except Exception as e:
1182
+ logger.error(f"Failed to update profile in config: {e}")
1183
+ return False