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,1063 @@
1
+ """
2
+ Agent and Skill Manager.
3
+
4
+ Manages agents defined in .kollabor-cli/agents/ directories:
5
+ - Each agent has a system_prompt.md and optional skill files
6
+ - Skills are loaded dynamically and appended to system prompt
7
+ - Supports both local (project) and global (user) agent directories
8
+
9
+ Directory structure:
10
+ .kollabor-cli/agents/
11
+ default/
12
+ system_prompt.md
13
+ lint-editor/
14
+ system_prompt.md
15
+ agent.json # Optional config
16
+ create-tasks.md # Skill file
17
+ fix-file.md # Another skill
18
+ """
19
+
20
+ import json
21
+ import logging
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ from core.utils.config_utils import get_global_agents_dir, get_local_agents_dir, get_local_agents_path
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class Skill:
33
+ """
34
+ A skill that can be loaded into an agent's context.
35
+
36
+ Skills are markdown files containing instructions or context
37
+ that can be dynamically loaded during a session.
38
+
39
+ Attributes:
40
+ name: Skill identifier (filename without extension)
41
+ content: Full content of the skill file
42
+ file_path: Path to the skill file
43
+ description: Optional description extracted from file header
44
+ """
45
+
46
+ name: str
47
+ content: str
48
+ file_path: Path
49
+ description: str = ""
50
+
51
+ @classmethod
52
+ def from_file(cls, file_path: Path) -> Optional["Skill"]:
53
+ """
54
+ Load skill from a markdown file.
55
+
56
+ Extracts description from HTML comment at start of file:
57
+ <!-- Description text here -->
58
+
59
+ Args:
60
+ file_path: Path to the .md file
61
+
62
+ Returns:
63
+ Skill instance or None on error
64
+ """
65
+ try:
66
+ content = file_path.read_text(encoding="utf-8")
67
+ except Exception as e:
68
+ logger.error(f"Failed to read skill file {file_path}: {e}")
69
+ return None
70
+
71
+ # Extract description from HTML comment at start
72
+ description = ""
73
+ lines = content.split("\n")
74
+ if lines and lines[0].strip().startswith("<!--"):
75
+ comment_lines = []
76
+ for line in lines:
77
+ comment_lines.append(line)
78
+ if "-->" in line:
79
+ break
80
+ comment_text = "\n".join(comment_lines)
81
+ description = (
82
+ comment_text.replace("<!--", "")
83
+ .replace("-->", "")
84
+ .strip()
85
+ )
86
+
87
+ return cls(
88
+ name=file_path.stem,
89
+ content=content,
90
+ file_path=file_path,
91
+ description=description,
92
+ )
93
+
94
+ def to_dict(self) -> Dict[str, Any]:
95
+ """Convert skill to dictionary representation."""
96
+ return {
97
+ "name": self.name,
98
+ "description": self.description,
99
+ "file_path": str(self.file_path),
100
+ }
101
+
102
+
103
+ @dataclass
104
+ class Agent:
105
+ """
106
+ An agent configuration with system prompt and available skills.
107
+
108
+ Agents are loaded from directories containing:
109
+ - system_prompt.md (required)
110
+ - agent.json (optional config)
111
+ - *.md files (skills)
112
+
113
+ Attributes:
114
+ name: Agent identifier (directory name)
115
+ directory: Path to agent directory
116
+ system_prompt: Base system prompt content
117
+ skills: Available skills (name -> Skill)
118
+ active_skills: Currently loaded skill names
119
+ profile: Optional preferred LLM profile
120
+ description: Human-readable description
121
+ default_skills: Skills to auto-load when agent is activated
122
+ source: 'local' or 'global' - where the agent was loaded from
123
+ overrides_global: True if local agent overrides a global agent with same name
124
+ """
125
+
126
+ name: str
127
+ directory: Path
128
+ system_prompt: str
129
+ skills: Dict[str, Skill] = field(default_factory=dict)
130
+ active_skills: List[str] = field(default_factory=list)
131
+ profile: Optional[str] = None
132
+ description: str = ""
133
+ default_skills: List[str] = field(default_factory=list)
134
+ source: str = "global"
135
+ overrides_global: bool = False
136
+
137
+ @classmethod
138
+ def from_directory(
139
+ cls,
140
+ agent_dir: Path,
141
+ source: str = "global",
142
+ overrides_global: bool = False,
143
+ ) -> Optional["Agent"]:
144
+ """
145
+ Load agent from a directory.
146
+
147
+ Args:
148
+ agent_dir: Path to agent directory
149
+ source: 'local' or 'global' - where the agent was loaded from
150
+ overrides_global: True if local agent overrides a global agent
151
+
152
+ Returns:
153
+ Agent instance or None if invalid
154
+ """
155
+ if not agent_dir.is_dir():
156
+ return None
157
+
158
+ # Load system prompt (required)
159
+ system_prompt_file = agent_dir / "system_prompt.md"
160
+ if not system_prompt_file.exists():
161
+ logger.warning(f"Agent {agent_dir.name} missing system_prompt.md")
162
+ return None
163
+
164
+ try:
165
+ system_prompt = system_prompt_file.read_text(encoding="utf-8")
166
+ except Exception as e:
167
+ logger.error(f"Failed to read system prompt for {agent_dir.name}: {e}")
168
+ return None
169
+
170
+ # Load skills (all .md files except system_prompt.md)
171
+ skills: Dict[str, Skill] = {}
172
+ for md_file in agent_dir.glob("*.md"):
173
+ if md_file.name != "system_prompt.md":
174
+ skill = Skill.from_file(md_file)
175
+ if skill:
176
+ skills[skill.name] = skill
177
+
178
+ # Load optional config
179
+ profile = None
180
+ description = ""
181
+ default_skills: List[str] = []
182
+ config_file = agent_dir / "agent.json"
183
+ if config_file.exists():
184
+ try:
185
+ config = json.loads(config_file.read_text(encoding="utf-8"))
186
+ profile = config.get("profile")
187
+ description = config.get("description", "")
188
+ default_skills = config.get("default_skills", [])
189
+ except Exception as e:
190
+ logger.warning(f"Failed to load agent config for {agent_dir.name}: {e}")
191
+
192
+ return cls(
193
+ name=agent_dir.name,
194
+ directory=agent_dir,
195
+ system_prompt=system_prompt,
196
+ skills=skills,
197
+ profile=profile,
198
+ description=description,
199
+ default_skills=default_skills,
200
+ source=source,
201
+ overrides_global=overrides_global,
202
+ )
203
+
204
+ def get_full_system_prompt(self) -> str:
205
+ """
206
+ Get system prompt with active skills appended.
207
+
208
+ Skills are added under "## Skill: {name}" headers.
209
+
210
+ Returns:
211
+ Combined system prompt string
212
+ """
213
+ parts = [self.system_prompt]
214
+
215
+ for skill_name in self.active_skills:
216
+ if skill_name in self.skills:
217
+ skill = self.skills[skill_name]
218
+ parts.append(f"\n\n## Skill: {skill_name}\n\n{skill.content}")
219
+
220
+ return "\n".join(parts)
221
+
222
+ def load_skill(self, skill_name: str) -> bool:
223
+ """
224
+ Load a skill into active context.
225
+
226
+ Args:
227
+ skill_name: Name of skill to load
228
+
229
+ Returns:
230
+ True if loaded, False if not found
231
+ """
232
+ if skill_name not in self.skills:
233
+ logger.error(f"Skill not found: {skill_name}")
234
+ return False
235
+
236
+ if skill_name not in self.active_skills:
237
+ self.active_skills.append(skill_name)
238
+ logger.info(f"Loaded skill: {skill_name}")
239
+ return True
240
+
241
+ def unload_skill(self, skill_name: str) -> bool:
242
+ """
243
+ Unload a skill from active context.
244
+
245
+ Args:
246
+ skill_name: Name of skill to unload
247
+
248
+ Returns:
249
+ True if unloaded, False if not loaded
250
+ """
251
+ if skill_name in self.active_skills:
252
+ self.active_skills.remove(skill_name)
253
+ logger.info(f"Unloaded skill: {skill_name}")
254
+ return True
255
+ return False
256
+
257
+ def list_skills(self) -> List[Skill]:
258
+ """Get list of available skills."""
259
+ return list(self.skills.values())
260
+
261
+ def get_skill(self, name: str) -> Optional[Skill]:
262
+ """Get a specific skill by name."""
263
+ return self.skills.get(name)
264
+
265
+ def to_dict(self) -> Dict[str, Any]:
266
+ """Convert agent to dictionary representation."""
267
+ return {
268
+ "name": self.name,
269
+ "directory": str(self.directory),
270
+ "description": self.description,
271
+ "profile": self.profile,
272
+ "skills": [s.to_dict() for s in self.skills.values()],
273
+ "active_skills": self.active_skills,
274
+ "source": self.source,
275
+ "overrides_global": self.overrides_global,
276
+ }
277
+
278
+
279
+ class AgentManager:
280
+ """
281
+ Manages agent discovery, loading, and skill management.
282
+
283
+ Searches for agents in:
284
+ 1. Local: .kollabor-cli/agents/ (project-specific, higher priority)
285
+ 2. Global: ~/.kollabor-cli/agents/ (user defaults)
286
+
287
+ Local agents override global agents with the same name.
288
+ """
289
+
290
+ def __init__(self, config=None):
291
+ """
292
+ Initialize agent manager.
293
+
294
+ Args:
295
+ config: Configuration object (optional)
296
+ """
297
+ self.config = config
298
+ self._agents: Dict[str, Agent] = {}
299
+ self._active_agent_name: Optional[str] = None
300
+
301
+ # Agent directories (in discovery order, lowest to highest priority)
302
+ # 1. Global: ~/.kollabor-cli/agents/ (user defaults)
303
+ # 2. Local: .kollabor-cli/agents/ (project-specific, where agents are created)
304
+ self.global_agents_dir = get_global_agents_dir()
305
+ self.local_agents_dir = get_local_agents_dir()
306
+
307
+ self._discover_agents()
308
+
309
+ def _discover_agents(self) -> None:
310
+ """Discover all available agents from directories."""
311
+ # Skip these directory names during discovery
312
+ skip_dirs = {"__pycache__", ".git", ".svn", "node_modules"}
313
+
314
+ # Load from global first (lowest priority)
315
+ if self.global_agents_dir:
316
+ for agent_dir in self.global_agents_dir.iterdir():
317
+ if agent_dir.is_dir() and agent_dir.name not in skip_dirs and not agent_dir.name.startswith("."):
318
+ agent = Agent.from_directory(agent_dir, source="global", overrides_global=False)
319
+ if agent:
320
+ self._agents[agent.name] = agent
321
+ logger.debug(f"Discovered global agent: {agent.name}")
322
+
323
+ # Load from local (higher priority, overrides global)
324
+ if self.local_agents_dir:
325
+ for agent_dir in self.local_agents_dir.iterdir():
326
+ if agent_dir.is_dir() and agent_dir.name not in skip_dirs and not agent_dir.name.startswith("."):
327
+ # Check if this local agent overrides a global one
328
+ overrides = agent_dir.name in self._agents
329
+ agent = Agent.from_directory(agent_dir, source="local", overrides_global=overrides)
330
+ if agent:
331
+ self._agents[agent.name] = agent
332
+ override_msg = " (overrides global)" if overrides else ""
333
+ logger.debug(f"Discovered local agent: {agent.name}{override_msg}")
334
+
335
+ logger.info(f"Discovered {len(self._agents)} agents")
336
+
337
+ def get_agent(self, name: str) -> Optional[Agent]:
338
+ """
339
+ Get agent by name.
340
+
341
+ Args:
342
+ name: Agent name
343
+
344
+ Returns:
345
+ Agent instance or None if not found
346
+ """
347
+ return self._agents.get(name)
348
+
349
+ def get_active_agent(self) -> Optional[Agent]:
350
+ """
351
+ Get the currently active agent.
352
+
353
+ Returns:
354
+ Active Agent or "default" agent or None
355
+ """
356
+ if self._active_agent_name:
357
+ agent = self._agents.get(self._active_agent_name)
358
+ if agent:
359
+ return agent
360
+
361
+ # Fall back to "default" agent
362
+ return self._agents.get("default")
363
+
364
+ def set_active_agent(self, name: str, load_defaults: bool = True) -> bool:
365
+ """
366
+ Set the active agent.
367
+
368
+ Args:
369
+ name: Agent name to activate
370
+ load_defaults: If True, auto-load the agent's default skills
371
+
372
+ Returns:
373
+ True if successful, False if agent not found
374
+ """
375
+ if name not in self._agents:
376
+ logger.error(f"Agent not found: {name}")
377
+ return False
378
+
379
+ old_agent = self._active_agent_name
380
+ self._active_agent_name = name
381
+
382
+ # Auto-load default skills if configured
383
+ agent = self._agents[name]
384
+ if load_defaults and agent.default_skills:
385
+ for skill_name in agent.default_skills:
386
+ if skill_name in agent.skills and skill_name not in agent.active_skills:
387
+ agent.load_skill(skill_name)
388
+ logger.debug(f"Auto-loaded default skill: {skill_name}")
389
+
390
+ logger.info(f"Activated agent: {old_agent} -> {name}")
391
+ return True
392
+
393
+ def clear_active_agent(self) -> None:
394
+ """Clear the active agent (use default or no agent)."""
395
+ self._active_agent_name = None
396
+ logger.info("Cleared active agent")
397
+
398
+ def list_agents(self) -> List[Agent]:
399
+ """
400
+ List all available agents.
401
+
402
+ Returns:
403
+ List of Agent instances
404
+ """
405
+ return list(self._agents.values())
406
+
407
+ def get_agent_names(self) -> List[str]:
408
+ """
409
+ Get list of agent names.
410
+
411
+ Returns:
412
+ List of agent name strings
413
+ """
414
+ return list(self._agents.keys())
415
+
416
+ def has_agent(self, name: str) -> bool:
417
+ """Check if an agent exists."""
418
+ return name in self._agents
419
+
420
+ def list_skills(self, agent_name: Optional[str] = None) -> List[Skill]:
421
+ """
422
+ List skills for an agent.
423
+
424
+ Args:
425
+ agent_name: Agent name (default: active agent)
426
+
427
+ Returns:
428
+ List of Skill instances
429
+ """
430
+ agent = self._agents.get(agent_name) if agent_name else self.get_active_agent()
431
+ if not agent:
432
+ return []
433
+ return agent.list_skills()
434
+
435
+ def load_skill(
436
+ self, skill_name: str, agent_name: Optional[str] = None
437
+ ) -> bool:
438
+ """
439
+ Load a skill into an agent's active context.
440
+
441
+ Args:
442
+ skill_name: Name of skill to load
443
+ agent_name: Agent name (default: active agent)
444
+
445
+ Returns:
446
+ True if loaded, False otherwise
447
+ """
448
+ agent = self._agents.get(agent_name) if agent_name else self.get_active_agent()
449
+ if not agent:
450
+ logger.error("No agent available to load skill")
451
+ return False
452
+
453
+ return agent.load_skill(skill_name)
454
+
455
+ def unload_skill(
456
+ self, skill_name: str, agent_name: Optional[str] = None
457
+ ) -> bool:
458
+ """
459
+ Unload a skill from an agent's active context.
460
+
461
+ Args:
462
+ skill_name: Name of skill to unload
463
+ agent_name: Agent name (default: active agent)
464
+
465
+ Returns:
466
+ True if unloaded, False otherwise
467
+ """
468
+ agent = self._agents.get(agent_name) if agent_name else self.get_active_agent()
469
+ if not agent:
470
+ return False
471
+
472
+ return agent.unload_skill(skill_name)
473
+
474
+ def toggle_default_skill(
475
+ self, skill_name: str, agent_name: Optional[str] = None, scope: str = "project"
476
+ ) -> tuple[bool, bool]:
477
+ """
478
+ Toggle a skill as default (auto-loaded when agent is activated).
479
+
480
+ Args:
481
+ skill_name: Name of skill to toggle
482
+ agent_name: Agent name (default: active agent)
483
+ scope: "project" for .kollabor-cli or "global" for ~/.kollabor-cli
484
+
485
+ Returns:
486
+ Tuple of (success, is_now_default)
487
+ """
488
+ agent = self._agents.get(agent_name) if agent_name else self.get_active_agent()
489
+ if not agent:
490
+ return (False, False)
491
+
492
+ # Check if skill exists
493
+ if skill_name not in agent.skills:
494
+ logger.error(f"Skill not found: {skill_name}")
495
+ return (False, False)
496
+
497
+ # Determine target directory based on scope
498
+ if scope == "global":
499
+ target_dir = self.global_agents_dir / agent.name
500
+ else:
501
+ # Use get_local_agents_path() for creation (creates dir if needed)
502
+ target_dir = get_local_agents_path() / agent.name
503
+
504
+ # Ensure directory exists
505
+ target_dir.mkdir(parents=True, exist_ok=True)
506
+
507
+ # Load existing config from target scope
508
+ config_file = target_dir / "agent.json"
509
+ current_defaults = []
510
+ if config_file.exists():
511
+ try:
512
+ config_data = json.loads(config_file.read_text(encoding="utf-8"))
513
+ current_defaults = config_data.get("default_skills", [])
514
+ except Exception as e:
515
+ logger.error(f"Failed to read {scope} agent.json: {e}")
516
+
517
+ # Toggle default status
518
+ if skill_name in current_defaults:
519
+ current_defaults.remove(skill_name)
520
+ is_default = False
521
+ logger.info(f"Removed skill from {scope} defaults: {skill_name}")
522
+ else:
523
+ current_defaults.append(skill_name)
524
+ is_default = True
525
+ logger.info(f"Added skill to {scope} defaults: {skill_name}")
526
+
527
+ # Save to target scope
528
+ self._save_agent_config_to_path(target_dir, current_defaults, agent)
529
+
530
+ # Reload agent to reflect changes
531
+ self._reload_agent(agent.name)
532
+
533
+ return (True, is_default)
534
+
535
+ def _save_agent_config(self, agent: Agent) -> bool:
536
+ """
537
+ Save agent configuration to agent.json.
538
+
539
+ Args:
540
+ agent: Agent to save config for
541
+
542
+ Returns:
543
+ True if saved, False otherwise
544
+ """
545
+ try:
546
+ config_file = agent.directory / "agent.json"
547
+
548
+ # Build config dict
549
+ agent_json: Dict[str, Any] = {}
550
+ if agent.description:
551
+ agent_json["description"] = agent.description
552
+ if agent.profile:
553
+ agent_json["profile"] = agent.profile
554
+ if agent.default_skills:
555
+ agent_json["default_skills"] = agent.default_skills
556
+
557
+ if agent_json:
558
+ config_file.write_text(
559
+ json.dumps(agent_json, indent=4, ensure_ascii=False),
560
+ encoding="utf-8"
561
+ )
562
+ elif config_file.exists():
563
+ # Remove agent.json if empty
564
+ config_file.unlink()
565
+
566
+ return True
567
+ except Exception as e:
568
+ logger.error(f"Failed to save agent config for {agent.name}: {e}")
569
+ return False
570
+
571
+ def _save_agent_config_to_path(
572
+ self, target_dir: Path, default_skills: List[str], agent: Agent
573
+ ) -> bool:
574
+ """
575
+ Save agent configuration to a specific directory.
576
+
577
+ Args:
578
+ target_dir: Directory to save to
579
+ default_skills: List of default skill names
580
+ agent: Agent instance for reference data
581
+
582
+ Returns:
583
+ True if saved, False otherwise
584
+ """
585
+ try:
586
+ config_file = target_dir / "agent.json"
587
+
588
+ # Load existing config to preserve other fields
589
+ agent_json: Dict[str, Any] = {}
590
+ if config_file.exists():
591
+ try:
592
+ agent_json = json.loads(config_file.read_text(encoding="utf-8"))
593
+ except Exception:
594
+ pass
595
+
596
+ # Update default_skills
597
+ if default_skills:
598
+ agent_json["default_skills"] = default_skills
599
+ elif "default_skills" in agent_json:
600
+ del agent_json["default_skills"]
601
+
602
+ if agent_json:
603
+ config_file.write_text(
604
+ json.dumps(agent_json, indent=4, ensure_ascii=False),
605
+ encoding="utf-8"
606
+ )
607
+ elif config_file.exists():
608
+ config_file.unlink()
609
+
610
+ return True
611
+ except Exception as e:
612
+ logger.error(f"Failed to save agent config to {target_dir}: {e}")
613
+ return False
614
+
615
+ def _reload_agent(self, agent_name: str) -> None:
616
+ """
617
+ Reload an agent from disk to pick up configuration changes.
618
+
619
+ Args:
620
+ agent_name: Name of agent to reload
621
+ """
622
+ # Store active skills before reload
623
+ active_skills = []
624
+ if agent_name in self._agents:
625
+ active_skills = self._agents[agent_name].active_skills.copy()
626
+
627
+ # Reload from disk (local overrides global)
628
+ local_path = self.local_agents_dir / agent_name if self.local_agents_dir else None
629
+ global_path = self.global_agents_dir / agent_name if self.global_agents_dir else None
630
+
631
+ if local_path and local_path.exists():
632
+ # Check if this overrides a global agent
633
+ overrides = global_path and global_path.exists()
634
+ agent = Agent.from_directory(local_path, source="local", overrides_global=overrides)
635
+ if agent:
636
+ self._agents[agent_name] = agent
637
+ elif global_path and global_path.exists():
638
+ agent = Agent.from_directory(global_path, source="global", overrides_global=False)
639
+ if agent:
640
+ self._agents[agent_name] = agent
641
+
642
+ # Restore active skills
643
+ if agent_name in self._agents and active_skills:
644
+ for skill_name in active_skills:
645
+ if skill_name in self._agents[agent_name].skills:
646
+ self._agents[agent_name].load_skill(skill_name)
647
+
648
+ def get_system_prompt(self) -> Optional[str]:
649
+ """
650
+ Get the full system prompt for the active agent.
651
+
652
+ Includes base system prompt and active skills.
653
+
654
+ Returns:
655
+ System prompt string or None if no agent
656
+ """
657
+ agent = self.get_active_agent()
658
+ if agent:
659
+ return agent.get_full_system_prompt()
660
+ return None
661
+
662
+ def get_preferred_profile(self) -> Optional[str]:
663
+ """
664
+ Get the preferred LLM profile for the active agent.
665
+
666
+ Returns:
667
+ Profile name or None
668
+ """
669
+ agent = self.get_active_agent()
670
+ if agent:
671
+ return agent.profile
672
+ return None
673
+
674
+ @property
675
+ def active_agent_name(self) -> Optional[str]:
676
+ """Get the name of the active agent."""
677
+ return self._active_agent_name
678
+
679
+ def is_active(self, name: str) -> bool:
680
+ """Check if an agent is the active one."""
681
+ return name == self._active_agent_name
682
+
683
+ def get_agent_summary(self, name: Optional[str] = None) -> str:
684
+ """
685
+ Get a human-readable summary of an agent.
686
+
687
+ Args:
688
+ name: Agent name (default: active agent)
689
+
690
+ Returns:
691
+ Formatted summary string
692
+ """
693
+ agent = self._agents.get(name) if name else self.get_active_agent()
694
+ if not agent:
695
+ return f"Agent '{name}' not found" if name else "No active agent"
696
+
697
+ lines = [
698
+ f"Agent: {agent.name}",
699
+ f" Directory: {agent.directory}",
700
+ ]
701
+ if agent.description:
702
+ lines.append(f" Description: {agent.description}")
703
+ if agent.profile:
704
+ lines.append(f" Preferred Profile: {agent.profile}")
705
+
706
+ skills = agent.list_skills()
707
+ if skills:
708
+ lines.append(f" Skills ({len(skills)}):")
709
+ for skill in skills:
710
+ active = "*" if skill.name in agent.active_skills else " "
711
+ desc = f" - {skill.description[:40]}..." if skill.description else ""
712
+ lines.append(f" [{active}] {skill.name}{desc}")
713
+ else:
714
+ lines.append(" Skills: none")
715
+
716
+ return "\n".join(lines)
717
+
718
+ def refresh(self) -> None:
719
+ """Re-discover agents from directories, preserving active skills."""
720
+ # Preserve active skills state before refresh
721
+ active_skills_backup: Dict[str, List[str]] = {}
722
+ for name, agent in self._agents.items():
723
+ if agent.active_skills:
724
+ active_skills_backup[name] = list(agent.active_skills)
725
+
726
+ self._agents.clear()
727
+ self._discover_agents()
728
+
729
+ # Restore active skills after refresh
730
+ for name, skills in active_skills_backup.items():
731
+ if name in self._agents:
732
+ self._agents[name].active_skills = skills
733
+
734
+ def create_agent(
735
+ self,
736
+ name: str,
737
+ description: str = "",
738
+ profile: Optional[str] = None,
739
+ system_prompt: str = "",
740
+ default_skills: Optional[List[str]] = None,
741
+ ) -> Optional[Agent]:
742
+ """
743
+ Create a new agent with directory structure.
744
+
745
+ Creates .kollabor-cli/agents/<name>/ directory with:
746
+ - system_prompt.md
747
+ - agent.json (if profile, description, or default_skills specified)
748
+
749
+ Args:
750
+ name: Agent name (becomes directory name)
751
+ description: Agent description
752
+ profile: Preferred LLM profile name
753
+ system_prompt: Base system prompt content
754
+ default_skills: List of skill names to auto-load when agent is activated
755
+
756
+ Returns:
757
+ Created Agent or None on failure
758
+ """
759
+ import json
760
+
761
+ # Check if agent already exists
762
+ if name in self._agents:
763
+ logger.warning(f"Agent already exists: {name}")
764
+ return None
765
+
766
+ # Create in .kollabor-cli/agents/ directory (creates local dir if needed)
767
+ local_path = get_local_agents_path()
768
+ agent_dir = local_path / name
769
+
770
+ if agent_dir.exists():
771
+ logger.warning(f"Agent directory already exists: {agent_dir}")
772
+ return None
773
+
774
+ try:
775
+ # Create directory structure
776
+ agent_dir.mkdir(parents=True, exist_ok=True)
777
+
778
+ # Create system_prompt.md
779
+ default_prompt = system_prompt or f"""# {name.replace('-', ' ').title()} Agent
780
+
781
+ You are a specialized assistant.
782
+
783
+ ## Your Mission
784
+
785
+ {description or 'Help users with their tasks.'}
786
+
787
+ ## Approach
788
+
789
+ 1. Analyze the user's request
790
+ 2. Provide clear, actionable guidance
791
+ 3. Follow best practices
792
+ """
793
+ prompt_file = agent_dir / "system_prompt.md"
794
+ prompt_file.write_text(default_prompt, encoding="utf-8")
795
+
796
+ # Create agent.json if profile, description, or default_skills specified
797
+ if profile or description or default_skills:
798
+ agent_json: Dict[str, Any] = {
799
+ "description": description or f"Agent: {name}",
800
+ }
801
+ if profile and profile != "(none)":
802
+ agent_json["profile"] = profile
803
+ if default_skills:
804
+ agent_json["default_skills"] = default_skills
805
+
806
+ json_file = agent_dir / "agent.json"
807
+ json_file.write_text(
808
+ json.dumps(agent_json, indent=4, ensure_ascii=False),
809
+ encoding="utf-8"
810
+ )
811
+
812
+ # Update local_agents_dir since we just created the local directory
813
+ self.local_agents_dir = get_local_agents_dir()
814
+
815
+ # Load the newly created agent
816
+ # Check if it overrides a global agent
817
+ overrides = (
818
+ self.global_agents_dir is not None
819
+ and (self.global_agents_dir / name).exists()
820
+ )
821
+ agent = Agent.from_directory(agent_dir, source="local", overrides_global=overrides)
822
+ if agent:
823
+ self._agents[name] = agent
824
+ logger.info(f"Created agent: {name} at {agent_dir}")
825
+ return agent
826
+
827
+ return None
828
+
829
+ except Exception as e:
830
+ logger.error(f"Failed to create agent {name}: {e}")
831
+ # Clean up on failure
832
+ if agent_dir.exists():
833
+ import shutil
834
+ shutil.rmtree(agent_dir, ignore_errors=True)
835
+ return None
836
+
837
+ def delete_agent(self, name: str) -> bool:
838
+ """
839
+ Delete an agent by removing its directory.
840
+
841
+ Cannot delete the active agent or protected agents like "default".
842
+
843
+ Args:
844
+ name: Agent name to delete
845
+
846
+ Returns:
847
+ True if deleted, False if cannot delete
848
+ """
849
+ import shutil
850
+
851
+ # Protected agents that cannot be deleted
852
+ protected_agents = {"default"}
853
+
854
+ # Check if agent exists
855
+ if name not in self._agents:
856
+ logger.warning(f"Agent not found: {name}")
857
+ return False
858
+
859
+ # Check if protected
860
+ if name in protected_agents:
861
+ logger.warning(f"Cannot delete protected agent: {name}")
862
+ return False
863
+
864
+ # Check if active
865
+ if self.is_active(name):
866
+ logger.warning(f"Cannot delete active agent: {name}")
867
+ return False
868
+
869
+ agent = self._agents[name]
870
+ agent_dir = agent.directory
871
+
872
+ # Only delete from local directory (never delete global agents)
873
+ if not agent_dir.is_relative_to(self.local_agents_dir):
874
+ logger.warning(f"Cannot delete agent from global directory: {name}")
875
+ return False
876
+
877
+ try:
878
+ # Remove the directory
879
+ shutil.rmtree(agent_dir)
880
+ # Remove from internal dict
881
+ del self._agents[name]
882
+ logger.info(f"Deleted agent: {name}")
883
+ return True
884
+ except Exception as e:
885
+ logger.error(f"Failed to delete agent {name}: {e}")
886
+ return False
887
+
888
+ def load_default_agent(self, cli_agent_name: Optional[str] = None) -> Optional[str]:
889
+ """
890
+ Load the appropriate default agent based on priority.
891
+
892
+ Priority:
893
+ 1. CLI agent name (highest, one-time override)
894
+ 2. Project default (.kollabor-cli/config.json)
895
+ 3. Global default (~/.kollabor-cli/config.json)
896
+ 4. Fallback to "default" agent
897
+
898
+ Args:
899
+ cli_agent_name: Agent name from CLI --agent argument
900
+
901
+ Returns:
902
+ Name of agent that was activated, or None if failed
903
+ """
904
+ from ..utils.config_utils import get_default_agent
905
+
906
+ # Priority 1: CLI argument (one-time override)
907
+ if cli_agent_name:
908
+ if self.set_active_agent(cli_agent_name):
909
+ logger.info(f"Loaded agent from CLI argument: {cli_agent_name}")
910
+ return cli_agent_name
911
+ else:
912
+ logger.warning(f"CLI agent '{cli_agent_name}' not found, trying defaults")
913
+
914
+ # Priority 2: Project default
915
+ project_agent, level = get_default_agent()
916
+ if level == "project" and project_agent:
917
+ if self.set_active_agent(project_agent):
918
+ logger.info(f"Loaded project default agent: {project_agent}")
919
+ return project_agent
920
+ else:
921
+ logger.warning(f"Project default agent '{project_agent}' not found, trying next level")
922
+
923
+ # Priority 3: Global default
924
+ global_agent, level = get_default_agent()
925
+ if level == "global" and global_agent:
926
+ if self.set_active_agent(global_agent):
927
+ logger.info(f"Loaded global default agent: {global_agent}")
928
+ return global_agent
929
+ else:
930
+ logger.warning(f"Global default agent '{global_agent}' not found, trying fallback")
931
+
932
+ # Priority 4: Fallback to "default" agent
933
+ if self.set_active_agent("default", load_defaults=True):
934
+ logger.info("Loaded fallback default agent")
935
+ return "default"
936
+
937
+ logger.error("Failed to load any agent")
938
+ return None
939
+
940
+ def update_agent(
941
+ self,
942
+ original_name: str,
943
+ new_name: str,
944
+ description: str = "",
945
+ profile: Optional[str] = None,
946
+ system_prompt: str = "",
947
+ default_skills: Optional[List[str]] = None,
948
+ ) -> bool:
949
+ """
950
+ Update an existing agent's configuration.
951
+
952
+ Can rename the agent (rename directory), update description,
953
+ profile, system prompt, and default skills. Only works for agents in the
954
+ local directory (.kollabor-cli/agents/).
955
+
956
+ Args:
957
+ original_name: Current name of the agent to update.
958
+ new_name: New name for the agent (can be same as original).
959
+ description: New description.
960
+ profile: New preferred LLM profile name.
961
+ system_prompt: New system prompt content.
962
+ default_skills: List of skill names to auto-load when agent is activated.
963
+
964
+ Returns:
965
+ True if updated successfully, False otherwise.
966
+ """
967
+ import shutil
968
+
969
+ # Check if agent exists
970
+ if original_name not in self._agents:
971
+ logger.warning(f"Agent not found for update: {original_name}")
972
+ return False
973
+
974
+ agent = self._agents[original_name]
975
+ agent_dir = agent.directory
976
+
977
+ # Only update local agents (not global)
978
+ local_path = get_local_agents_path()
979
+ if not self.local_agents_dir or not agent_dir.is_relative_to(self.local_agents_dir):
980
+ logger.warning(f"Cannot edit agent from global directory: {original_name}")
981
+ return False
982
+
983
+ try:
984
+ # If renaming, we need to move the directory
985
+ if new_name != original_name:
986
+ # Check if new name already exists
987
+ if new_name in self._agents:
988
+ logger.warning(f"Agent already exists with new name: {new_name}")
989
+ return False
990
+
991
+ new_agent_dir = local_path / new_name
992
+
993
+ # Check if target directory already exists
994
+ if new_agent_dir.exists():
995
+ logger.warning(f"Target directory already exists: {new_agent_dir}")
996
+ return False
997
+
998
+ # Rename directory
999
+ shutil.move(str(agent_dir), str(new_agent_dir))
1000
+ agent_dir = new_agent_dir
1001
+ logger.info(f"Renamed agent directory: {original_name} -> {new_name}")
1002
+
1003
+ # Update system_prompt.md
1004
+ prompt_file = agent_dir / "system_prompt.md"
1005
+ if system_prompt:
1006
+ prompt_file.write_text(system_prompt, encoding="utf-8")
1007
+ logger.info(f"Updated system prompt for agent: {new_name}")
1008
+
1009
+ # Update or create agent.json for description, profile, and default_skills
1010
+ agent_json: Dict[str, Any] = {}
1011
+ if description or profile or default_skills:
1012
+ agent_json["description"] = description or f"Agent: {new_name}"
1013
+ if profile:
1014
+ agent_json["profile"] = profile
1015
+ if default_skills:
1016
+ agent_json["default_skills"] = default_skills
1017
+
1018
+ if agent_json:
1019
+ json_file = agent_dir / "agent.json"
1020
+ json_file.write_text(
1021
+ json.dumps(agent_json, indent=4, ensure_ascii=False),
1022
+ encoding="utf-8"
1023
+ )
1024
+ logger.info(f"Updated agent.json for agent: {new_name}")
1025
+ elif (agent_dir / "agent.json").exists():
1026
+ # Remove agent.json if no description or profile
1027
+ (agent_dir / "agent.json").unlink()
1028
+
1029
+ # If renamed, remove old entry from dict
1030
+ if new_name != original_name:
1031
+ del self._agents[original_name]
1032
+
1033
+ # Reload the agent from directory
1034
+ # Check if it overrides a global agent
1035
+ overrides = (
1036
+ self.global_agents_dir is not None
1037
+ and (self.global_agents_dir / new_name).exists()
1038
+ )
1039
+ updated_agent = Agent.from_directory(agent_dir, source="local", overrides_global=overrides)
1040
+ if updated_agent:
1041
+ self._agents[new_name] = updated_agent
1042
+
1043
+ # If this was the active agent, update the active name
1044
+ if self._active_agent_name == original_name:
1045
+ self._active_agent_name = new_name
1046
+
1047
+ logger.info(f"Updated agent: {new_name}")
1048
+ return True
1049
+
1050
+ return False
1051
+
1052
+ except Exception as e:
1053
+ logger.error(f"Failed to update agent {original_name}: {e}")
1054
+ # If rename failed, try to revert
1055
+ if new_name != original_name:
1056
+ original_dir = local_path / original_name
1057
+ new_dir = local_path / new_name
1058
+ if not original_dir.exists() and new_dir.exists():
1059
+ try:
1060
+ shutil.move(str(new_dir), str(original_dir))
1061
+ except:
1062
+ pass
1063
+ return False