ata-coder 2.4.2__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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
ata_coder/skills.py
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Skills system — folder-based with SKILL.md manifest.
|
|
4
|
+
|
|
5
|
+
Each skill lives in its own folder under skills/:
|
|
6
|
+
skills/
|
|
7
|
+
skill-name/
|
|
8
|
+
SKILL.md # REQUIRED: identity, I/O schema, permissions, prompt
|
|
9
|
+
handler.py # optional: run(input_data) entry point
|
|
10
|
+
prompts/ # optional: LLM prompt templates
|
|
11
|
+
resources/ # optional: static data (tables, configs)
|
|
12
|
+
tests/ # optional: test code
|
|
13
|
+
requirements.txt # optional: external dependencies
|
|
14
|
+
README.md # optional: developer/user docs
|
|
15
|
+
|
|
16
|
+
Backward-compatible: flat .md files still work (loaded as simple skills).
|
|
17
|
+
|
|
18
|
+
Design principles:
|
|
19
|
+
- Single responsibility per skill
|
|
20
|
+
- Explicit I/O contract (call.parameters → output.schema)
|
|
21
|
+
- Self-contained context (no implicit conversation dependency)
|
|
22
|
+
- Observable execution (logs, error codes, status)
|
|
23
|
+
- Permission boundaries (network, filesystem, commands, domains)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import importlib.util
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import traceback
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Callable
|
|
35
|
+
|
|
36
|
+
from .utils import try_import_yaml
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
yaml, HAS_YAML = try_import_yaml()
|
|
41
|
+
|
|
42
|
+
__all__ = ["Skill", "SkillCallSpec", "SkillOutputSpec", "SkillPermissions",
|
|
43
|
+
"SkillManager", "get_skill_manager"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
47
|
+
# I/O contract types
|
|
48
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SkillCallSpec:
|
|
52
|
+
"""How to invoke this skill."""
|
|
53
|
+
function: str = "" # function name
|
|
54
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
# parameters: {name: {type, description, required, default}}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class SkillOutputSpec:
|
|
60
|
+
"""What this skill returns."""
|
|
61
|
+
format: str = "text" # text | json | status_code
|
|
62
|
+
schema: dict[str, Any] = field(default_factory=dict) # JSON Schema subset
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class SkillPermissions:
|
|
67
|
+
"""Security boundaries for this skill."""
|
|
68
|
+
network: bool = False # allow network access?
|
|
69
|
+
filesystem: str = "none" # none | read_only | read_write
|
|
70
|
+
allowed_commands: list[str] = field(default_factory=list)
|
|
71
|
+
allowed_domains: list[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
75
|
+
# Skill data model
|
|
76
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Skill:
|
|
80
|
+
"""A named skill with explicit I/O contract, permissions, and lifecycle."""
|
|
81
|
+
|
|
82
|
+
# Identity
|
|
83
|
+
name: str
|
|
84
|
+
version: str = "1.0.0"
|
|
85
|
+
description: str = ""
|
|
86
|
+
type: str = "skill" # skill | tool | mcp | middleware
|
|
87
|
+
tags: list[str] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
# Prompt (main body of SKILL.md)
|
|
90
|
+
system_prompt: str = ""
|
|
91
|
+
|
|
92
|
+
# I/O contract
|
|
93
|
+
call: SkillCallSpec | None = None
|
|
94
|
+
output: SkillOutputSpec | None = None
|
|
95
|
+
|
|
96
|
+
# Triggers (for auto-detection)
|
|
97
|
+
triggers: list[str] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
# Tool restrictions (empty = all tools allowed)
|
|
100
|
+
tools: list[str] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
# Permissions
|
|
103
|
+
permissions: SkillPermissions | None = None
|
|
104
|
+
|
|
105
|
+
# Dependencies
|
|
106
|
+
dependencies: list[str] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
# Model override
|
|
109
|
+
model: str | None = None
|
|
110
|
+
temperature: float | None = None
|
|
111
|
+
|
|
112
|
+
# Extension metadata
|
|
113
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
114
|
+
|
|
115
|
+
# Runtime
|
|
116
|
+
skill_dir: str = "" # path to skill folder
|
|
117
|
+
_handler: Callable | None = None # loaded handler function
|
|
118
|
+
|
|
119
|
+
# ── Serialization ────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def to_frontmatter(self) -> str:
|
|
122
|
+
"""Export as SKILL.md format with full manifest."""
|
|
123
|
+
d: dict[str, Any] = {
|
|
124
|
+
"name": self.name,
|
|
125
|
+
"version": self.version,
|
|
126
|
+
"description": self.description,
|
|
127
|
+
"type": self.type,
|
|
128
|
+
"tags": self.tags,
|
|
129
|
+
}
|
|
130
|
+
if self.triggers:
|
|
131
|
+
d["triggers"] = self.triggers
|
|
132
|
+
if self.tools:
|
|
133
|
+
d["tools"] = self.tools
|
|
134
|
+
if self.model:
|
|
135
|
+
d["model"] = self.model
|
|
136
|
+
if self.dependencies:
|
|
137
|
+
d["dependencies"] = self.dependencies
|
|
138
|
+
if self.call:
|
|
139
|
+
d["call"] = {
|
|
140
|
+
"function": self.call.function,
|
|
141
|
+
"parameters": self.call.parameters,
|
|
142
|
+
}
|
|
143
|
+
if self.output:
|
|
144
|
+
d["output"] = {
|
|
145
|
+
"format": self.output.format,
|
|
146
|
+
}
|
|
147
|
+
if self.output.schema:
|
|
148
|
+
d["output"]["schema"] = self.output.schema
|
|
149
|
+
if self.permissions:
|
|
150
|
+
d["permissions"] = {
|
|
151
|
+
"network": self.permissions.network,
|
|
152
|
+
"filesystem": self.permissions.filesystem,
|
|
153
|
+
}
|
|
154
|
+
if self.permissions.allowed_commands:
|
|
155
|
+
d["permissions"]["allowed_commands"] = self.permissions.allowed_commands
|
|
156
|
+
if self.permissions.allowed_domains:
|
|
157
|
+
d["permissions"]["allowed_domains"] = self.permissions.allowed_domains
|
|
158
|
+
if self.metadata:
|
|
159
|
+
d["metadata"] = self.metadata
|
|
160
|
+
|
|
161
|
+
fm = (
|
|
162
|
+
yaml.dump(d, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
163
|
+
if HAS_YAML
|
|
164
|
+
else json.dumps(d, indent=2, ensure_ascii=False)
|
|
165
|
+
)
|
|
166
|
+
body = self.system_prompt or f"# {self.name}\n\n{self.description}"
|
|
167
|
+
return f"---\n{fm}---\n\n{body}"
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_dict(cls, d: dict[str, Any]) -> "Skill":
|
|
171
|
+
"""Build a Skill from a flat dict (legacy JSON/YAML format)."""
|
|
172
|
+
call_raw = d.get("call", {}) or {}
|
|
173
|
+
output_raw = d.get("output", {}) or {}
|
|
174
|
+
perm_raw = d.get("permissions", {}) or {}
|
|
175
|
+
|
|
176
|
+
return cls(
|
|
177
|
+
name=d.get("name", ""),
|
|
178
|
+
version=d.get("version", "1.0.0"),
|
|
179
|
+
description=d.get("description", ""),
|
|
180
|
+
type=d.get("type", "skill"),
|
|
181
|
+
tags=d.get("tags", []),
|
|
182
|
+
system_prompt=d.get("system_prompt", ""),
|
|
183
|
+
call=SkillCallSpec(
|
|
184
|
+
function=call_raw.get("function", ""),
|
|
185
|
+
parameters=call_raw.get("parameters", {}),
|
|
186
|
+
) if call_raw else None,
|
|
187
|
+
output=SkillOutputSpec(
|
|
188
|
+
format=output_raw.get("format", "text"),
|
|
189
|
+
schema=output_raw.get("schema", {}),
|
|
190
|
+
) if output_raw else None,
|
|
191
|
+
triggers=d.get("triggers", []),
|
|
192
|
+
tools=d.get("tools", []),
|
|
193
|
+
permissions=SkillPermissions(
|
|
194
|
+
network=perm_raw.get("network", False),
|
|
195
|
+
filesystem=perm_raw.get("filesystem", "none"),
|
|
196
|
+
allowed_commands=perm_raw.get("allowed_commands", []),
|
|
197
|
+
allowed_domains=perm_raw.get("allowed_domains", []),
|
|
198
|
+
) if perm_raw else None,
|
|
199
|
+
dependencies=d.get("dependencies", []),
|
|
200
|
+
model=d.get("model"),
|
|
201
|
+
temperature=d.get("temperature"),
|
|
202
|
+
metadata=d.get("metadata", {}),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def from_frontmatter(cls, raw: str, source: str = "unknown",
|
|
207
|
+
skill_dir: str = "") -> "Skill | None":
|
|
208
|
+
"""Parse a SKILL.md file (YAML frontmatter + markdown body)."""
|
|
209
|
+
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", raw, re.DOTALL)
|
|
210
|
+
if not match:
|
|
211
|
+
logger.warning("No YAML frontmatter in %s", source)
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
if HAS_YAML:
|
|
215
|
+
meta = yaml.safe_load(match.group(1))
|
|
216
|
+
else:
|
|
217
|
+
meta = json.loads(match.group(1))
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.warning("Failed to parse frontmatter in %s: %s", source, e)
|
|
220
|
+
return None
|
|
221
|
+
if not isinstance(meta, dict):
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
call_raw = meta.get("call", {}) or {}
|
|
225
|
+
output_raw = meta.get("output", {}) or {}
|
|
226
|
+
perm_raw = meta.get("permissions", {}) or {}
|
|
227
|
+
|
|
228
|
+
return cls(
|
|
229
|
+
name=meta.get("name", Path(source).stem),
|
|
230
|
+
version=str(meta.get("version", "1.0.0")),
|
|
231
|
+
description=meta.get("description", ""),
|
|
232
|
+
type=meta.get("type", "skill"),
|
|
233
|
+
tags=meta.get("tags", []),
|
|
234
|
+
system_prompt=match.group(2).strip(),
|
|
235
|
+
call=SkillCallSpec(
|
|
236
|
+
function=call_raw.get("function", ""),
|
|
237
|
+
parameters=call_raw.get("parameters", {}),
|
|
238
|
+
) if call_raw else None,
|
|
239
|
+
output=SkillOutputSpec(
|
|
240
|
+
format=output_raw.get("format", "text"),
|
|
241
|
+
schema=output_raw.get("schema", {}),
|
|
242
|
+
) if output_raw else None,
|
|
243
|
+
triggers=meta.get("triggers", []),
|
|
244
|
+
tools=meta.get("tools", []),
|
|
245
|
+
permissions=SkillPermissions(
|
|
246
|
+
network=perm_raw.get("network", False),
|
|
247
|
+
filesystem=perm_raw.get("filesystem", "none"),
|
|
248
|
+
allowed_commands=perm_raw.get("allowed_commands", []),
|
|
249
|
+
allowed_domains=perm_raw.get("allowed_domains", []),
|
|
250
|
+
) if (perm_raw.get("network") is not None
|
|
251
|
+
or perm_raw.get("filesystem")
|
|
252
|
+
or perm_raw.get("allowed_commands")) else None,
|
|
253
|
+
dependencies=meta.get("dependencies", []),
|
|
254
|
+
model=meta.get("model"),
|
|
255
|
+
temperature=meta.get("temperature"),
|
|
256
|
+
metadata=meta.get("metadata", {}),
|
|
257
|
+
skill_dir=skill_dir,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# ── Runtime: handler ────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def load_handler(self) -> bool:
|
|
263
|
+
"""Load handler.py from the skill directory. Returns True if found."""
|
|
264
|
+
if not self.skill_dir:
|
|
265
|
+
return False
|
|
266
|
+
handler_path = Path(self.skill_dir) / "handler.py"
|
|
267
|
+
if not handler_path.exists():
|
|
268
|
+
return False
|
|
269
|
+
try:
|
|
270
|
+
spec = importlib.util.spec_from_file_location(
|
|
271
|
+
f"ata_skill_{self.name}", str(handler_path)
|
|
272
|
+
)
|
|
273
|
+
if spec is None or spec.loader is None:
|
|
274
|
+
return False
|
|
275
|
+
module = importlib.util.module_from_spec(spec)
|
|
276
|
+
sys.modules[spec.name] = module
|
|
277
|
+
spec.loader.exec_module(module)
|
|
278
|
+
self._handler = getattr(module, "run", None) or getattr(module, "handle", None)
|
|
279
|
+
if self._handler:
|
|
280
|
+
logger.debug("Loaded handler for skill %s", self.name)
|
|
281
|
+
return True
|
|
282
|
+
except Exception:
|
|
283
|
+
logger.exception("Failed to load handler for skill %s", self.name)
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
def run_handler(self, input_data: dict[str, Any]) -> Any:
|
|
287
|
+
"""Execute the skill's handler with structured input."""
|
|
288
|
+
if not self._handler:
|
|
289
|
+
self.load_handler()
|
|
290
|
+
if not self._handler:
|
|
291
|
+
raise RuntimeError(f"Skill {self.name} has no handler")
|
|
292
|
+
try:
|
|
293
|
+
return self._handler(input_data)
|
|
294
|
+
except Exception:
|
|
295
|
+
logger.exception("Handler failed for skill %s", self.name)
|
|
296
|
+
raise
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def has_handler(self) -> bool:
|
|
300
|
+
if self._handler:
|
|
301
|
+
return True
|
|
302
|
+
return bool(self.skill_dir and (Path(self.skill_dir) / "handler.py").exists())
|
|
303
|
+
|
|
304
|
+
# ── Runtime: prompts ─────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def load_prompts(self) -> dict[str, str]:
|
|
307
|
+
"""
|
|
308
|
+
Load all prompt templates from the prompts/ directory.
|
|
309
|
+
Returns {name: content} dict. Supports .md, .txt, .prompt files.
|
|
310
|
+
Cached after first load.
|
|
311
|
+
"""
|
|
312
|
+
if not self.skill_dir:
|
|
313
|
+
return {}
|
|
314
|
+
prompts_dir = Path(self.skill_dir) / "prompts"
|
|
315
|
+
if not prompts_dir.is_dir():
|
|
316
|
+
return {}
|
|
317
|
+
result: dict[str, str] = {}
|
|
318
|
+
for fp in sorted(prompts_dir.glob("*")):
|
|
319
|
+
if fp.suffix in (".md", ".txt", ".prompt"):
|
|
320
|
+
try:
|
|
321
|
+
result[fp.stem] = fp.read_text(encoding="utf-8")
|
|
322
|
+
except Exception:
|
|
323
|
+
logger.warning("Failed to read prompt %s", fp)
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
def get_prompt_template(self, name: str) -> str | None:
|
|
327
|
+
"""
|
|
328
|
+
Get a specific prompt template by name (without extension).
|
|
329
|
+
Example: skill.get_prompt_template("system") → content of prompts/system.md
|
|
330
|
+
"""
|
|
331
|
+
if not self.skill_dir:
|
|
332
|
+
return None
|
|
333
|
+
for ext in (".md", ".txt", ".prompt"):
|
|
334
|
+
fp = Path(self.skill_dir) / "prompts" / f"{name}{ext}"
|
|
335
|
+
if fp.exists():
|
|
336
|
+
try:
|
|
337
|
+
return fp.read_text(encoding="utf-8")
|
|
338
|
+
except Exception:
|
|
339
|
+
return None
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def prompt_names(self) -> list[str]:
|
|
344
|
+
"""List available prompt template names."""
|
|
345
|
+
if not self.skill_dir:
|
|
346
|
+
return []
|
|
347
|
+
prompts_dir = Path(self.skill_dir) / "prompts"
|
|
348
|
+
if not prompts_dir.is_dir():
|
|
349
|
+
return []
|
|
350
|
+
return sorted(
|
|
351
|
+
fp.stem for fp in prompts_dir.glob("*")
|
|
352
|
+
if fp.suffix in (".md", ".txt", ".prompt")
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# ── Runtime: resources ───────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
def load_resources(self) -> dict[str, Any]:
|
|
358
|
+
"""
|
|
359
|
+
Load all resource files from the resources/ directory.
|
|
360
|
+
JSON files → parsed objects; .yaml/.yml → parsed; others → raw text.
|
|
361
|
+
Cached after first load.
|
|
362
|
+
"""
|
|
363
|
+
if not self.skill_dir:
|
|
364
|
+
return {}
|
|
365
|
+
res_dir = Path(self.skill_dir) / "resources"
|
|
366
|
+
if not res_dir.is_dir():
|
|
367
|
+
return {}
|
|
368
|
+
result: dict[str, Any] = {}
|
|
369
|
+
for fp in sorted(res_dir.glob("*")):
|
|
370
|
+
if fp.name.startswith("."):
|
|
371
|
+
continue
|
|
372
|
+
try:
|
|
373
|
+
if fp.suffix in (".json",):
|
|
374
|
+
result[fp.stem] = json.loads(fp.read_text(encoding="utf-8"))
|
|
375
|
+
elif fp.suffix in (".yaml", ".yml") and HAS_YAML:
|
|
376
|
+
result[fp.stem] = yaml.safe_load(fp.read_text(encoding="utf-8"))
|
|
377
|
+
elif fp.suffix in (".txt", ".csv", ".tsv"):
|
|
378
|
+
result[fp.stem] = fp.read_text(encoding="utf-8")
|
|
379
|
+
elif fp.suffix in (".py",):
|
|
380
|
+
continue # skip Python files in resources
|
|
381
|
+
else:
|
|
382
|
+
result[fp.stem] = fp.read_text(encoding="utf-8")
|
|
383
|
+
except Exception:
|
|
384
|
+
logger.warning("Failed to load resource %s", fp)
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
def get_resource(self, name: str) -> Any:
|
|
388
|
+
"""
|
|
389
|
+
Get a specific resource by name (without extension).
|
|
390
|
+
Example: skill.get_resource("config") → parsed JSON from resources/config.json
|
|
391
|
+
"""
|
|
392
|
+
if not self.skill_dir:
|
|
393
|
+
return None
|
|
394
|
+
res_dir = Path(self.skill_dir) / "resources"
|
|
395
|
+
if not res_dir.is_dir():
|
|
396
|
+
return None
|
|
397
|
+
for fp in sorted(res_dir.glob(f"{name}.*")):
|
|
398
|
+
if fp.name.startswith("."):
|
|
399
|
+
continue
|
|
400
|
+
try:
|
|
401
|
+
if fp.suffix in (".json",):
|
|
402
|
+
return json.loads(fp.read_text(encoding="utf-8"))
|
|
403
|
+
elif fp.suffix in (".yaml", ".yml") and HAS_YAML:
|
|
404
|
+
return yaml.safe_load(fp.read_text(encoding="utf-8"))
|
|
405
|
+
else:
|
|
406
|
+
return fp.read_text(encoding="utf-8")
|
|
407
|
+
except Exception:
|
|
408
|
+
logger.warning("Failed to read resource %s", fp)
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def resource_names(self) -> list[str]:
|
|
413
|
+
"""List available resource names."""
|
|
414
|
+
if not self.skill_dir:
|
|
415
|
+
return []
|
|
416
|
+
res_dir = Path(self.skill_dir) / "resources"
|
|
417
|
+
if not res_dir.is_dir():
|
|
418
|
+
return []
|
|
419
|
+
return sorted({fp.stem for fp in res_dir.glob("*") if not fp.name.startswith(".")})
|
|
420
|
+
|
|
421
|
+
# ── Runtime: README ──────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
def get_readme(self) -> str | None:
|
|
424
|
+
"""Read the skill's README.md if it exists."""
|
|
425
|
+
if not self.skill_dir:
|
|
426
|
+
return None
|
|
427
|
+
fp = Path(self.skill_dir) / "README.md"
|
|
428
|
+
if fp.exists():
|
|
429
|
+
try:
|
|
430
|
+
return fp.read_text(encoding="utf-8")
|
|
431
|
+
except Exception:
|
|
432
|
+
return None
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
# ── Runtime: dependencies ────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
def get_dependencies(self) -> list[str]:
|
|
438
|
+
"""Parse requirements.txt from the skill directory. Returns list of package specs."""
|
|
439
|
+
if not self.skill_dir:
|
|
440
|
+
return []
|
|
441
|
+
fp = Path(self.skill_dir) / "requirements.txt"
|
|
442
|
+
if not fp.exists():
|
|
443
|
+
return []
|
|
444
|
+
try:
|
|
445
|
+
lines = fp.read_text(encoding="utf-8").strip().splitlines()
|
|
446
|
+
return [
|
|
447
|
+
line.strip() for line in lines
|
|
448
|
+
if line.strip() and not line.strip().startswith("#")
|
|
449
|
+
]
|
|
450
|
+
except Exception:
|
|
451
|
+
return []
|
|
452
|
+
|
|
453
|
+
# ── Runtime: generic file access ────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
def read_file(self, relative_path: str) -> str | None:
|
|
456
|
+
"""
|
|
457
|
+
Read ANY file within the skill folder.
|
|
458
|
+
|
|
459
|
+
Example: skill.read_file(".env.example") → content
|
|
460
|
+
skill.read_file("prompts/system_prompt.txt") → content
|
|
461
|
+
"""
|
|
462
|
+
if not self.skill_dir:
|
|
463
|
+
return None
|
|
464
|
+
fp = Path(self.skill_dir) / relative_path
|
|
465
|
+
# Safety: prevent path traversal
|
|
466
|
+
try:
|
|
467
|
+
fp.resolve().relative_to(Path(self.skill_dir).resolve())
|
|
468
|
+
except ValueError:
|
|
469
|
+
logger.warning("Path traversal blocked: %s", relative_path)
|
|
470
|
+
return None
|
|
471
|
+
if not fp.is_file():
|
|
472
|
+
return None
|
|
473
|
+
try:
|
|
474
|
+
return fp.read_text(encoding="utf-8")
|
|
475
|
+
except Exception:
|
|
476
|
+
logger.warning("Failed to read %s", fp)
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
def read_json(self, relative_path: str) -> Any:
|
|
480
|
+
"""Read and parse a JSON file in the skill folder."""
|
|
481
|
+
raw = self.read_file(relative_path)
|
|
482
|
+
if raw is None:
|
|
483
|
+
return None
|
|
484
|
+
try:
|
|
485
|
+
return json.loads(raw)
|
|
486
|
+
except json.JSONDecodeError:
|
|
487
|
+
logger.warning("Invalid JSON: %s", relative_path)
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
def list_files(self, pattern: str = "*") -> list[str]:
|
|
491
|
+
"""
|
|
492
|
+
List files in the skill folder matching a glob pattern.
|
|
493
|
+
|
|
494
|
+
Example: skill.list_files("prompts/*.txt") → ["prompts/system_prompt.txt"]
|
|
495
|
+
"""
|
|
496
|
+
if not self.skill_dir:
|
|
497
|
+
return []
|
|
498
|
+
base = Path(self.skill_dir)
|
|
499
|
+
matches = sorted(base.glob(pattern))
|
|
500
|
+
return [
|
|
501
|
+
str(m.relative_to(base)).replace("\\", "/")
|
|
502
|
+
for m in matches if m.is_file()
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
def file_tree(self) -> str:
|
|
506
|
+
"""Return a plain-text tree of the skill folder (dir/, indent, file)."""
|
|
507
|
+
if not self.skill_dir:
|
|
508
|
+
return "(no directory)"
|
|
509
|
+
base = Path(self.skill_dir)
|
|
510
|
+
lines = [base.name + "/"]
|
|
511
|
+
for fp in sorted(base.rglob("*")):
|
|
512
|
+
if any(p.startswith(".") for p in fp.parts):
|
|
513
|
+
continue
|
|
514
|
+
if fp.name == "__pycache__":
|
|
515
|
+
continue
|
|
516
|
+
depth = len(fp.relative_to(base).parts)
|
|
517
|
+
indent = " " * (depth - 1)
|
|
518
|
+
if fp.is_dir():
|
|
519
|
+
lines.append(f"{indent} {fp.name}/")
|
|
520
|
+
else:
|
|
521
|
+
lines.append(f"{indent} {fp.name}")
|
|
522
|
+
return "\n".join(lines)
|
|
523
|
+
|
|
524
|
+
# ── Alternative manifest formats ─────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def from_manifest_json(cls, path: str | Path) -> "Skill | None":
|
|
528
|
+
"""Load skill from a manifest.json file."""
|
|
529
|
+
fp = Path(path)
|
|
530
|
+
if not fp.exists():
|
|
531
|
+
return None
|
|
532
|
+
try:
|
|
533
|
+
data = json.loads(fp.read_text(encoding="utf-8"))
|
|
534
|
+
except Exception as e:
|
|
535
|
+
logger.warning("Failed to parse %s: %s", fp, e)
|
|
536
|
+
return None
|
|
537
|
+
call_raw = data.get("call", {}) or {}
|
|
538
|
+
output_raw = data.get("output", {}) or {}
|
|
539
|
+
perm_raw = data.get("permissions", {}) or {}
|
|
540
|
+
return cls(
|
|
541
|
+
name=data.get("name", fp.parent.name),
|
|
542
|
+
version=data.get("version", "1.0.0"),
|
|
543
|
+
description=data.get("description", ""),
|
|
544
|
+
type=data.get("type", "skill"),
|
|
545
|
+
tags=data.get("tags", []),
|
|
546
|
+
system_prompt=data.get("system_prompt", data.get("description", "")),
|
|
547
|
+
call=SkillCallSpec(
|
|
548
|
+
function=call_raw.get("function", ""),
|
|
549
|
+
parameters=call_raw.get("parameters", {}),
|
|
550
|
+
) if call_raw else None,
|
|
551
|
+
output=SkillOutputSpec(
|
|
552
|
+
format=output_raw.get("format", "text"),
|
|
553
|
+
schema=output_raw.get("schema", {}),
|
|
554
|
+
) if output_raw else None,
|
|
555
|
+
triggers=data.get("triggers", []),
|
|
556
|
+
tools=data.get("tools", []),
|
|
557
|
+
permissions=SkillPermissions(
|
|
558
|
+
network=perm_raw.get("network", False),
|
|
559
|
+
filesystem=perm_raw.get("filesystem", "none"),
|
|
560
|
+
allowed_commands=perm_raw.get("allowed_commands", []),
|
|
561
|
+
allowed_domains=perm_raw.get("allowed_domains", []),
|
|
562
|
+
) if perm_raw else None,
|
|
563
|
+
dependencies=data.get("dependencies", []),
|
|
564
|
+
model=data.get("model"),
|
|
565
|
+
metadata={
|
|
566
|
+
**data.get("metadata", {}),
|
|
567
|
+
**{k: data[k] for k in ("author", "license", "homepage")
|
|
568
|
+
if k in data and k not in data.get("metadata", {})},
|
|
569
|
+
},
|
|
570
|
+
skill_dir=str(fp.parent),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
@classmethod
|
|
574
|
+
def from_skill_yaml(cls, path: str | Path) -> "Skill | None":
|
|
575
|
+
"""Load skill from a skill.yaml or skill.yml file."""
|
|
576
|
+
if not HAS_YAML:
|
|
577
|
+
return None
|
|
578
|
+
fp = Path(path)
|
|
579
|
+
if not fp.exists():
|
|
580
|
+
return None
|
|
581
|
+
try:
|
|
582
|
+
data = yaml.safe_load(fp.read_text(encoding="utf-8"))
|
|
583
|
+
except Exception as e:
|
|
584
|
+
logger.warning("Failed to parse %s: %s", fp, e)
|
|
585
|
+
return None
|
|
586
|
+
if not isinstance(data, dict):
|
|
587
|
+
return None
|
|
588
|
+
return cls.from_dict({**data, "skill_dir": str(fp.parent)})
|
|
589
|
+
|
|
590
|
+
@property
|
|
591
|
+
def safe_name(self) -> str:
|
|
592
|
+
"""Name safe for use as identifier."""
|
|
593
|
+
return re.sub(r"[^a-zA-Z0-9_]", "_", self.name)
|
|
594
|
+
|
|
595
|
+
def get_prompt(self) -> str:
|
|
596
|
+
"""Return system prompt (alias for system_prompt for Extension compat)."""
|
|
597
|
+
return self.resolve_includes(self.system_prompt)
|
|
598
|
+
|
|
599
|
+
def get_tools(self) -> list[str]:
|
|
600
|
+
"""Return tool restriction list (alias for tools for Extension compat)."""
|
|
601
|
+
return self.tools
|
|
602
|
+
|
|
603
|
+
def resolve_includes(self, text: str, _depth: int = 0) -> str:
|
|
604
|
+
"""
|
|
605
|
+
Resolve @include directives in *text*.
|
|
606
|
+
|
|
607
|
+
Syntax:
|
|
608
|
+
@include path/to/file.md — inline file content (relative to skill dir)
|
|
609
|
+
@include prompts/system.txt — load a prompt template
|
|
610
|
+
@include resources/config.json — load and inline as text
|
|
611
|
+
|
|
612
|
+
The included file's content replaces the @include line. Recursive
|
|
613
|
+
includes are supported (max depth 5 to prevent infinite loops).
|
|
614
|
+
|
|
615
|
+
Lines without a matching file are left as-is (no error — the LLM
|
|
616
|
+
will see the raw directive and can ask for clarification).
|
|
617
|
+
"""
|
|
618
|
+
if _depth > 5:
|
|
619
|
+
return text
|
|
620
|
+
|
|
621
|
+
resolved_lines: list[str] = []
|
|
622
|
+
for line in text.split("\n"):
|
|
623
|
+
stripped = line.strip()
|
|
624
|
+
if stripped.startswith("@include "):
|
|
625
|
+
rel_path = stripped[len("@include "):].strip()
|
|
626
|
+
included = self.read_file(rel_path)
|
|
627
|
+
if included is not None:
|
|
628
|
+
# Recursively resolve includes in the included file
|
|
629
|
+
included = self.resolve_includes(included, _depth + 1)
|
|
630
|
+
resolved_lines.append(included)
|
|
631
|
+
else:
|
|
632
|
+
# File not found — leave directive as-is so the LLM can react
|
|
633
|
+
resolved_lines.append(line)
|
|
634
|
+
else:
|
|
635
|
+
resolved_lines.append(line)
|
|
636
|
+
return "\n".join(resolved_lines)
|
|
637
|
+
|
|
638
|
+
def __repr__(self) -> str:
|
|
639
|
+
return f"Skill(name={self.name!r}, v{self.version}, type={self.type})"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
643
|
+
# Skill manager
|
|
644
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
645
|
+
|
|
646
|
+
class SkillManager:
|
|
647
|
+
"""Loads skills from folder-based skill directories + flat legacy files."""
|
|
648
|
+
|
|
649
|
+
def __init__(self, skills_dir: str | Path | None = None):
|
|
650
|
+
if skills_dir is None:
|
|
651
|
+
# Always use ~/.ata_coder/skills — seed if empty
|
|
652
|
+
from .settings import init_settings
|
|
653
|
+
try:
|
|
654
|
+
settings = init_settings()
|
|
655
|
+
skills_dir = settings.skills_dir
|
|
656
|
+
except Exception:
|
|
657
|
+
skills_dir = Path.home() / ".ata_coder" / "skills"
|
|
658
|
+
self.skills_dir = Path(skills_dir)
|
|
659
|
+
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
|
660
|
+
|
|
661
|
+
self._skills: dict[str, Skill] = {}
|
|
662
|
+
self._active_skills: dict[str, Skill] = {}
|
|
663
|
+
|
|
664
|
+
self._load_from_directory()
|
|
665
|
+
|
|
666
|
+
# Log what we found
|
|
667
|
+
logger.info("Skills loaded: %d from %s", len(self._skills), self.skills_dir)
|
|
668
|
+
|
|
669
|
+
# ── Loading ─────────────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
def _load_from_directory(self) -> None:
|
|
672
|
+
"""Scan skills/ for:
|
|
673
|
+
1. Subdirectories containing SKILL.md (primary format)
|
|
674
|
+
2. Flat .md files (legacy — each is one skill)
|
|
675
|
+
3. .json / .yaml files (legacy)
|
|
676
|
+
"""
|
|
677
|
+
if not self.skills_dir.exists():
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
# ── Folder-based skills (primary) ──────────────────────────────
|
|
681
|
+
for d in sorted(self.skills_dir.iterdir()):
|
|
682
|
+
if not d.is_dir() or d.name.startswith(".") or d.name.startswith("_"):
|
|
683
|
+
continue
|
|
684
|
+
skill = None
|
|
685
|
+
|
|
686
|
+
# Priority: SKILL.md → manifest.json → skill.yaml → *.md
|
|
687
|
+
skill_md = d / "SKILL.md"
|
|
688
|
+
manifest_json = d / "manifest.json"
|
|
689
|
+
skill_yaml = d / "skill.yaml"
|
|
690
|
+
skill_yml = d / "skill.yml"
|
|
691
|
+
|
|
692
|
+
if skill_md.exists():
|
|
693
|
+
try:
|
|
694
|
+
raw = skill_md.read_text(encoding="utf-8")
|
|
695
|
+
skill = Skill.from_frontmatter(raw, source=str(skill_md),
|
|
696
|
+
skill_dir=str(d))
|
|
697
|
+
except Exception as e:
|
|
698
|
+
logger.warning("Failed to load SKILL.md from %s: %s", d, e)
|
|
699
|
+
elif manifest_json.exists():
|
|
700
|
+
skill = Skill.from_manifest_json(str(manifest_json))
|
|
701
|
+
elif skill_yaml.exists():
|
|
702
|
+
skill = Skill.from_skill_yaml(str(skill_yaml))
|
|
703
|
+
elif skill_yml.exists():
|
|
704
|
+
skill = Skill.from_skill_yaml(str(skill_yml))
|
|
705
|
+
else:
|
|
706
|
+
# Legacy fallback: first .md file in folder
|
|
707
|
+
md_files = sorted(d.glob("*.md"))
|
|
708
|
+
if md_files:
|
|
709
|
+
try:
|
|
710
|
+
raw = md_files[0].read_text(encoding="utf-8")
|
|
711
|
+
skill = Skill.from_frontmatter(raw, source=str(md_files[0]),
|
|
712
|
+
skill_dir=str(d))
|
|
713
|
+
except Exception as e:
|
|
714
|
+
logger.warning("Failed to load %s: %s", md_files[0], e)
|
|
715
|
+
|
|
716
|
+
if skill and skill.name:
|
|
717
|
+
self._skills[skill.name] = skill
|
|
718
|
+
logger.debug("Loaded skill: %s from %s/", skill.name, d.name)
|
|
719
|
+
|
|
720
|
+
# ── Flat .md files (legacy backward compat) ────────────────────
|
|
721
|
+
for fp in sorted(self.skills_dir.glob("*.md")):
|
|
722
|
+
try:
|
|
723
|
+
raw = fp.read_text(encoding="utf-8")
|
|
724
|
+
skill = Skill.from_frontmatter(raw, source=fp.name)
|
|
725
|
+
if skill and skill.name not in self._skills:
|
|
726
|
+
self._skills[skill.name] = skill
|
|
727
|
+
logger.debug("Loaded legacy skill: %s from %s", skill.name, fp.name)
|
|
728
|
+
except Exception as e:
|
|
729
|
+
logger.warning("Failed to load legacy skill %s: %s", fp.name, e)
|
|
730
|
+
|
|
731
|
+
# ── Legacy JSON/YAML ──────────────────────────────────────────
|
|
732
|
+
for fp in self.skills_dir.glob("*.json"):
|
|
733
|
+
try:
|
|
734
|
+
data = json.loads(fp.read_text(encoding="utf-8"))
|
|
735
|
+
for item in (data if isinstance(data, list) else [data]):
|
|
736
|
+
skill = Skill.from_dict(item)
|
|
737
|
+
if skill.name and skill.name not in self._skills:
|
|
738
|
+
self._skills[skill.name] = skill
|
|
739
|
+
except Exception as e:
|
|
740
|
+
logger.warning("Failed to load %s: %s", fp.name, e)
|
|
741
|
+
|
|
742
|
+
if HAS_YAML:
|
|
743
|
+
for fp in list(self.skills_dir.glob("*.yaml")) + list(self.skills_dir.glob("*.yml")):
|
|
744
|
+
try:
|
|
745
|
+
data = yaml.safe_load(fp.read_text(encoding="utf-8"))
|
|
746
|
+
for item in (data if isinstance(data, list) else [data]):
|
|
747
|
+
skill = Skill.from_dict(item)
|
|
748
|
+
if skill.name and skill.name not in self._skills:
|
|
749
|
+
self._skills[skill.name] = skill
|
|
750
|
+
except Exception as e:
|
|
751
|
+
logger.warning("Failed to load %s: %s", fp.name, e)
|
|
752
|
+
|
|
753
|
+
logger.debug("Loaded %d skills total", len(self._skills))
|
|
754
|
+
|
|
755
|
+
# ── Management ──────────────────────────────────────────────────────────
|
|
756
|
+
|
|
757
|
+
def list_skills(self) -> list[Skill]:
|
|
758
|
+
return list(self._skills.values())
|
|
759
|
+
|
|
760
|
+
def get_skill(self, name: str) -> Skill | None:
|
|
761
|
+
return self._skills.get(name)
|
|
762
|
+
|
|
763
|
+
def activate(self, name: str, merge: bool = True) -> Skill | None:
|
|
764
|
+
"""Activate a skill. merge=True → multi-skill, merge=False → solo."""
|
|
765
|
+
skill = self._skills.get(name)
|
|
766
|
+
if skill:
|
|
767
|
+
if not merge:
|
|
768
|
+
self._active_skills.clear()
|
|
769
|
+
self._active_skills[name] = skill
|
|
770
|
+
logger.info("Activated: %s (active: %d)", name, len(self._active_skills))
|
|
771
|
+
else:
|
|
772
|
+
logger.warning("Skill not found: %s", name)
|
|
773
|
+
return skill
|
|
774
|
+
|
|
775
|
+
def deactivate(self, name: str | None = None) -> None:
|
|
776
|
+
"""Deactivate specific skill or all."""
|
|
777
|
+
if name:
|
|
778
|
+
self._active_skills.pop(name, None)
|
|
779
|
+
else:
|
|
780
|
+
self._active_skills.clear()
|
|
781
|
+
|
|
782
|
+
@property
|
|
783
|
+
def active_skill(self) -> Skill | None:
|
|
784
|
+
"""Backward-compat: first active skill."""
|
|
785
|
+
for s in self._active_skills.values():
|
|
786
|
+
return s
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
@property
|
|
790
|
+
def active_skills(self) -> list[Skill]:
|
|
791
|
+
return list(self._active_skills.values())
|
|
792
|
+
|
|
793
|
+
def get_system_prompt(self) -> str:
|
|
794
|
+
"""Aggregate prompts from all active skills."""
|
|
795
|
+
if self._active_skills:
|
|
796
|
+
sorted_skills = sorted(self._active_skills.values(), key=lambda s: s.name)
|
|
797
|
+
parts = [s.system_prompt for s in sorted_skills if s.system_prompt]
|
|
798
|
+
if parts:
|
|
799
|
+
return "\n\n".join(parts)
|
|
800
|
+
default = self._skills.get("general-coder")
|
|
801
|
+
return default.system_prompt if default else "You are an expert coding assistant."
|
|
802
|
+
|
|
803
|
+
def get_allowed_tools(self) -> list[str] | None:
|
|
804
|
+
"""Intersection of tool restrictions from all active skills."""
|
|
805
|
+
restrictions: list[set[str]] = []
|
|
806
|
+
for skill in self._active_skills.values():
|
|
807
|
+
if skill.tools:
|
|
808
|
+
restrictions.append(set(skill.tools))
|
|
809
|
+
if not restrictions:
|
|
810
|
+
return None
|
|
811
|
+
allowed = restrictions[0]
|
|
812
|
+
for r in restrictions[1:]:
|
|
813
|
+
allowed &= r
|
|
814
|
+
return list(allowed) if allowed else None
|
|
815
|
+
|
|
816
|
+
# ── Detection ───────────────────────────────────────────────────────────
|
|
817
|
+
|
|
818
|
+
def _trigger_matches(self, trigger: str, text: str) -> bool:
|
|
819
|
+
t = trigger.lower()
|
|
820
|
+
words = t.split()
|
|
821
|
+
return all(w in text for w in words) if len(words) > 1 else t in text
|
|
822
|
+
|
|
823
|
+
def detect_skill(self, user_input: str) -> Skill | None:
|
|
824
|
+
candidates = self.detect_skills(user_input)
|
|
825
|
+
return candidates[0] if candidates else None
|
|
826
|
+
|
|
827
|
+
def detect_skills(self, user_input: str, max_results: int = 3) -> list[Skill]:
|
|
828
|
+
"""Auto-detect matching skills from trigger keywords."""
|
|
829
|
+
user_lower = user_input.lower()
|
|
830
|
+
candidates: list[tuple[int, Skill]] = []
|
|
831
|
+
for skill in self._skills.values():
|
|
832
|
+
if not skill.triggers:
|
|
833
|
+
continue
|
|
834
|
+
score = sum(
|
|
835
|
+
len(t.split())
|
|
836
|
+
for t in skill.triggers
|
|
837
|
+
if self._trigger_matches(t, user_lower)
|
|
838
|
+
)
|
|
839
|
+
if score > 0:
|
|
840
|
+
candidates.append((score, skill))
|
|
841
|
+
if not candidates:
|
|
842
|
+
return []
|
|
843
|
+
candidates.sort(key=lambda x: (-x[0], 1 if x[1].name == "general-coder" else 0))
|
|
844
|
+
result = [skill for _, skill in candidates[:max_results]]
|
|
845
|
+
if result and result[0].name == "general-coder" and len(result) > 1:
|
|
846
|
+
result = result[1:] + [result[:1]]
|
|
847
|
+
return result
|
|
848
|
+
|
|
849
|
+
def detect_skills_smart(self, user_input: str, max_results: int = 3,
|
|
850
|
+
llm_client=None) -> list[tuple[Skill, float]]:
|
|
851
|
+
"""Smart skill detection with LLM-based classification.
|
|
852
|
+
|
|
853
|
+
Uses keyword matching as first pass, then LLM classification for
|
|
854
|
+
ambiguous cases (multiple skills with similar scores, or low confidence).
|
|
855
|
+
|
|
856
|
+
Args:
|
|
857
|
+
user_input: The user's task/query
|
|
858
|
+
max_results: Maximum number of skills to return
|
|
859
|
+
llm_client: Optional LLM client for smart classification
|
|
860
|
+
|
|
861
|
+
Returns:
|
|
862
|
+
List of (Skill, confidence) tuples sorted by confidence descending
|
|
863
|
+
"""
|
|
864
|
+
# Phase 1: Keyword-based scoring (fast, no API call)
|
|
865
|
+
keyword_results = self.detect_skills(user_input, max_results=5)
|
|
866
|
+
|
|
867
|
+
if not keyword_results:
|
|
868
|
+
# No keyword match at all — try LLM if available, else default
|
|
869
|
+
if llm_client:
|
|
870
|
+
skill_name = self._llm_classify(user_input, llm_client)
|
|
871
|
+
if skill_name and skill_name in self._skills:
|
|
872
|
+
skill = self._skills[skill_name]
|
|
873
|
+
return [(skill, 0.7)]
|
|
874
|
+
default = self._skills.get("general-coder")
|
|
875
|
+
return [(default, 0.3)] if default else []
|
|
876
|
+
|
|
877
|
+
# Phase 2: If we have 1 clear winner (score gap > 2x), use it
|
|
878
|
+
if len(keyword_results) == 1:
|
|
879
|
+
return [(keyword_results[0], 0.85)]
|
|
880
|
+
|
|
881
|
+
# Calculate score-based confidences
|
|
882
|
+
scores = {}
|
|
883
|
+
for skill in keyword_results:
|
|
884
|
+
triggers = getattr(skill, 'triggers', []) or []
|
|
885
|
+
scores[skill.name] = sum(
|
|
886
|
+
len(t.split()) for t in triggers
|
|
887
|
+
if self._trigger_matches(t, user_input.lower())
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
top_score = max(scores.values()) if scores else 1
|
|
891
|
+
ranked = []
|
|
892
|
+
for skill in keyword_results:
|
|
893
|
+
conf = min(0.9, scores.get(skill.name, 1) / max(top_score, 1))
|
|
894
|
+
ranked.append((skill, round(conf, 2)))
|
|
895
|
+
|
|
896
|
+
# Phase 3: LLM refinement for ambiguous cases
|
|
897
|
+
# If top 2 skills are within 30% confidence of each other, use LLM
|
|
898
|
+
if len(ranked) >= 2 and ranked[0][1] - ranked[1][1] < 0.3:
|
|
899
|
+
if llm_client:
|
|
900
|
+
skill_name = self._llm_classify(user_input, llm_client)
|
|
901
|
+
if skill_name and skill_name in self._skills:
|
|
902
|
+
skill = self._skills[skill_name]
|
|
903
|
+
# Insert LLM-chosen skill at top
|
|
904
|
+
ranked.insert(0, (skill, 0.8))
|
|
905
|
+
# Deduplicate
|
|
906
|
+
seen = set()
|
|
907
|
+
deduped = []
|
|
908
|
+
for s, c in ranked:
|
|
909
|
+
if s.name not in seen:
|
|
910
|
+
seen.add(s.name)
|
|
911
|
+
deduped.append((s, c))
|
|
912
|
+
ranked = deduped
|
|
913
|
+
|
|
914
|
+
ranked.sort(key=lambda x: -x[1])
|
|
915
|
+
return ranked[:max_results]
|
|
916
|
+
|
|
917
|
+
def _llm_classify(self, user_input: str, llm_client) -> str | None:
|
|
918
|
+
"""Use a cheap LLM call to classify which skill best fits the task."""
|
|
919
|
+
skill_list = "\n".join(
|
|
920
|
+
f"- {s.name}: {s.description[:100]}"
|
|
921
|
+
for s in self._skills.values()
|
|
922
|
+
if s.name != "general-coder"
|
|
923
|
+
)
|
|
924
|
+
prompt = (
|
|
925
|
+
"You are a task router. Given a user's request, pick the SINGLE "
|
|
926
|
+
"best-matching skill from the list below. Reply with ONLY the skill "
|
|
927
|
+
"name, nothing else.\n\n"
|
|
928
|
+
f"Skills:\n{skill_list}\n\n"
|
|
929
|
+
f"User request: {user_input[:500]}\n\n"
|
|
930
|
+
"Best skill name:"
|
|
931
|
+
)
|
|
932
|
+
try:
|
|
933
|
+
msgs = [
|
|
934
|
+
{"role": "system", "content": "You are a skill router. Reply with only the skill name."},
|
|
935
|
+
{"role": "user", "content": prompt},
|
|
936
|
+
]
|
|
937
|
+
# Use non-streaming call with minimal tokens
|
|
938
|
+
response = llm_client.chat(msgs, system_prompt="Reply with only one skill name.")
|
|
939
|
+
name = response.strip().lower().split("\n")[0].strip().strip('"').strip("'")
|
|
940
|
+
# Validate against known skills
|
|
941
|
+
for sname in self._skills:
|
|
942
|
+
if sname.lower() in name or name in sname.lower():
|
|
943
|
+
return sname
|
|
944
|
+
return None
|
|
945
|
+
except Exception:
|
|
946
|
+
return None
|
|
947
|
+
|
|
948
|
+
# ── Execution ───────────────────────────────────────────────────────────
|
|
949
|
+
|
|
950
|
+
def execute_skill(self, name: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
|
951
|
+
"""
|
|
952
|
+
Execute a skill's handler with structured input.
|
|
953
|
+
Returns {success, output, error, status_code}.
|
|
954
|
+
"""
|
|
955
|
+
skill = self._skills.get(name)
|
|
956
|
+
if not skill:
|
|
957
|
+
return {"success": False, "output": None, "error": f"Skill not found: {name}", "status_code": 404}
|
|
958
|
+
if not skill.has_handler:
|
|
959
|
+
return {"success": False, "output": None, "error": f"Skill {name} has no handler", "status_code": 501}
|
|
960
|
+
try:
|
|
961
|
+
result = skill.run_handler(input_data)
|
|
962
|
+
return {"success": True, "output": result, "error": None, "status_code": 200}
|
|
963
|
+
except Exception as e:
|
|
964
|
+
return {
|
|
965
|
+
"success": False,
|
|
966
|
+
"output": None,
|
|
967
|
+
"error": f"{type(e).__name__}: {e}",
|
|
968
|
+
"status_code": 500,
|
|
969
|
+
"traceback": traceback.format_exc(),
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
# ── Persistence ─────────────────────────────────────────────────────────
|
|
973
|
+
|
|
974
|
+
def save_skill(self, skill: Skill) -> Path:
|
|
975
|
+
"""Save a skill as a folder with SKILL.md."""
|
|
976
|
+
skill_dir = self.skills_dir / skill.name
|
|
977
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
978
|
+
fp = skill_dir / "SKILL.md"
|
|
979
|
+
fp.write_text(skill.to_frontmatter(), encoding="utf-8")
|
|
980
|
+
self._skills[skill.name] = skill
|
|
981
|
+
return skill_dir
|
|
982
|
+
|
|
983
|
+
def delete_skill(self, name: str) -> bool:
|
|
984
|
+
"""Delete a skill folder and all contents."""
|
|
985
|
+
import shutil
|
|
986
|
+
skill_dir = self.skills_dir / name
|
|
987
|
+
if skill_dir.exists() and skill_dir.is_dir():
|
|
988
|
+
shutil.rmtree(skill_dir, ignore_errors=True)
|
|
989
|
+
self._skills.pop(name, None)
|
|
990
|
+
logger.info("Deleted skill folder: %s", name)
|
|
991
|
+
return True
|
|
992
|
+
# Legacy flat files
|
|
993
|
+
for ext in (".md", ".json", ".yaml", ".yml"):
|
|
994
|
+
fp = self.skills_dir / f"{name}{ext}"
|
|
995
|
+
if fp.exists():
|
|
996
|
+
fp.unlink()
|
|
997
|
+
self._skills.pop(name, None)
|
|
998
|
+
return True
|
|
999
|
+
return False
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1003
|
+
# Global singleton
|
|
1004
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1005
|
+
|
|
1006
|
+
_skill_manager: SkillManager | None = None
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def get_skill_manager(skills_dir: str | None = None) -> SkillManager:
|
|
1010
|
+
"""Get the global SkillManager singleton."""
|
|
1011
|
+
global _skill_manager
|
|
1012
|
+
if _skill_manager is None:
|
|
1013
|
+
_skill_manager = SkillManager(skills_dir)
|
|
1014
|
+
return _skill_manager
|