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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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."""
|