kolega-code 0.1.0__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 (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,409 @@
1
+ """Agent Skills discovery and activation helpers for the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Callable, Iterable, Optional
9
+
10
+ import yaml
11
+
12
+ from kolega_code.agent import PromptExtension, ToolExtension
13
+ from kolega_code.llm.models import Message
14
+ from kolega_code.agent.prompt_provider import AgentMode
15
+
16
+
17
+ PROJECT_SKILLS_DIR = Path(".agents") / "skills"
18
+ USER_SKILLS_DIR = Path(".agents") / "skills"
19
+ MAX_RESOURCE_FILES = 100
20
+ MAX_RESOURCE_READ_CHARS = 100_000
21
+ SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
22
+ SKILL_CONTENT_RE = re.compile(r'<skill_content name="([^"]+)">')
23
+ SKILL_CATALOG_PROMPT = """The CLI provides Agent Skills discovered from the project and user skill directories.
24
+ Use `list_skills` to inspect available skills and `activate_skill` before applying a skill's workflow.
25
+ Users can also activate skills explicitly with `/skill-name`.
26
+
27
+ Available skills:
28
+
29
+ {catalog}
30
+ """
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SkillDiagnostic:
35
+ severity: str
36
+ message: str
37
+ path: Path
38
+
39
+ def format(self) -> str:
40
+ return f"{self.severity}: {self.message} ({self.path})"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class SkillRecord:
45
+ name: str
46
+ description: str
47
+ skill_dir: Path
48
+ skill_file: Path
49
+ scope: str
50
+
51
+
52
+ @dataclass
53
+ class SkillCatalog:
54
+ skills: dict[str, SkillRecord] = field(default_factory=dict)
55
+ diagnostics: list[SkillDiagnostic] = field(default_factory=list)
56
+
57
+ def has_skills(self) -> bool:
58
+ return bool(self.skills)
59
+
60
+ def get(self, name: str) -> Optional[SkillRecord]:
61
+ return self.skills.get(name)
62
+
63
+ def format_catalog(self, *, include_diagnostics: bool = True) -> str:
64
+ lines: list[str] = []
65
+ if self.skills:
66
+ lines.append("# Available Skills")
67
+ lines.append("")
68
+ for record in self.skills.values():
69
+ lines.append(f"- `/{record.name}` ({record.scope}): {record.description}")
70
+ else:
71
+ lines.append("No Agent Skills found.")
72
+
73
+ if include_diagnostics and self.diagnostics:
74
+ lines.append("")
75
+ lines.append("# Skill Diagnostics")
76
+ for diagnostic in self.diagnostics:
77
+ lines.append(f"- {diagnostic.format()}")
78
+
79
+ return "\n".join(lines)
80
+
81
+ def prompt_catalog(self) -> str:
82
+ catalog_lines = [
83
+ f"- `{record.name}` ({record.scope}): {record.description}" for record in self.skills.values()
84
+ ]
85
+ return SKILL_CATALOG_PROMPT.format(catalog="\n".join(catalog_lines))
86
+
87
+ def activation_content(self, name: str, *, active_names: Optional[set[str]] = None) -> str:
88
+ record = self._require_skill(name)
89
+ active_names = active_names or set()
90
+ if record.name in active_names:
91
+ return f"Skill `{record.name}` is already active in this conversation. Continue using its instructions."
92
+
93
+ _metadata, body = _parse_skill_file(record.skill_file)
94
+ resources, truncated = self.resource_paths(record.name)
95
+ resource_lines = [f" <file>{resource}</file>" for resource in resources]
96
+ if truncated:
97
+ resource_lines.append(f" <truncated>Only the first {MAX_RESOURCE_FILES} resources are listed.</truncated>")
98
+ resource_listing = "\n".join(resource_lines)
99
+
100
+ return (
101
+ f'<skill_content name="{record.name}">\n'
102
+ f"{body}\n\n"
103
+ f"Skill directory: {record.skill_dir}\n"
104
+ "Relative paths in this skill are relative to the skill directory.\n"
105
+ "<skill_resources>\n"
106
+ f"{resource_listing}\n"
107
+ "</skill_resources>\n"
108
+ "</skill_content>"
109
+ )
110
+
111
+ def resource_paths(self, name: str, *, max_files: int = MAX_RESOURCE_FILES) -> tuple[list[str], bool]:
112
+ record = self._require_skill(name)
113
+ root = record.skill_dir
114
+ paths: list[str] = []
115
+
116
+ for path in sorted(root.rglob("*")):
117
+ if len(paths) >= max_files:
118
+ return paths, True
119
+ if not path.is_file():
120
+ continue
121
+ if path.name == "SKILL.md":
122
+ continue
123
+ if any(part in {".git", "node_modules", "__pycache__"} for part in path.relative_to(root).parts):
124
+ continue
125
+ try:
126
+ resolved = path.resolve()
127
+ resolved.relative_to(root.resolve())
128
+ except ValueError:
129
+ continue
130
+ paths.append(path.relative_to(root).as_posix())
131
+
132
+ return paths, False
133
+
134
+ def read_resource(self, name: str, relative_path: str, *, max_chars: int = MAX_RESOURCE_READ_CHARS) -> str:
135
+ record = self._require_skill(name)
136
+ clean_relative_path = relative_path.strip()
137
+ if not clean_relative_path:
138
+ raise ValueError("relative_path must not be empty.")
139
+ requested = Path(clean_relative_path)
140
+ if requested.is_absolute() or ".." in requested.parts:
141
+ raise ValueError("Skill resource path must stay inside the skill directory.")
142
+
143
+ root = record.skill_dir.resolve()
144
+ path = (record.skill_dir / requested).resolve()
145
+ try:
146
+ path.relative_to(root)
147
+ except ValueError as exc:
148
+ raise ValueError("Skill resource path must stay inside the skill directory.") from exc
149
+
150
+ if not path.is_file():
151
+ raise ValueError(f"Skill resource not found: {clean_relative_path}")
152
+
153
+ try:
154
+ content = path.read_text(encoding="utf-8")
155
+ except UnicodeDecodeError as exc:
156
+ raise ValueError(f"Skill resource is not UTF-8 text: {clean_relative_path}") from exc
157
+
158
+ if len(content) <= max_chars:
159
+ return content
160
+ return f"{content[:max_chars]}\n\n[truncated to first {max_chars} characters]"
161
+
162
+ def _require_skill(self, name: str) -> SkillRecord:
163
+ skill_name = name.strip()
164
+ record = self.skills.get(skill_name)
165
+ if record is None:
166
+ raise ValueError(f"Skill not found: {skill_name}")
167
+ return record
168
+
169
+
170
+ def discover_skills(project_path: Path, *, user_home: Optional[Path] = None) -> SkillCatalog:
171
+ """Discover project and user Agent Skills."""
172
+ user_home = user_home or Path.home()
173
+ catalog = SkillCatalog()
174
+ scan_roots = [
175
+ ("user", user_home / USER_SKILLS_DIR),
176
+ ("project", project_path / PROJECT_SKILLS_DIR),
177
+ ]
178
+
179
+ for scope, root in scan_roots:
180
+ for skill_file in _iter_skill_files(root):
181
+ record, diagnostics = _load_skill(skill_file, scope)
182
+ catalog.diagnostics.extend(diagnostics)
183
+ if record is None:
184
+ continue
185
+
186
+ existing = catalog.skills.get(record.name)
187
+ if existing is None:
188
+ catalog.skills[record.name] = record
189
+ continue
190
+
191
+ if existing.scope == "user" and record.scope == "project":
192
+ catalog.diagnostics.append(
193
+ SkillDiagnostic(
194
+ severity="warning",
195
+ message=f"Project skill `{record.name}` overrides user skill at {existing.skill_dir}.",
196
+ path=record.skill_file,
197
+ )
198
+ )
199
+ catalog.skills[record.name] = record
200
+ continue
201
+
202
+ catalog.diagnostics.append(
203
+ SkillDiagnostic(
204
+ severity="warning",
205
+ message=f"Duplicate skill `{record.name}` ignored; already loaded from {existing.skill_dir}.",
206
+ path=record.skill_file,
207
+ )
208
+ )
209
+
210
+ catalog.skills = dict(sorted(catalog.skills.items()))
211
+ return catalog
212
+
213
+
214
+ def build_skill_prompt_extension(catalog: SkillCatalog) -> Optional[PromptExtension]:
215
+ if not catalog.has_skills():
216
+ return None
217
+ return PromptExtension(
218
+ id="cli-agent-skills",
219
+ title="Agent Skills",
220
+ markdown=catalog.prompt_catalog(),
221
+ modes=[AgentMode.CLI],
222
+ )
223
+
224
+
225
+ def build_skill_tool_extension(
226
+ catalog: SkillCatalog,
227
+ history_provider: Callable[[], Iterable[Message]],
228
+ ) -> Optional[ToolExtension]:
229
+ if not catalog.has_skills():
230
+ return None
231
+
232
+ async def list_skills() -> str:
233
+ """
234
+ Return Agent Skills available in this CLI session.
235
+
236
+ Use this when choosing whether a specialized workflow is available.
237
+
238
+ Returns:
239
+ A Markdown list of skill names, scopes, and descriptions.
240
+ """
241
+ return catalog.format_catalog()
242
+
243
+ async def activate_skill(name: str) -> str:
244
+ """
245
+ Load the full instructions for an Agent Skill.
246
+
247
+ Call this before using a skill's specialized workflow. The returned content explains where skill resources live
248
+ and lists bundled resources that can be read with `read_skill_resource`.
249
+
250
+ Args:
251
+ name: The skill name to activate, without a leading slash.
252
+
253
+ Returns:
254
+ The activated skill instructions, or a note if the skill is already active.
255
+ """
256
+ return catalog.activation_content(name, active_names=activated_skill_names(history_provider()))
257
+
258
+ async def read_skill_resource(name: str, relative_path: str) -> str:
259
+ """
260
+ Read a text resource bundled with an activated Agent Skill.
261
+
262
+ Args:
263
+ name: The skill name, without a leading slash.
264
+ relative_path: Path relative to the skill directory.
265
+
266
+ Returns:
267
+ UTF-8 text content from the requested skill resource, capped for context size.
268
+ """
269
+ return catalog.read_resource(name, relative_path)
270
+
271
+ return ToolExtension(
272
+ name="cli-agent-skills",
273
+ tools={
274
+ "list_skills": list_skills,
275
+ "activate_skill": activate_skill,
276
+ "read_skill_resource": read_skill_resource,
277
+ },
278
+ tool_groups={
279
+ "planning_tools": ["list_skills", "activate_skill", "read_skill_resource"],
280
+ "cli_skill_tools": ["list_skills", "activate_skill", "read_skill_resource"],
281
+ },
282
+ )
283
+
284
+
285
+ def activated_skill_names(history: Iterable[Message]) -> set[str]:
286
+ names: set[str] = set()
287
+ for message in history or []:
288
+ for text in _message_text_parts(message):
289
+ names.update(SKILL_CONTENT_RE.findall(text))
290
+ return names
291
+
292
+
293
+ def skill_names_in_text(text: str) -> list[str]:
294
+ return SKILL_CONTENT_RE.findall(text)
295
+
296
+
297
+ def _iter_skill_files(root: Path) -> Iterable[Path]:
298
+ if not root.is_dir():
299
+ return []
300
+ return [path / "SKILL.md" for path in sorted(root.iterdir()) if path.is_dir() and (path / "SKILL.md").is_file()]
301
+
302
+
303
+ def _load_skill(skill_file: Path, scope: str) -> tuple[Optional[SkillRecord], list[SkillDiagnostic]]:
304
+ diagnostics: list[SkillDiagnostic] = []
305
+ try:
306
+ metadata, _body = _parse_skill_file(skill_file)
307
+ except Exception as exc:
308
+ return None, [
309
+ SkillDiagnostic(
310
+ severity="error",
311
+ message=f"Could not parse SKILL.md: {exc}",
312
+ path=skill_file,
313
+ )
314
+ ]
315
+
316
+ name = str(metadata.get("name") or "").strip()
317
+ description = str(metadata.get("description") or "").strip()
318
+
319
+ if not name:
320
+ diagnostics.append(SkillDiagnostic("error", "Skill is missing required `name`.", skill_file))
321
+ return None, diagnostics
322
+ if not description:
323
+ diagnostics.append(SkillDiagnostic("error", f"Skill `{name}` is missing required `description`.", skill_file))
324
+ return None, diagnostics
325
+
326
+ parent_name = skill_file.parent.name
327
+ if name != parent_name:
328
+ diagnostics.append(
329
+ SkillDiagnostic(
330
+ "warning",
331
+ f"Skill name `{name}` does not match directory `{parent_name}`.",
332
+ skill_file,
333
+ )
334
+ )
335
+ if len(name) > 64 or not SKILL_NAME_RE.match(name) or "--" in name:
336
+ diagnostics.append(
337
+ SkillDiagnostic(
338
+ "warning",
339
+ f"Skill name `{name}` does not follow the Agent Skills name convention.",
340
+ skill_file,
341
+ )
342
+ )
343
+
344
+ if len(description) > 1024:
345
+ diagnostics.append(
346
+ SkillDiagnostic(
347
+ "warning",
348
+ f"Skill `{name}` description exceeds 1024 characters.",
349
+ skill_file,
350
+ )
351
+ )
352
+
353
+ return (
354
+ SkillRecord(
355
+ name=name,
356
+ description=description,
357
+ skill_dir=skill_file.parent.resolve(),
358
+ skill_file=skill_file.resolve(),
359
+ scope=scope,
360
+ ),
361
+ diagnostics,
362
+ )
363
+
364
+
365
+ def _parse_skill_file(skill_file: Path) -> tuple[dict, str]:
366
+ text = skill_file.read_text(encoding="utf-8")
367
+ lines = text.splitlines()
368
+ if not lines or lines[0].strip() != "---":
369
+ raise ValueError("missing YAML frontmatter")
370
+
371
+ closing_index = None
372
+ for index, line in enumerate(lines[1:], start=1):
373
+ if line.strip() == "---":
374
+ closing_index = index
375
+ break
376
+ if closing_index is None:
377
+ raise ValueError("missing closing YAML frontmatter delimiter")
378
+
379
+ frontmatter = "\n".join(lines[1:closing_index])
380
+ metadata = yaml.safe_load(frontmatter) or {}
381
+ if not isinstance(metadata, dict):
382
+ raise ValueError("YAML frontmatter must be a mapping")
383
+
384
+ body = "\n".join(lines[closing_index + 1 :]).strip()
385
+ return metadata, body
386
+
387
+
388
+ def _message_text_parts(message: Message) -> Iterable[str]:
389
+ content = getattr(message, "content", None)
390
+ return _content_text_parts(content)
391
+
392
+
393
+ def _content_text_parts(content: object) -> list[str]:
394
+ if isinstance(content, str):
395
+ return [content]
396
+ if not isinstance(content, list):
397
+ return []
398
+
399
+ parts: list[str] = []
400
+ for block in content:
401
+ if hasattr(block, "text"):
402
+ parts.append(str(block.text))
403
+ elif hasattr(block, "content"):
404
+ block_content = block.content
405
+ if isinstance(block_content, str):
406
+ parts.append(block_content)
407
+ else:
408
+ parts.extend(_content_text_parts(block_content))
409
+ return parts
@@ -0,0 +1,108 @@
1
+ """Single source of truth for slash commands across the TUI, the agent, and skills.
2
+
3
+ This module must stay free of Textual imports so ``main.py`` and tests can
4
+ import it cheaply. Agent commands are declared on
5
+ ``CommandProcessor.SPECS`` (the agent package cannot import the CLI package);
6
+ this module aggregates them with TUI commands and dynamically discovered skills.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from typing import TYPE_CHECKING, List
14
+
15
+ from ..agent.utils.commands import CommandProcessor
16
+
17
+ if TYPE_CHECKING:
18
+ from .skills import SkillCatalog
19
+
20
+
21
+ class CommandScope(str, Enum):
22
+ TUI = "tui" # handled by KolegaCodeApp
23
+ AGENT = "agent" # handled by CommandProcessor inside the agent
24
+ SKILL = "skill" # dynamic, from SkillCatalog
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SlashCommandEntry:
29
+ name: str # without the leading "/", e.g. "clear"
30
+ description: str
31
+ scope: CommandScope
32
+
33
+ @property
34
+ def token(self) -> str:
35
+ return f"/{self.name}"
36
+
37
+
38
+ THREAD_RESET_COMMANDS: frozenset[str] = frozenset({"/clear", "/reset"})
39
+ SKILLS_LIST_COMMAND = "/skills"
40
+
41
+ TUI_COMMAND_ENTRIES: tuple[SlashCommandEntry, ...] = (
42
+ SlashCommandEntry("skills", "List available Agent Skills", CommandScope.TUI),
43
+ SlashCommandEntry("plan", "Switch to plan mode", CommandScope.TUI),
44
+ SlashCommandEntry("build", "Switch to build mode", CommandScope.TUI),
45
+ SlashCommandEntry("model", "Show or switch the active model", CommandScope.TUI),
46
+ SlashCommandEntry("copy", "Copy the last response to the clipboard", CommandScope.TUI),
47
+ SlashCommandEntry("version", "Show the Kolega Code version", CommandScope.TUI),
48
+ SlashCommandEntry("quit", "Save the session and exit", CommandScope.TUI),
49
+ )
50
+
51
+ TUI_COMMAND_NAMES: frozenset[str] = frozenset(entry.token for entry in TUI_COMMAND_ENTRIES)
52
+
53
+
54
+ def agent_command_entries() -> tuple[SlashCommandEntry, ...]:
55
+ """Agent built-in commands, derived from the agent's own declarations."""
56
+ return tuple(
57
+ SlashCommandEntry(spec.name.removeprefix("/"), spec.description, CommandScope.AGENT)
58
+ for spec in CommandProcessor.SPECS
59
+ )
60
+
61
+
62
+ def agent_command_names() -> frozenset[str]:
63
+ """Command tokens (with leading "/") handled by the agent's CommandProcessor."""
64
+ return frozenset(spec.name for spec in CommandProcessor.SPECS)
65
+
66
+
67
+ def all_command_entries(skill_catalog: "SkillCatalog | None" = None) -> List[SlashCommandEntry]:
68
+ """All known slash commands: agent built-ins, TUI commands, then skills.
69
+
70
+ De-duplicated by name; agent and TUI commands take precedence over a
71
+ skill that happens to share a name.
72
+ """
73
+ entries: List[SlashCommandEntry] = []
74
+ seen: set[str] = set()
75
+ skill_entries = (
76
+ tuple(
77
+ SlashCommandEntry(record.name, record.description, CommandScope.SKILL)
78
+ for record in skill_catalog.skills.values()
79
+ )
80
+ if skill_catalog is not None
81
+ else ()
82
+ )
83
+ for entry in (*agent_command_entries(), *TUI_COMMAND_ENTRIES, *skill_entries):
84
+ if entry.name in seen:
85
+ continue
86
+ seen.add(entry.name)
87
+ entries.append(entry)
88
+ return entries
89
+
90
+
91
+ def search_commands(
92
+ query: str, skill_catalog: "SkillCatalog | None" = None, limit: int = 8
93
+ ) -> List[SlashCommandEntry]:
94
+ """Filter commands by ``query``: prefix matches first, then substring matches.
95
+
96
+ An empty query returns all commands (capped at ``limit``). Results are
97
+ alphabetical within each tier.
98
+ """
99
+ needle = query.lower()
100
+ entries = all_command_entries(skill_catalog)
101
+ if not needle:
102
+ return sorted(entries, key=lambda entry: entry.name)[:limit]
103
+ prefix = [entry for entry in entries if entry.name.lower().startswith(needle)]
104
+ substring = [
105
+ entry for entry in entries if needle in entry.name.lower() and not entry.name.lower().startswith(needle)
106
+ ]
107
+ ranked = sorted(prefix, key=lambda entry: entry.name) + sorted(substring, key=lambda entry: entry.name)
108
+ return ranked[:limit]
@@ -0,0 +1 @@
1
+ """CLI tests."""