skilllite 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.
- skilllite/__init__.py +159 -0
- skilllite/analyzer.py +391 -0
- skilllite/builtin_tools.py +240 -0
- skilllite/cli.py +217 -0
- skilllite/core/__init__.py +65 -0
- skilllite/core/executor.py +182 -0
- skilllite/core/handler.py +332 -0
- skilllite/core/loops.py +770 -0
- skilllite/core/manager.py +507 -0
- skilllite/core/metadata.py +338 -0
- skilllite/core/prompt_builder.py +321 -0
- skilllite/core/registry.py +185 -0
- skilllite/core/skill_info.py +181 -0
- skilllite/core/tool_builder.py +338 -0
- skilllite/core/tools.py +253 -0
- skilllite/mcp/__init__.py +45 -0
- skilllite/mcp/server.py +734 -0
- skilllite/quick.py +420 -0
- skilllite/sandbox/__init__.py +36 -0
- skilllite/sandbox/base.py +93 -0
- skilllite/sandbox/config.py +229 -0
- skilllite/sandbox/skillbox/__init__.py +44 -0
- skilllite/sandbox/skillbox/binary.py +421 -0
- skilllite/sandbox/skillbox/executor.py +608 -0
- skilllite/sandbox/utils.py +77 -0
- skilllite/validation.py +137 -0
- skilllite-0.1.0.dist-info/METADATA +293 -0
- skilllite-0.1.0.dist-info/RECORD +32 -0
- skilllite-0.1.0.dist-info/WHEEL +5 -0
- skilllite-0.1.0.dist-info/entry_points.txt +3 -0
- skilllite-0.1.0.dist-info/licenses/LICENSE +21 -0
- skilllite-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Skill Registry - Skill registration and discovery.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Scanning directories for skills
|
|
6
|
+
- Registering individual skills
|
|
7
|
+
- Skill lookup and listing
|
|
8
|
+
- Multi-script tool detection
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional, Set
|
|
13
|
+
|
|
14
|
+
from .metadata import SkillMetadata, parse_skill_metadata, detect_all_scripts
|
|
15
|
+
from .skill_info import SkillInfo
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SkillRegistry:
|
|
19
|
+
"""
|
|
20
|
+
Registry for managing skill registration and discovery.
|
|
21
|
+
|
|
22
|
+
Handles skill scanning, registration, and lookup operations.
|
|
23
|
+
Multi-script analysis is performed lazily when needed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self._skills: Dict[str, SkillInfo] = {}
|
|
28
|
+
# Maps tool_name -> tool info for multi-script skills
|
|
29
|
+
self._multi_script_tools: Dict[str, Dict[str, str]] = {}
|
|
30
|
+
# Track which skills have been analyzed for multi-script tools
|
|
31
|
+
self._analyzed_skills: Set[str] = set()
|
|
32
|
+
|
|
33
|
+
def scan_directory(self, directory: Path) -> int:
|
|
34
|
+
"""
|
|
35
|
+
Scan a directory for skills.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
directory: Directory to scan
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Number of skills registered
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
FileNotFoundError: If directory does not exist
|
|
45
|
+
"""
|
|
46
|
+
if not directory.exists():
|
|
47
|
+
raise FileNotFoundError(f"Skills directory does not exist: {directory}")
|
|
48
|
+
|
|
49
|
+
# Check if directory itself is a skill
|
|
50
|
+
if (directory / "SKILL.md").exists():
|
|
51
|
+
self.register_skill(directory)
|
|
52
|
+
return 1
|
|
53
|
+
|
|
54
|
+
# Scan subdirectories
|
|
55
|
+
count = 0
|
|
56
|
+
for path in directory.iterdir():
|
|
57
|
+
if path.is_dir() and (path / "SKILL.md").exists():
|
|
58
|
+
try:
|
|
59
|
+
self.register_skill(path)
|
|
60
|
+
count += 1
|
|
61
|
+
except Exception as e:
|
|
62
|
+
print(f"Warning: Failed to register skill at {path}: {e}")
|
|
63
|
+
return count
|
|
64
|
+
|
|
65
|
+
def register_skill(self, skill_dir: Path) -> SkillInfo:
|
|
66
|
+
"""
|
|
67
|
+
Register a single skill from a directory.
|
|
68
|
+
|
|
69
|
+
Multi-script analysis is deferred until needed (lazy loading).
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
skill_dir: Path to skill directory
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Registered SkillInfo
|
|
76
|
+
"""
|
|
77
|
+
metadata = parse_skill_metadata(skill_dir)
|
|
78
|
+
info = SkillInfo(metadata, skill_dir)
|
|
79
|
+
self._skills[metadata.name] = info
|
|
80
|
+
return info
|
|
81
|
+
|
|
82
|
+
def analyze_multi_script_skill(self, skill_name: str) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Analyze a skill for multiple scripts and register them as tools.
|
|
85
|
+
|
|
86
|
+
Called lazily when tool definitions are requested.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
skill_name: Name of skill to analyze
|
|
90
|
+
"""
|
|
91
|
+
if skill_name in self._analyzed_skills:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
info = self._skills.get(skill_name)
|
|
95
|
+
if not info:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Only analyze skills without a main entry point
|
|
99
|
+
if not info.metadata.entry_point:
|
|
100
|
+
scripts = detect_all_scripts(info.path)
|
|
101
|
+
if scripts:
|
|
102
|
+
for script in scripts:
|
|
103
|
+
# Create unique tool name: skill-name__script-name
|
|
104
|
+
# Use double underscore instead of colon for API compatibility
|
|
105
|
+
tool_name = f"{skill_name}__{script['name']}"
|
|
106
|
+
self._multi_script_tools[tool_name] = {
|
|
107
|
+
"skill_name": skill_name,
|
|
108
|
+
"script_path": script["path"],
|
|
109
|
+
"script_name": script["name"],
|
|
110
|
+
"language": script["language"],
|
|
111
|
+
"filename": script["filename"],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
self._analyzed_skills.add(skill_name)
|
|
115
|
+
|
|
116
|
+
def analyze_all_multi_script_skills(self) -> None:
|
|
117
|
+
"""Analyze all registered skills for multi-script tools."""
|
|
118
|
+
for skill_name in self._skills:
|
|
119
|
+
self.analyze_multi_script_skill(skill_name)
|
|
120
|
+
|
|
121
|
+
def get_skill(self, name: str) -> Optional[SkillInfo]:
|
|
122
|
+
"""Get a skill by name."""
|
|
123
|
+
return self._skills.get(name)
|
|
124
|
+
|
|
125
|
+
def list_skills(self) -> List[SkillInfo]:
|
|
126
|
+
"""Get all registered skills."""
|
|
127
|
+
return list(self._skills.values())
|
|
128
|
+
|
|
129
|
+
def skill_names(self) -> List[str]:
|
|
130
|
+
"""Get names of all registered skills."""
|
|
131
|
+
return list(self._skills.keys())
|
|
132
|
+
|
|
133
|
+
def has_skill(self, name: str) -> bool:
|
|
134
|
+
"""Check if a skill exists."""
|
|
135
|
+
return name in self._skills
|
|
136
|
+
|
|
137
|
+
def is_executable(self, name: str) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Check if a skill or tool is executable.
|
|
140
|
+
|
|
141
|
+
Includes:
|
|
142
|
+
- Skills with a single entry_point
|
|
143
|
+
- Multi-script tools (skill-name:script-name format)
|
|
144
|
+
"""
|
|
145
|
+
if name in self._multi_script_tools:
|
|
146
|
+
return True
|
|
147
|
+
info = self._skills.get(name)
|
|
148
|
+
return info is not None and bool(info.metadata.entry_point)
|
|
149
|
+
|
|
150
|
+
def list_executable_skills(self) -> List[SkillInfo]:
|
|
151
|
+
"""Get all executable skills (with entry_point or multi-script tools)."""
|
|
152
|
+
executable = []
|
|
153
|
+
for info in self._skills.values():
|
|
154
|
+
if info.metadata.entry_point:
|
|
155
|
+
executable.append(info)
|
|
156
|
+
elif info.name in [t["skill_name"] for t in self._multi_script_tools.values()]:
|
|
157
|
+
executable.append(info)
|
|
158
|
+
return executable
|
|
159
|
+
|
|
160
|
+
def list_prompt_only_skills(self) -> List[SkillInfo]:
|
|
161
|
+
"""Get all prompt-only skills (without entry_point and no multi-script tools)."""
|
|
162
|
+
prompt_only = []
|
|
163
|
+
multi_script_skill_names = set(t["skill_name"] for t in self._multi_script_tools.values())
|
|
164
|
+
for info in self._skills.values():
|
|
165
|
+
if not info.metadata.entry_point and info.name not in multi_script_skill_names:
|
|
166
|
+
prompt_only.append(info)
|
|
167
|
+
return prompt_only
|
|
168
|
+
|
|
169
|
+
def list_multi_script_tools(self) -> List[str]:
|
|
170
|
+
"""Get all multi-script tool names."""
|
|
171
|
+
return list(self._multi_script_tools.keys())
|
|
172
|
+
|
|
173
|
+
def get_multi_script_tool_info(self, tool_name: str) -> Optional[Dict[str, str]]:
|
|
174
|
+
"""Get info for a multi-script tool."""
|
|
175
|
+
return self._multi_script_tools.get(tool_name)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def skills(self) -> Dict[str, SkillInfo]:
|
|
179
|
+
"""Direct access to skills dict (for compatibility)."""
|
|
180
|
+
return self._skills
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def multi_script_tools(self) -> Dict[str, Dict[str, str]]:
|
|
184
|
+
"""Direct access to multi-script tools dict (for compatibility)."""
|
|
185
|
+
return self._multi_script_tools
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SkillInfo - Information container for a registered skill.
|
|
3
|
+
|
|
4
|
+
This is a CORE module - do not modify without explicit permission.
|
|
5
|
+
|
|
6
|
+
This module provides the SkillInfo class which encapsulates all information
|
|
7
|
+
about a skill including its metadata, content, references, and assets.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from .metadata import SkillMetadata, detect_language, detect_all_scripts
|
|
15
|
+
|
|
16
|
+
class SkillInfo:
|
|
17
|
+
"""Information about a registered skill."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, metadata: SkillMetadata, path: Path):
|
|
20
|
+
self.metadata = metadata
|
|
21
|
+
self.path = path
|
|
22
|
+
self._full_content_cache: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def name(self) -> str:
|
|
26
|
+
return self.metadata.name
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def description(self) -> Optional[str]:
|
|
30
|
+
return self.metadata.description
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def language(self) -> str:
|
|
34
|
+
return detect_language(self.path, self.metadata)
|
|
35
|
+
|
|
36
|
+
def get_full_content(self) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Get the full content of SKILL.md file.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Complete content of SKILL.md including instructions and examples
|
|
42
|
+
"""
|
|
43
|
+
if self._full_content_cache is not None:
|
|
44
|
+
return self._full_content_cache
|
|
45
|
+
|
|
46
|
+
skill_md_path = self.path / "SKILL.md"
|
|
47
|
+
if skill_md_path.exists():
|
|
48
|
+
self._full_content_cache = skill_md_path.read_text(encoding="utf-8")
|
|
49
|
+
return self._full_content_cache
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
def get_references(self) -> Dict[str, str]:
|
|
53
|
+
"""
|
|
54
|
+
Get all reference documents from references/ directory.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dictionary mapping filename to file content
|
|
58
|
+
"""
|
|
59
|
+
references = {}
|
|
60
|
+
references_dir = self.path / "references"
|
|
61
|
+
|
|
62
|
+
if not references_dir.exists():
|
|
63
|
+
return references
|
|
64
|
+
|
|
65
|
+
for file_path in references_dir.iterdir():
|
|
66
|
+
if file_path.is_file():
|
|
67
|
+
try:
|
|
68
|
+
content = file_path.read_text(encoding="utf-8")
|
|
69
|
+
references[file_path.name] = content
|
|
70
|
+
except Exception:
|
|
71
|
+
references[file_path.name] = f"[Error reading file: {file_path.name}]"
|
|
72
|
+
|
|
73
|
+
return references
|
|
74
|
+
|
|
75
|
+
def get_assets(self) -> Dict[str, Any]:
|
|
76
|
+
"""
|
|
77
|
+
Get all asset files from assets/ directory.
|
|
78
|
+
|
|
79
|
+
For JSON files, returns parsed content.
|
|
80
|
+
For other files, returns file path for reference.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dictionary mapping filename to content or path
|
|
84
|
+
"""
|
|
85
|
+
assets = {}
|
|
86
|
+
assets_dir = self.path / "assets"
|
|
87
|
+
|
|
88
|
+
if not assets_dir.exists():
|
|
89
|
+
return assets
|
|
90
|
+
|
|
91
|
+
for file_path in assets_dir.iterdir():
|
|
92
|
+
if file_path.is_file():
|
|
93
|
+
try:
|
|
94
|
+
if file_path.suffix.lower() == ".json":
|
|
95
|
+
content = file_path.read_text(encoding="utf-8")
|
|
96
|
+
assets[file_path.name] = json.loads(content)
|
|
97
|
+
elif file_path.suffix.lower() in [".txt", ".md", ".yaml", ".yml", ".toml", ".ini", ".cfg"]:
|
|
98
|
+
assets[file_path.name] = file_path.read_text(encoding="utf-8")
|
|
99
|
+
else:
|
|
100
|
+
assets[file_path.name] = {"_path": str(file_path), "_type": "binary"}
|
|
101
|
+
except Exception as e:
|
|
102
|
+
assets[file_path.name] = {"_error": str(e)}
|
|
103
|
+
|
|
104
|
+
return assets
|
|
105
|
+
|
|
106
|
+
def get_assets_dir(self) -> Optional[Path]:
|
|
107
|
+
"""
|
|
108
|
+
Get the path to assets/ directory if it exists.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Path to assets directory, or None if not exists
|
|
112
|
+
"""
|
|
113
|
+
assets_dir = self.path / "assets"
|
|
114
|
+
return assets_dir if assets_dir.exists() else None
|
|
115
|
+
|
|
116
|
+
def get_references_dir(self) -> Optional[Path]:
|
|
117
|
+
"""
|
|
118
|
+
Get the path to references/ directory if it exists.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Path to references directory, or None if not exists
|
|
122
|
+
"""
|
|
123
|
+
references_dir = self.path / "references"
|
|
124
|
+
return references_dir if references_dir.exists() else None
|
|
125
|
+
|
|
126
|
+
def get_all_scripts(self) -> List[Dict[str, str]]:
|
|
127
|
+
"""
|
|
128
|
+
Get all executable scripts in this skill.
|
|
129
|
+
|
|
130
|
+
This is useful for skills with multiple entry points (like skill-creator
|
|
131
|
+
which has init_skill.py, package_skill.py, etc.)
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of dicts with 'name', 'path', 'language', and 'filename' for each script
|
|
135
|
+
"""
|
|
136
|
+
return detect_all_scripts(self.path)
|
|
137
|
+
|
|
138
|
+
def has_multiple_scripts(self) -> bool:
|
|
139
|
+
"""
|
|
140
|
+
Check if this skill has multiple executable scripts.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True if there are multiple scripts that could be entry points
|
|
144
|
+
"""
|
|
145
|
+
scripts = self.get_all_scripts()
|
|
146
|
+
return len(scripts) > 1
|
|
147
|
+
|
|
148
|
+
def get_context(self, include_references: bool = True, include_assets: bool = True) -> Dict[str, Any]:
|
|
149
|
+
"""
|
|
150
|
+
Get complete skill context for LLM consumption.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
include_references: Whether to include reference documents
|
|
154
|
+
include_assets: Whether to include asset files
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Dictionary containing full skill context
|
|
158
|
+
"""
|
|
159
|
+
context = {
|
|
160
|
+
"name": self.name,
|
|
161
|
+
"description": self.description,
|
|
162
|
+
"full_instructions": self.get_full_content(),
|
|
163
|
+
"skill_dir": str(self.path),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if include_references:
|
|
167
|
+
refs = self.get_references()
|
|
168
|
+
if refs:
|
|
169
|
+
context["references"] = refs
|
|
170
|
+
|
|
171
|
+
if include_assets:
|
|
172
|
+
assets = self.get_assets()
|
|
173
|
+
if assets:
|
|
174
|
+
context["assets"] = assets
|
|
175
|
+
|
|
176
|
+
# Include available scripts info for multi-script skills
|
|
177
|
+
scripts = self.get_all_scripts()
|
|
178
|
+
if scripts:
|
|
179
|
+
context["available_scripts"] = scripts
|
|
180
|
+
|
|
181
|
+
return context
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool Builder - Tool definition generation and schema inference.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Creating tool definitions from skills
|
|
6
|
+
- Schema inference from script content
|
|
7
|
+
- Argparse parsing for Python scripts
|
|
8
|
+
- Multi-script tool definition creation
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from .skill_info import SkillInfo
|
|
17
|
+
from .tools import ToolDefinition
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .registry import SkillRegistry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolBuilder:
|
|
24
|
+
"""
|
|
25
|
+
Builder for creating tool definitions from skills.
|
|
26
|
+
|
|
27
|
+
Handles tool definition generation and argparse parsing for Python scripts.
|
|
28
|
+
Uses progressive disclosure - tool definitions only contain name and description,
|
|
29
|
+
full SKILL.md content is injected when the tool is actually called.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, registry: "SkillRegistry"):
|
|
33
|
+
"""
|
|
34
|
+
Initialize the tool builder.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
registry: Skill registry for accessing skill info
|
|
38
|
+
"""
|
|
39
|
+
self._registry = registry
|
|
40
|
+
# Cache for multi-script tool schemas (argparse-based)
|
|
41
|
+
self._multi_script_schemas: Dict[str, Dict[str, Any]] = {}
|
|
42
|
+
|
|
43
|
+
def get_tool_definitions(self, include_prompt_only: bool = False) -> List[ToolDefinition]:
|
|
44
|
+
"""
|
|
45
|
+
Get tool definitions for registered skills.
|
|
46
|
+
|
|
47
|
+
Includes:
|
|
48
|
+
- Regular skills with a single entry_point
|
|
49
|
+
- Multi-script tools (each script as a separate tool)
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
include_prompt_only: Whether to include prompt-only skills
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
List of tool definitions
|
|
56
|
+
"""
|
|
57
|
+
# Lazily analyze all skills for multi-script tools
|
|
58
|
+
self._registry.analyze_all_multi_script_skills()
|
|
59
|
+
|
|
60
|
+
definitions = []
|
|
61
|
+
multi_script_skill_names = set(
|
|
62
|
+
t["skill_name"] for t in self._registry.multi_script_tools.values()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Add regular skills with single entry_point
|
|
66
|
+
for info in self._registry.list_skills():
|
|
67
|
+
if info.metadata.entry_point:
|
|
68
|
+
definition = self._create_tool_definition(info)
|
|
69
|
+
definitions.append(definition)
|
|
70
|
+
elif info.name in multi_script_skill_names:
|
|
71
|
+
# Skip - will be handled by multi-script tools below
|
|
72
|
+
pass
|
|
73
|
+
elif include_prompt_only:
|
|
74
|
+
definition = self._create_tool_definition(info)
|
|
75
|
+
definitions.append(definition)
|
|
76
|
+
|
|
77
|
+
# Add multi-script tools
|
|
78
|
+
for tool_name, tool_info in self._registry.multi_script_tools.items():
|
|
79
|
+
skill_info = self._registry.get_skill(tool_info["skill_name"])
|
|
80
|
+
if skill_info:
|
|
81
|
+
definition = self._create_multi_script_tool_definition(
|
|
82
|
+
tool_name, tool_info, skill_info
|
|
83
|
+
)
|
|
84
|
+
definitions.append(definition)
|
|
85
|
+
|
|
86
|
+
return definitions
|
|
87
|
+
|
|
88
|
+
def get_tools_openai(self) -> List[Dict[str, Any]]:
|
|
89
|
+
"""Get tool definitions in OpenAI-compatible format."""
|
|
90
|
+
return [d.to_openai_format() for d in self.get_tool_definitions()]
|
|
91
|
+
|
|
92
|
+
def get_tools_claude_native(self) -> List[Dict[str, Any]]:
|
|
93
|
+
"""Get tool definitions in Claude's native API format."""
|
|
94
|
+
return [d.to_claude_format() for d in self.get_tool_definitions()]
|
|
95
|
+
|
|
96
|
+
def _create_tool_definition(self, info: SkillInfo) -> ToolDefinition:
|
|
97
|
+
"""Create a tool definition from skill info.
|
|
98
|
+
|
|
99
|
+
Uses progressive disclosure:
|
|
100
|
+
1. Tool definition only contains name and description (from YAML front matter)
|
|
101
|
+
2. Uses a flexible schema that accepts any parameters
|
|
102
|
+
3. Full SKILL.md content is injected when the tool is actually called,
|
|
103
|
+
letting the LLM understand the expected parameters from the documentation
|
|
104
|
+
"""
|
|
105
|
+
description = info.description or f"Execute the {info.name} skill"
|
|
106
|
+
|
|
107
|
+
# Use a flexible schema that accepts any parameters
|
|
108
|
+
# The LLM will understand the expected format from the full SKILL.md
|
|
109
|
+
# content that is injected when the tool is called
|
|
110
|
+
input_schema = {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"properties": {},
|
|
113
|
+
"additionalProperties": True
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return ToolDefinition(
|
|
117
|
+
name=info.name,
|
|
118
|
+
description=description,
|
|
119
|
+
input_schema=input_schema
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _create_multi_script_tool_definition(
|
|
123
|
+
self,
|
|
124
|
+
tool_name: str,
|
|
125
|
+
tool_info: Dict[str, str],
|
|
126
|
+
skill_info: SkillInfo
|
|
127
|
+
) -> ToolDefinition:
|
|
128
|
+
"""Create a tool definition for a multi-script tool."""
|
|
129
|
+
script_name = tool_info["script_name"]
|
|
130
|
+
script_path = skill_info.path / tool_info["script_path"]
|
|
131
|
+
description = f"Execute {script_name} from {skill_info.name} skill"
|
|
132
|
+
|
|
133
|
+
if script_path.exists():
|
|
134
|
+
try:
|
|
135
|
+
script_content = script_path.read_text(encoding="utf-8")
|
|
136
|
+
docstring = self._extract_script_docstring(script_content)
|
|
137
|
+
if docstring:
|
|
138
|
+
first_line = docstring.strip().split('\n')[0].strip()
|
|
139
|
+
if first_line:
|
|
140
|
+
description = first_line
|
|
141
|
+
# Add action hint for common operations
|
|
142
|
+
if "init" in script_name.lower() or "create" in script_name.lower():
|
|
143
|
+
description += ". Call this tool directly to create a new skill."
|
|
144
|
+
elif "package" in script_name.lower():
|
|
145
|
+
description += ". Call this tool directly to package a skill."
|
|
146
|
+
elif "validate" in script_name.lower():
|
|
147
|
+
description += ". Call this tool directly to validate a skill."
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
input_schema = self._infer_script_schema(tool_name, skill_info, tool_info)
|
|
152
|
+
|
|
153
|
+
return ToolDefinition(
|
|
154
|
+
name=tool_name,
|
|
155
|
+
description=description,
|
|
156
|
+
input_schema=input_schema
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _infer_script_schema(
|
|
160
|
+
self,
|
|
161
|
+
tool_name: str,
|
|
162
|
+
skill_info: SkillInfo,
|
|
163
|
+
tool_info: Dict[str, str]
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""Infer input schema for a multi-script tool using argparse parsing."""
|
|
166
|
+
if tool_name in self._multi_script_schemas:
|
|
167
|
+
return self._multi_script_schemas[tool_name]
|
|
168
|
+
|
|
169
|
+
script_path = skill_info.path / tool_info["script_path"]
|
|
170
|
+
script_code = None
|
|
171
|
+
if script_path.exists():
|
|
172
|
+
try:
|
|
173
|
+
script_code = script_path.read_text(encoding="utf-8")
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
# Try argparse parsing for Python scripts
|
|
178
|
+
if script_code and tool_info.get("language") == "python":
|
|
179
|
+
schema = self._parse_argparse_schema(script_code)
|
|
180
|
+
if schema:
|
|
181
|
+
self._multi_script_schemas[tool_name] = schema
|
|
182
|
+
return schema
|
|
183
|
+
|
|
184
|
+
# Default schema for scripts without argparse
|
|
185
|
+
return {
|
|
186
|
+
"type": "object",
|
|
187
|
+
"properties": {
|
|
188
|
+
"args": {
|
|
189
|
+
"type": "array",
|
|
190
|
+
"items": {"type": "string"},
|
|
191
|
+
"description": "Command line arguments to pass to the script"
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
"required": []
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def _extract_script_docstring(self, script_content: str) -> Optional[str]:
|
|
198
|
+
"""Extract the module-level docstring from a Python script."""
|
|
199
|
+
try:
|
|
200
|
+
tree = ast.parse(script_content)
|
|
201
|
+
return ast.get_docstring(tree)
|
|
202
|
+
except Exception:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def _parse_argparse_schema(self, script_code: str) -> Optional[Dict[str, Any]]:
|
|
206
|
+
"""
|
|
207
|
+
Parse argparse argument definitions from Python script code.
|
|
208
|
+
|
|
209
|
+
Extracts add_argument calls and converts them to JSON schema format.
|
|
210
|
+
"""
|
|
211
|
+
properties = {}
|
|
212
|
+
required = []
|
|
213
|
+
|
|
214
|
+
# Pattern to match add_argument calls
|
|
215
|
+
arg_pattern = re.compile(
|
|
216
|
+
r'\.add_argument\s*\(\s*["\']([^"\']+)["\']'
|
|
217
|
+
r'(?:\s*,\s*["\']([^"\']+)["\'])?'
|
|
218
|
+
r'([^)]*)\)',
|
|
219
|
+
re.MULTILINE | re.DOTALL
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
for match in arg_pattern.finditer(script_code):
|
|
223
|
+
arg_name = match.group(1)
|
|
224
|
+
second_arg = match.group(2)
|
|
225
|
+
kwargs_str = match.group(3)
|
|
226
|
+
|
|
227
|
+
# Determine parameter name
|
|
228
|
+
if arg_name.startswith('--'):
|
|
229
|
+
param_name = arg_name[2:].replace('-', '_')
|
|
230
|
+
is_positional = False
|
|
231
|
+
elif arg_name.startswith('-'):
|
|
232
|
+
if second_arg and second_arg.startswith('--'):
|
|
233
|
+
param_name = second_arg[2:].replace('-', '_')
|
|
234
|
+
else:
|
|
235
|
+
param_name = arg_name[1:]
|
|
236
|
+
is_positional = False
|
|
237
|
+
else:
|
|
238
|
+
param_name = arg_name.replace('-', '_')
|
|
239
|
+
is_positional = True
|
|
240
|
+
|
|
241
|
+
prop = {"type": "string"}
|
|
242
|
+
|
|
243
|
+
# Extract help text
|
|
244
|
+
help_match = re.search(r'help\s*=\s*["\']([^"\']+)["\']', kwargs_str)
|
|
245
|
+
if help_match:
|
|
246
|
+
prop["description"] = help_match.group(1)
|
|
247
|
+
|
|
248
|
+
# Extract type
|
|
249
|
+
type_match = re.search(r'type\s*=\s*(\w+)', kwargs_str)
|
|
250
|
+
if type_match:
|
|
251
|
+
type_name = type_match.group(1)
|
|
252
|
+
if type_name == 'int':
|
|
253
|
+
prop["type"] = "integer"
|
|
254
|
+
elif type_name == 'float':
|
|
255
|
+
prop["type"] = "number"
|
|
256
|
+
elif type_name == 'bool':
|
|
257
|
+
prop["type"] = "boolean"
|
|
258
|
+
|
|
259
|
+
# Check for action="store_true" or action="store_false"
|
|
260
|
+
action_match = re.search(r'action\s*=\s*["\'](\w+)["\']', kwargs_str)
|
|
261
|
+
if action_match:
|
|
262
|
+
action = action_match.group(1)
|
|
263
|
+
if action in ('store_true', 'store_false'):
|
|
264
|
+
prop["type"] = "boolean"
|
|
265
|
+
|
|
266
|
+
# Check for nargs
|
|
267
|
+
nargs_match = re.search(r'nargs\s*=\s*["\']?([^,\s\)]+)["\']?', kwargs_str)
|
|
268
|
+
if nargs_match:
|
|
269
|
+
nargs = nargs_match.group(1)
|
|
270
|
+
if nargs in ('*', '+') or nargs.isdigit():
|
|
271
|
+
prop["type"] = "array"
|
|
272
|
+
prop["items"] = {"type": "string"}
|
|
273
|
+
|
|
274
|
+
# Check for choices
|
|
275
|
+
choices_match = re.search(r'choices\s*=\s*\[([^\]]+)\]', kwargs_str)
|
|
276
|
+
if choices_match:
|
|
277
|
+
choices_str = choices_match.group(1)
|
|
278
|
+
choices = re.findall(r'["\']([^"\']+)["\']', choices_str)
|
|
279
|
+
if choices:
|
|
280
|
+
prop["enum"] = choices
|
|
281
|
+
|
|
282
|
+
# Check for default
|
|
283
|
+
default_match = re.search(r'default\s*=\s*([^,\)]+)', kwargs_str)
|
|
284
|
+
if default_match:
|
|
285
|
+
default_val = default_match.group(1).strip()
|
|
286
|
+
if default_val not in ('None', '""', "''"):
|
|
287
|
+
prop["default"] = default_val.strip('"\'')
|
|
288
|
+
|
|
289
|
+
# Check if required
|
|
290
|
+
required_match = re.search(r'required\s*=\s*True', kwargs_str)
|
|
291
|
+
if required_match or is_positional:
|
|
292
|
+
required.append(param_name)
|
|
293
|
+
|
|
294
|
+
properties[param_name] = prop
|
|
295
|
+
|
|
296
|
+
if not properties:
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
"type": "object",
|
|
301
|
+
"properties": properties,
|
|
302
|
+
"required": required
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
def infer_all_schemas(self, force: bool = False) -> Dict[str, Dict[str, Any]]:
|
|
306
|
+
"""
|
|
307
|
+
Infer schemas for all skills that don't have one defined.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
force: Force re-inference even if cached
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Dict mapping skill name to inferred schema
|
|
314
|
+
"""
|
|
315
|
+
if not self._schema_inferrer:
|
|
316
|
+
raise RuntimeError("Schema inferrer not configured.")
|
|
317
|
+
|
|
318
|
+
results = {}
|
|
319
|
+
for info in self._registry.list_skills():
|
|
320
|
+
name = info.name
|
|
321
|
+
if info.metadata.input_schema:
|
|
322
|
+
results[name] = info.metadata.input_schema
|
|
323
|
+
continue
|
|
324
|
+
if not force and name in self._inferred_schemas:
|
|
325
|
+
results[name] = self._inferred_schemas[name]
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
schema = self._infer_skill_schema(info)
|
|
329
|
+
if schema:
|
|
330
|
+
self._inferred_schemas[name] = schema
|
|
331
|
+
results[name] = schema
|
|
332
|
+
|
|
333
|
+
return results
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def inferred_schemas(self) -> Dict[str, Dict[str, Any]]:
|
|
337
|
+
"""Access to inferred schemas cache."""
|
|
338
|
+
return self._inferred_schemas
|