structured_skills 0.1.1__tar.gz
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.
- structured_skills-0.1.1/PKG-INFO +88 -0
- structured_skills-0.1.1/README.md +76 -0
- structured_skills-0.1.1/pyproject.toml +62 -0
- structured_skills-0.1.1/src/structured_skills/__init__.py +13 -0
- structured_skills-0.1.1/src/structured_skills/__main__.py +4 -0
- structured_skills-0.1.1/src/structured_skills/cli.py +140 -0
- structured_skills-0.1.1/src/structured_skills/cst/__init__.py +0 -0
- structured_skills-0.1.1/src/structured_skills/cst/utils.py +171 -0
- structured_skills-0.1.1/src/structured_skills/server.py +42 -0
- structured_skills-0.1.1/src/structured_skills/skill_registry.py +226 -0
- structured_skills-0.1.1/src/structured_skills/smolagents.py +119 -0
- structured_skills-0.1.1/src/structured_skills/validator.py +267 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: structured_skills
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Structured Skills for Agents
|
|
5
|
+
Requires-Dist: fastmcp>=3.0.0
|
|
6
|
+
Requires-Dist: libcst>=1.8.6
|
|
7
|
+
Requires-Dist: strictyaml>=1.7.3
|
|
8
|
+
Requires-Dist: smolagents>=1.0.0 ; extra == 'smolagents'
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Provides-Extra: smolagents
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# structured_skills
|
|
14
|
+
|
|
15
|
+
Structured Skills for Agents - launch MCP servers from skill directories
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Quick usage to launch MCP server:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
structured_skills run path/to/root/skills
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To test via CLI:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
structured_skills cli list_skills
|
|
29
|
+
structured_skills cli load_skill <skill_name>
|
|
30
|
+
structured_skills cli read_skill_resource <skill_name> <resource_name>
|
|
31
|
+
structured_skills cli run_skill <skill_name> <function_name>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Programmatically:
|
|
35
|
+
|
|
36
|
+
```py
|
|
37
|
+
from structured_skills import SkillRegistry
|
|
38
|
+
|
|
39
|
+
registry = SkillRegistry("/path/to/skills")
|
|
40
|
+
|
|
41
|
+
# List all available skills
|
|
42
|
+
registry.list_skills()
|
|
43
|
+
|
|
44
|
+
# Load full skill instructions
|
|
45
|
+
registry.load_skill(skill_name)
|
|
46
|
+
|
|
47
|
+
# Read a resource (file, script, or function info)
|
|
48
|
+
registry.read_skill_resource(skill_name, resource_name, args)
|
|
49
|
+
|
|
50
|
+
# Execute a skill function
|
|
51
|
+
registry.run_skill(skill_name, function_name, args)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## smolagents Integration
|
|
55
|
+
|
|
56
|
+
structured_skills provides integration with [smolagents](https://github.com/huggingface/smolagents):
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
uv pip install structured_skills[smolagents]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```py
|
|
63
|
+
from structured_skills import SkillRegistry
|
|
64
|
+
from structured_skills.smolagents import create_smolagents_tools
|
|
65
|
+
|
|
66
|
+
registry = SkillRegistry("/path/to/skills")
|
|
67
|
+
|
|
68
|
+
# Create all tools
|
|
69
|
+
tools = create_smolagents_tools(registry)
|
|
70
|
+
|
|
71
|
+
# Or create specific tools
|
|
72
|
+
tools = create_smolagents_tools(registry, tools=["list_skills", "load_skill"])
|
|
73
|
+
|
|
74
|
+
# Use with smolagents
|
|
75
|
+
from smolagents import CodeAgent, HfApiModel
|
|
76
|
+
|
|
77
|
+
agent = CodeAgent(tools=tools, model=HfApiModel())
|
|
78
|
+
agent.run("List available skills")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Validation
|
|
82
|
+
|
|
83
|
+
Perform checks with suggested fixes:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
structured_skills check path/to/root/skills
|
|
87
|
+
structured_skills check path/to/root/skills --fix # try to fix observed issues
|
|
88
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# structured_skills
|
|
2
|
+
|
|
3
|
+
Structured Skills for Agents - launch MCP servers from skill directories
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
Quick usage to launch MCP server:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
structured_skills run path/to/root/skills
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
To test via CLI:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
structured_skills cli list_skills
|
|
17
|
+
structured_skills cli load_skill <skill_name>
|
|
18
|
+
structured_skills cli read_skill_resource <skill_name> <resource_name>
|
|
19
|
+
structured_skills cli run_skill <skill_name> <function_name>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Programmatically:
|
|
23
|
+
|
|
24
|
+
```py
|
|
25
|
+
from structured_skills import SkillRegistry
|
|
26
|
+
|
|
27
|
+
registry = SkillRegistry("/path/to/skills")
|
|
28
|
+
|
|
29
|
+
# List all available skills
|
|
30
|
+
registry.list_skills()
|
|
31
|
+
|
|
32
|
+
# Load full skill instructions
|
|
33
|
+
registry.load_skill(skill_name)
|
|
34
|
+
|
|
35
|
+
# Read a resource (file, script, or function info)
|
|
36
|
+
registry.read_skill_resource(skill_name, resource_name, args)
|
|
37
|
+
|
|
38
|
+
# Execute a skill function
|
|
39
|
+
registry.run_skill(skill_name, function_name, args)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## smolagents Integration
|
|
43
|
+
|
|
44
|
+
structured_skills provides integration with [smolagents](https://github.com/huggingface/smolagents):
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
uv pip install structured_skills[smolagents]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```py
|
|
51
|
+
from structured_skills import SkillRegistry
|
|
52
|
+
from structured_skills.smolagents import create_smolagents_tools
|
|
53
|
+
|
|
54
|
+
registry = SkillRegistry("/path/to/skills")
|
|
55
|
+
|
|
56
|
+
# Create all tools
|
|
57
|
+
tools = create_smolagents_tools(registry)
|
|
58
|
+
|
|
59
|
+
# Or create specific tools
|
|
60
|
+
tools = create_smolagents_tools(registry, tools=["list_skills", "load_skill"])
|
|
61
|
+
|
|
62
|
+
# Use with smolagents
|
|
63
|
+
from smolagents import CodeAgent, HfApiModel
|
|
64
|
+
|
|
65
|
+
agent = CodeAgent(tools=tools, model=HfApiModel())
|
|
66
|
+
agent.run("List available skills")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Validation
|
|
70
|
+
|
|
71
|
+
Perform checks with suggested fixes:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
structured_skills check path/to/root/skills
|
|
75
|
+
structured_skills check path/to/root/skills --fix # try to fix observed issues
|
|
76
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "structured_skills"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Structured Skills for Agents"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = []
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"fastmcp>=3.0.0",
|
|
10
|
+
"libcst>=1.8.6",
|
|
11
|
+
"strictyaml>=1.7.3",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
smolagents = ["smolagents>=1.0.0"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
structured_skills = "structured_skills:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"bump-my-version>=1.2.7",
|
|
27
|
+
"pytest>=9.0.2",
|
|
28
|
+
"pytest-cov>=7.0.0",
|
|
29
|
+
"ruff>=0.15.2",
|
|
30
|
+
"ty>=0.0.17",
|
|
31
|
+
]
|
|
32
|
+
smolagents = ["smolagents>=1.0.0"]
|
|
33
|
+
|
|
34
|
+
[tool.coverage.run]
|
|
35
|
+
omit = [
|
|
36
|
+
"*/__init__.py",
|
|
37
|
+
"*/__main__.py",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E4", "E7", "E9", "F", "I001"]
|
|
45
|
+
|
|
46
|
+
[tool.bumpversion]
|
|
47
|
+
current_version = "0.1.1"
|
|
48
|
+
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
49
|
+
serialize = ["{major}.{minor}.{patch}"]
|
|
50
|
+
search = "{current_version}"
|
|
51
|
+
replace = "{new_version}"
|
|
52
|
+
regex = false
|
|
53
|
+
ignore_missing_version = false
|
|
54
|
+
tag = true
|
|
55
|
+
sign_tags = false
|
|
56
|
+
tag_name = "v{new_version}"
|
|
57
|
+
tag_message = "Bump version: {current_version} → {new_version}"
|
|
58
|
+
allow_dirty = false
|
|
59
|
+
commit = true
|
|
60
|
+
message = "Bump version: {current_version} → {new_version}"
|
|
61
|
+
pre_commit_hooks = ["uv sync", "git add uv.lock"] # The new bit.
|
|
62
|
+
commit_args = ""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from structured_skills.cli import main
|
|
2
|
+
from structured_skills.skill_registry import Skill, SkillRegistry, get_tool
|
|
3
|
+
from structured_skills.validator import find_skill_md, parse_frontmatter, validate
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"main",
|
|
7
|
+
"SkillRegistry",
|
|
8
|
+
"Skill",
|
|
9
|
+
"get_tool",
|
|
10
|
+
"validate",
|
|
11
|
+
"find_skill_md",
|
|
12
|
+
"parse_frontmatter",
|
|
13
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from structured_skills.server import create_mcp_server
|
|
6
|
+
from structured_skills.skill_registry import SkillRegistry
|
|
7
|
+
from structured_skills.validator import validate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="structured_skills",
|
|
13
|
+
description="Structured Skills for Agents - Launch MCP servers from skill directories",
|
|
14
|
+
)
|
|
15
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
16
|
+
|
|
17
|
+
run_parser = subparsers.add_parser("run", help="Launch MCP server for skills")
|
|
18
|
+
run_parser.add_argument("skill_dir", type=Path, help="Path to skill root directory")
|
|
19
|
+
|
|
20
|
+
cli_parser = subparsers.add_parser("cli", help="CLI tools for skill management")
|
|
21
|
+
cli_subparsers = cli_parser.add_subparsers(dest="cli_command", help="CLI subcommands")
|
|
22
|
+
|
|
23
|
+
list_skills_parser = cli_subparsers.add_parser("list_skills", help="List skills")
|
|
24
|
+
list_skills_parser.add_argument("skill_dir", type=Path, help="Path to skill root directory")
|
|
25
|
+
|
|
26
|
+
load_skill_parser = cli_subparsers.add_parser("load_skill", help="Load a skill")
|
|
27
|
+
load_skill_parser.add_argument("skill_dir", type=Path, help="Path to skill root directory")
|
|
28
|
+
load_skill_parser.add_argument("skill_name", help="Name of the skill to load")
|
|
29
|
+
|
|
30
|
+
read_resource_parser = cli_subparsers.add_parser(
|
|
31
|
+
"read_skill_resource", help="Read a skill resource"
|
|
32
|
+
)
|
|
33
|
+
read_resource_parser.add_argument("skill_dir", type=Path, help="Path to skill root directory")
|
|
34
|
+
read_resource_parser.add_argument("skill_name", help="Name of the skill")
|
|
35
|
+
read_resource_parser.add_argument("resource_name", help="Name of the resource")
|
|
36
|
+
read_resource_parser.add_argument(
|
|
37
|
+
"--args", type=str, default=None, help="JSON args for the resource"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
run_skill_parser = cli_subparsers.add_parser("run_skill_script", help="Run a skill script")
|
|
41
|
+
run_skill_parser.add_argument("skill_dir", type=Path, help="Path to skill root directory")
|
|
42
|
+
run_skill_parser.add_argument("skill_name", help="Name of the skill")
|
|
43
|
+
run_skill_parser.add_argument("function_or_script", help="Function or script name to run")
|
|
44
|
+
run_skill_parser.add_argument("--args", type=str, default=None, help="JSON args for the script")
|
|
45
|
+
|
|
46
|
+
check_parser = subparsers.add_parser("check", help="Validate skill directory")
|
|
47
|
+
check_parser.add_argument("skill_dir", type=Path, help="Path to skill directory")
|
|
48
|
+
check_parser.add_argument("--fix", action="store_true", help="Attempt to fix issues")
|
|
49
|
+
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def handle_cli(args: argparse.Namespace) -> None:
|
|
54
|
+
registry = SkillRegistry(args.skill_dir)
|
|
55
|
+
|
|
56
|
+
if args.cli_command == "list_skills":
|
|
57
|
+
skills = registry.list_skills()
|
|
58
|
+
for name, desc in skills.items():
|
|
59
|
+
print(f"{name}: {desc}")
|
|
60
|
+
|
|
61
|
+
elif args.cli_command == "load_skill":
|
|
62
|
+
content = registry.load_skill(args.skill_name)
|
|
63
|
+
print(content)
|
|
64
|
+
|
|
65
|
+
elif args.cli_command == "read_skill_resource":
|
|
66
|
+
import json
|
|
67
|
+
|
|
68
|
+
args_dict = json.loads(args.args) if args.args else None
|
|
69
|
+
result = registry.read_skill_resource(args.skill_name, args.resource_name, args_dict)
|
|
70
|
+
if isinstance(result, dict):
|
|
71
|
+
print(json.dumps(result, indent=2))
|
|
72
|
+
else:
|
|
73
|
+
print(result)
|
|
74
|
+
|
|
75
|
+
elif args.cli_command == "run_skill_script":
|
|
76
|
+
import json
|
|
77
|
+
|
|
78
|
+
args_dict = json.loads(args.args) if args.args else None
|
|
79
|
+
result = registry.run_skill(args.skill_name, args.function_or_script, args_dict)
|
|
80
|
+
print(result)
|
|
81
|
+
|
|
82
|
+
else:
|
|
83
|
+
print("Unknown CLI command. Use --help for usage.")
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def handle_check(args: argparse.Namespace) -> None:
|
|
88
|
+
skill_dir = args.skill_dir
|
|
89
|
+
|
|
90
|
+
if not skill_dir.is_dir():
|
|
91
|
+
print(f"Error: {skill_dir} is not a directory")
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
all_errors: list[tuple[Path, list[str]]] = []
|
|
95
|
+
|
|
96
|
+
for sub_dir in skill_dir.iterdir():
|
|
97
|
+
if sub_dir.is_dir():
|
|
98
|
+
errors = validate(sub_dir)
|
|
99
|
+
if errors:
|
|
100
|
+
all_errors.append((sub_dir, errors))
|
|
101
|
+
|
|
102
|
+
if all_errors:
|
|
103
|
+
print("Validation errors found:\n")
|
|
104
|
+
for dir_path, errors in all_errors:
|
|
105
|
+
print(f"{dir_path}:")
|
|
106
|
+
for error in errors:
|
|
107
|
+
print(f" - {error}")
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
else:
|
|
110
|
+
print("All skills validated successfully!")
|
|
111
|
+
|
|
112
|
+
if args.fix:
|
|
113
|
+
print("\nNote: Auto-fix is not yet implemented.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
parser = create_parser()
|
|
118
|
+
args = parser.parse_args()
|
|
119
|
+
|
|
120
|
+
if args.command is None:
|
|
121
|
+
parser.print_help()
|
|
122
|
+
sys.exit(0)
|
|
123
|
+
|
|
124
|
+
if args.command == "run":
|
|
125
|
+
mcp = create_mcp_server(args.skill_dir)
|
|
126
|
+
mcp.run()
|
|
127
|
+
|
|
128
|
+
elif args.command == "cli":
|
|
129
|
+
handle_cli(args)
|
|
130
|
+
|
|
131
|
+
elif args.command == "check":
|
|
132
|
+
handle_check(args)
|
|
133
|
+
|
|
134
|
+
else:
|
|
135
|
+
parser.print_help()
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import libcst as cst
|
|
4
|
+
|
|
5
|
+
SECRET_VARIABLE = "__VALUE"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ParameterInfo:
|
|
10
|
+
name: str
|
|
11
|
+
annotation: str | None
|
|
12
|
+
default: str | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FunctionInfo:
|
|
17
|
+
name: str
|
|
18
|
+
parameters: list[ParameterInfo]
|
|
19
|
+
return_type: str | None
|
|
20
|
+
docstring: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FunctionNotFoundError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_function_info(source: str, function_name: str) -> FunctionInfo:
|
|
28
|
+
module = cst.parse_module(source)
|
|
29
|
+
|
|
30
|
+
class FunctionFinder(cst.CSTVisitor):
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.function_node = None
|
|
33
|
+
|
|
34
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> None:
|
|
35
|
+
if node.name.value == function_name:
|
|
36
|
+
self.function_node = node
|
|
37
|
+
super().visit_FunctionDef(node)
|
|
38
|
+
|
|
39
|
+
finder = FunctionFinder()
|
|
40
|
+
module.visit(finder)
|
|
41
|
+
|
|
42
|
+
if finder.function_node is None:
|
|
43
|
+
raise FunctionNotFoundError(f"Function '{function_name}' not found in source")
|
|
44
|
+
|
|
45
|
+
func = finder.function_node
|
|
46
|
+
|
|
47
|
+
params = []
|
|
48
|
+
if func.params and func.params.params:
|
|
49
|
+
for param in func.params.params:
|
|
50
|
+
annotation = None
|
|
51
|
+
if param.annotation and hasattr(param.annotation, "annotation"):
|
|
52
|
+
try:
|
|
53
|
+
expr_value = param.annotation.annotation
|
|
54
|
+
code_val = getattr(expr_value, "code", None)
|
|
55
|
+
if isinstance(code_val, str):
|
|
56
|
+
annotation = code_val
|
|
57
|
+
elif isinstance(expr_value, cst.BaseExpression):
|
|
58
|
+
if hasattr(expr_value, "value"):
|
|
59
|
+
value_attr = getattr(expr_value, "value", None)
|
|
60
|
+
if callable(value_attr):
|
|
61
|
+
annotation = str(value_attr())
|
|
62
|
+
else:
|
|
63
|
+
annotation = str(value_attr)
|
|
64
|
+
else:
|
|
65
|
+
annotation = str(expr_value)
|
|
66
|
+
elif isinstance(expr_value, str):
|
|
67
|
+
annotation = expr_value
|
|
68
|
+
except (AttributeError, TypeError):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
default = None
|
|
72
|
+
if param.default:
|
|
73
|
+
expr_value = param.default
|
|
74
|
+
value_attr = getattr(expr_value, "value", None)
|
|
75
|
+
if callable(value_attr):
|
|
76
|
+
default = str(value_attr())
|
|
77
|
+
elif isinstance(value_attr, str):
|
|
78
|
+
default = value_attr
|
|
79
|
+
else:
|
|
80
|
+
code_attr = getattr(expr_value, "code", None)
|
|
81
|
+
if code_attr is not None:
|
|
82
|
+
default = str(code_attr)
|
|
83
|
+
|
|
84
|
+
params.append(
|
|
85
|
+
ParameterInfo(
|
|
86
|
+
name=param.name.value,
|
|
87
|
+
annotation=annotation,
|
|
88
|
+
default=default,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return_type = None
|
|
93
|
+
if func.returns:
|
|
94
|
+
annotation_attr = getattr(func.returns, "annotation", None)
|
|
95
|
+
if annotation_attr is not None:
|
|
96
|
+
return_type = getattr(annotation_attr, "value", None)
|
|
97
|
+
|
|
98
|
+
docstring = None
|
|
99
|
+
if hasattr(func.body, "body") and func.body.body:
|
|
100
|
+
first_stmt = func.body.body[0]
|
|
101
|
+
if isinstance(first_stmt, cst.SimpleStatementLine):
|
|
102
|
+
body_stmt = first_stmt.body[0] if first_stmt.body else None
|
|
103
|
+
if isinstance(body_stmt, cst.Expr) and isinstance(body_stmt.value, cst.SimpleString):
|
|
104
|
+
docstring = (
|
|
105
|
+
body_stmt.value.value[3:-3]
|
|
106
|
+
if body_stmt.value.value.startswith('"""')
|
|
107
|
+
or body_stmt.value.value.startswith("'''")
|
|
108
|
+
else body_stmt.value.value[1:-1]
|
|
109
|
+
)
|
|
110
|
+
elif isinstance(first_stmt, cst.Expr) and isinstance(first_stmt.value, cst.SimpleString):
|
|
111
|
+
docstring = (
|
|
112
|
+
first_stmt.value.value[3:-3]
|
|
113
|
+
if first_stmt.value.value.startswith('"""')
|
|
114
|
+
or first_stmt.value.value.startswith("'''")
|
|
115
|
+
else first_stmt.value.value[1:-1]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return FunctionInfo(
|
|
119
|
+
name=func.name.value,
|
|
120
|
+
parameters=params,
|
|
121
|
+
return_type=return_type,
|
|
122
|
+
docstring=docstring,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def update_code(source: str, new_call: str) -> str:
|
|
127
|
+
module = cst.parse_module(source)
|
|
128
|
+
|
|
129
|
+
class MainBlockFinder(cst.CSTVisitor):
|
|
130
|
+
def __init__(self):
|
|
131
|
+
self.main_block_positions = []
|
|
132
|
+
|
|
133
|
+
def visit_If(self, node: cst.If) -> None:
|
|
134
|
+
test = node.test
|
|
135
|
+
if isinstance(test, cst.Comparison):
|
|
136
|
+
if (
|
|
137
|
+
isinstance(test.left, cst.Name)
|
|
138
|
+
and test.left.value == "__name__"
|
|
139
|
+
and len(test.comparisons) == 1
|
|
140
|
+
):
|
|
141
|
+
comparison = test.comparisons[0]
|
|
142
|
+
if (
|
|
143
|
+
isinstance(comparison.operator, cst.Equal)
|
|
144
|
+
and isinstance(comparison.comparator, cst.SimpleString)
|
|
145
|
+
and comparison.comparator.value == '"__main__"'
|
|
146
|
+
):
|
|
147
|
+
self.main_block_positions.append(node)
|
|
148
|
+
super().visit_If(node)
|
|
149
|
+
|
|
150
|
+
finder = MainBlockFinder()
|
|
151
|
+
module.visit(finder)
|
|
152
|
+
|
|
153
|
+
if not finder.main_block_positions:
|
|
154
|
+
return source + "\n" + new_call + "\n"
|
|
155
|
+
|
|
156
|
+
body = list(module.body)
|
|
157
|
+
for node in finder.main_block_positions:
|
|
158
|
+
body.remove(node)
|
|
159
|
+
|
|
160
|
+
new_statement = cst.parse_statement(new_call)
|
|
161
|
+
body.append(new_statement)
|
|
162
|
+
|
|
163
|
+
new_module = cst.Module(body=body)
|
|
164
|
+
return new_module.code
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def execute_script(content, function_name, args):
|
|
168
|
+
output = update_code(content, f"args={str(args)};{SECRET_VARIABLE} = {function_name}(**args)")
|
|
169
|
+
context: dict = {"__builtins__": __builtins__}
|
|
170
|
+
exec(output, context, context)
|
|
171
|
+
return context[SECRET_VARIABLE]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
from structured_skills.skill_registry import SkillRegistry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_mcp_server(skill_root_dir: Path, server_name: str = "structured_skills") -> FastMCP:
|
|
9
|
+
mcp = FastMCP(server_name)
|
|
10
|
+
registry = SkillRegistry(skill_root_dir)
|
|
11
|
+
|
|
12
|
+
skill_names = registry.get_skill_names()
|
|
13
|
+
skills_info = f"\n\nAvailable skills: {', '.join(skill_names)}"
|
|
14
|
+
|
|
15
|
+
@mcp.tool(
|
|
16
|
+
description=f"List all available skills. Returns a mapping of skill names to their descriptions.{skills_info}"
|
|
17
|
+
)
|
|
18
|
+
def list_skills() -> dict[str, str]:
|
|
19
|
+
"""List all available skills. Returns a mapping of skill names to their descriptions."""
|
|
20
|
+
return registry.list_skills()
|
|
21
|
+
|
|
22
|
+
@mcp.tool(description=f"Load full instructions for a specific skill by name.{skills_info}")
|
|
23
|
+
def load_skill(skill_name: str) -> str:
|
|
24
|
+
"""Load full instructions for a specific skill by name."""
|
|
25
|
+
return registry.load_skill(skill_name)
|
|
26
|
+
|
|
27
|
+
@mcp.tool(
|
|
28
|
+
description=f"Load full resource file, script, or function for a specific skill.{skills_info}"
|
|
29
|
+
)
|
|
30
|
+
def read_skill_resource(skill_name: str, resource_name: str, args: dict | None = None) -> str:
|
|
31
|
+
"""Load full resource file, script, or function for a specific skill."""
|
|
32
|
+
result = registry.read_skill_resource(skill_name, resource_name, args)
|
|
33
|
+
return str(result) if not isinstance(result, str) else result
|
|
34
|
+
|
|
35
|
+
@mcp.tool(
|
|
36
|
+
description=f"Execute skill scripts or functions with optional arguments.{skills_info}"
|
|
37
|
+
)
|
|
38
|
+
def run_skill(skill_name: str, function_name: str, args: dict | None = None) -> str:
|
|
39
|
+
"""Execute skill scripts or functions with optional arguments."""
|
|
40
|
+
return registry.run_skill(skill_name, function_name, args)
|
|
41
|
+
|
|
42
|
+
return mcp
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from difflib import get_close_matches
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from structured_skills.cst.utils import execute_script as execute_script_impl
|
|
9
|
+
from structured_skills.cst.utils import extract_function_info
|
|
10
|
+
from structured_skills.validator import find_skill_md, parse_frontmatter, validate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sanitize_skill_name(name: str) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Convert a skill name to a valid Python identifier.
|
|
16
|
+
|
|
17
|
+
- Replaces hyphens and spaces with underscores
|
|
18
|
+
- Removes invalid characters
|
|
19
|
+
- Ensures it doesn't start with a number
|
|
20
|
+
"""
|
|
21
|
+
# Replace common separators with underscores
|
|
22
|
+
sanitized = name.replace("-", "_").replace(" ", "_")
|
|
23
|
+
# Remove any characters that aren't alphanumeric or underscore
|
|
24
|
+
sanitized = re.sub(r"[^a-zA-Z0-9_]", "", sanitized)
|
|
25
|
+
# Ensure it doesn't start with a number
|
|
26
|
+
if sanitized and sanitized[0].isdigit():
|
|
27
|
+
sanitized = "_" + sanitized
|
|
28
|
+
return sanitized
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Skill:
|
|
33
|
+
name: str
|
|
34
|
+
description: str
|
|
35
|
+
directory: Path
|
|
36
|
+
content: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SkillRegistry:
|
|
40
|
+
def __init__(self, skill_root_dir: Path, exclude_skills: list[str] = []):
|
|
41
|
+
self.skill_root_dir = Path(skill_root_dir)
|
|
42
|
+
self._skills: list[Skill] | None = None
|
|
43
|
+
self._exclude_skills: list[str] = exclude_skills
|
|
44
|
+
|
|
45
|
+
def _load_skills(self) -> list[Skill]:
|
|
46
|
+
skills = []
|
|
47
|
+
for skill_dir in self.skill_root_dir.glob("*"):
|
|
48
|
+
if skill_dir.is_dir() and len(validate(skill_dir)) == 0:
|
|
49
|
+
skill_md = find_skill_md(skill_dir)
|
|
50
|
+
if skill_md:
|
|
51
|
+
content = skill_md.read_text()
|
|
52
|
+
metadata, body = parse_frontmatter(content)
|
|
53
|
+
if metadata["name"] in self._exclude_skills:
|
|
54
|
+
continue
|
|
55
|
+
skills.append(
|
|
56
|
+
Skill(
|
|
57
|
+
name=metadata["name"],
|
|
58
|
+
description=metadata["description"],
|
|
59
|
+
directory=skill_dir,
|
|
60
|
+
content=body,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
if not skills:
|
|
64
|
+
raise Exception(f"No skills found in the directory: {self.skill_root_dir}")
|
|
65
|
+
return skills
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def skills(self) -> list[Skill]:
|
|
69
|
+
if self._skills is None:
|
|
70
|
+
self._skills = self._load_skills()
|
|
71
|
+
return self._skills
|
|
72
|
+
|
|
73
|
+
def get_skill_by_name(self, name: str) -> Skill | None:
|
|
74
|
+
for skill in self.skills:
|
|
75
|
+
if skill.name == name:
|
|
76
|
+
return skill
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def get_skill_names(self) -> list[str]:
|
|
80
|
+
return [s.name for s in self.skills]
|
|
81
|
+
|
|
82
|
+
def find_similar_skill_names(self, name: str) -> list[str]:
|
|
83
|
+
return get_close_matches(name, self.get_skill_names(), n=3, cutoff=0.6)
|
|
84
|
+
|
|
85
|
+
def list_skills(self) -> dict[str, str]:
|
|
86
|
+
return {s.name: s.description for s in self.skills}
|
|
87
|
+
|
|
88
|
+
def load_skill(self, skill_name: str) -> str:
|
|
89
|
+
skill = self.get_skill_by_name(skill_name)
|
|
90
|
+
if skill:
|
|
91
|
+
return skill.content
|
|
92
|
+
|
|
93
|
+
similar = self.find_similar_skill_names(skill_name)
|
|
94
|
+
close_matches_info = f" Did you mean {similar}?" if similar else ""
|
|
95
|
+
raise Exception(f"[load_skill] Could not find: {skill_name}.{close_matches_info}")
|
|
96
|
+
|
|
97
|
+
def read_skill_resource(
|
|
98
|
+
self, skill_name: str, resource_name: str, args: dict[str, Any] | None = None
|
|
99
|
+
) -> str | dict[str, str]:
|
|
100
|
+
skill = self.get_skill_by_name(skill_name)
|
|
101
|
+
if not skill:
|
|
102
|
+
similar = self.find_similar_skill_names(skill_name)
|
|
103
|
+
close_matches_info = f" Did you mean {similar}?" if similar else ""
|
|
104
|
+
raise Exception(
|
|
105
|
+
f"[read_skill_resource] Could not find: {skill_name}.{close_matches_info}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
target_skill_dir = skill.directory
|
|
109
|
+
|
|
110
|
+
exact_file_match = list(target_skill_dir.glob(resource_name)) + list(
|
|
111
|
+
target_skill_dir.glob(f"*/{resource_name}")
|
|
112
|
+
)
|
|
113
|
+
if exact_file_match:
|
|
114
|
+
file_target = exact_file_match.pop()
|
|
115
|
+
return file_target.read_text()
|
|
116
|
+
|
|
117
|
+
if "." in resource_name:
|
|
118
|
+
raise Exception(f"[read_skill_resource] Unable to find resource: {resource_name}")
|
|
119
|
+
|
|
120
|
+
script_path = target_skill_dir / f"scripts/{resource_name}.py"
|
|
121
|
+
if script_path.exists():
|
|
122
|
+
if args is None:
|
|
123
|
+
return script_path.read_text()
|
|
124
|
+
else:
|
|
125
|
+
cmd = [str(script_path)]
|
|
126
|
+
for key, val in args.items():
|
|
127
|
+
cmd.append(f"--{key}")
|
|
128
|
+
cmd.append(f"{val}")
|
|
129
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
130
|
+
return result.stdout
|
|
131
|
+
|
|
132
|
+
scripts_dir = target_skill_dir / "scripts"
|
|
133
|
+
if scripts_dir.exists():
|
|
134
|
+
for script in scripts_dir.glob("*.py"):
|
|
135
|
+
content = script.read_text()
|
|
136
|
+
if f"def {resource_name}(" in content:
|
|
137
|
+
if args is None:
|
|
138
|
+
info = extract_function_info(content, resource_name)
|
|
139
|
+
return str(info)
|
|
140
|
+
else:
|
|
141
|
+
return execute_script_impl(content, resource_name, args or {})
|
|
142
|
+
|
|
143
|
+
raise Exception(f"[read_skill_resource] Unable to find resource: {resource_name}")
|
|
144
|
+
|
|
145
|
+
def run_skill(
|
|
146
|
+
self,
|
|
147
|
+
skill_name: str,
|
|
148
|
+
function_or_script_name: str,
|
|
149
|
+
args: dict[str, Any] | None = None,
|
|
150
|
+
) -> str:
|
|
151
|
+
skill = self.get_skill_by_name(skill_name)
|
|
152
|
+
if not skill:
|
|
153
|
+
similar = self.find_similar_skill_names(skill_name)
|
|
154
|
+
close_matches_info = f" Did you mean {similar}?" if similar else ""
|
|
155
|
+
raise Exception(f"[run_skill] Could not find: {skill_name}.{close_matches_info}")
|
|
156
|
+
|
|
157
|
+
target_skill_dir = skill.directory
|
|
158
|
+
|
|
159
|
+
script_path = target_skill_dir / "scripts" / function_or_script_name
|
|
160
|
+
if script_path.exists():
|
|
161
|
+
cmd = [str(script_path)]
|
|
162
|
+
for key, val in (args or {}).items():
|
|
163
|
+
cmd.append(f"--{key}")
|
|
164
|
+
cmd.append(f"{val}")
|
|
165
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
166
|
+
return result.stdout
|
|
167
|
+
|
|
168
|
+
scripts_dir = target_skill_dir / "scripts"
|
|
169
|
+
if scripts_dir.exists():
|
|
170
|
+
for script in scripts_dir.glob("*.py"):
|
|
171
|
+
content = script.read_text()
|
|
172
|
+
if f"def {function_or_script_name}(" in content:
|
|
173
|
+
return str(execute_script_impl(content, function_or_script_name, args or {}))
|
|
174
|
+
|
|
175
|
+
raise Exception(
|
|
176
|
+
f"[run_skill] Failed to execute {skill_name} {function_or_script_name} {args}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_tool(
|
|
181
|
+
registry: SkillRegistry,
|
|
182
|
+
tool_name: Literal["list_skills", "load_skill", "read_skill_resource", "run_skill"],
|
|
183
|
+
):
|
|
184
|
+
def add_docstring(description: str):
|
|
185
|
+
def inner(obj):
|
|
186
|
+
obj.__doc__ = description
|
|
187
|
+
return obj
|
|
188
|
+
|
|
189
|
+
return inner
|
|
190
|
+
|
|
191
|
+
skill_names = registry.get_skill_names()
|
|
192
|
+
skills_info = f"\n\nAvailable skills: {', '.join(skill_names)}"
|
|
193
|
+
|
|
194
|
+
@add_docstring(
|
|
195
|
+
f"List all available skills. Returns a mapping of skill names to their descriptions.{skills_info}"
|
|
196
|
+
)
|
|
197
|
+
def list_skills() -> dict[str, str]:
|
|
198
|
+
return registry.list_skills()
|
|
199
|
+
|
|
200
|
+
@add_docstring(f"Load full instructions for a specific skill by name.{skills_info}")
|
|
201
|
+
def load_skill(skill_name: str) -> str:
|
|
202
|
+
return registry.load_skill(skill_name)
|
|
203
|
+
|
|
204
|
+
@add_docstring(
|
|
205
|
+
f"Load full resource file, script, or function for a specific skill.{skills_info}"
|
|
206
|
+
)
|
|
207
|
+
def read_skill_resource(
|
|
208
|
+
skill_name: str, resource_name: str, args: dict[str, Any] | None = None
|
|
209
|
+
) -> str | dict[str, str]:
|
|
210
|
+
return registry.read_skill_resource(skill_name, resource_name, args)
|
|
211
|
+
|
|
212
|
+
@add_docstring(f"Execute skill scripts or functions with optional arguments.{skills_info}")
|
|
213
|
+
def run_skill(
|
|
214
|
+
skill_name: str,
|
|
215
|
+
function_or_script_name: str,
|
|
216
|
+
args: dict[str, Any] | None = None,
|
|
217
|
+
) -> str:
|
|
218
|
+
return registry.run_skill(skill_name, function_or_script_name, args)
|
|
219
|
+
|
|
220
|
+
tools = {
|
|
221
|
+
"list_skills": list_skills,
|
|
222
|
+
"load_skill": load_skill,
|
|
223
|
+
"read_skill_resource": read_skill_resource,
|
|
224
|
+
"run_skill": run_skill,
|
|
225
|
+
}
|
|
226
|
+
return tools[tool_name]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from structured_skills.skill_registry import SkillRegistry
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from smolagents import Tool # type: ignore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_smolagents_tools(
|
|
10
|
+
registry: SkillRegistry, tools: list[str] | None = None
|
|
11
|
+
) -> list["Tool"]:
|
|
12
|
+
"""
|
|
13
|
+
Create smolagents Tool instances from a SkillRegistry.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
registry: The SkillRegistry to create tools from
|
|
17
|
+
tools: Optional list of tool names to create. If None, creates all tools.
|
|
18
|
+
Valid names: "list_skills", "load_skill", "read_skill_resource", "run_skill"
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
List of smolagents Tool instances
|
|
22
|
+
"""
|
|
23
|
+
from smolagents import Tool # type: ignore
|
|
24
|
+
|
|
25
|
+
tool_names = tools or [
|
|
26
|
+
"list_skills",
|
|
27
|
+
"load_skill",
|
|
28
|
+
"read_skill_resource",
|
|
29
|
+
"run_skill",
|
|
30
|
+
]
|
|
31
|
+
result: list[Tool] = []
|
|
32
|
+
|
|
33
|
+
skill_names = registry.get_skill_names()
|
|
34
|
+
skills_info = f"Available skills: {', '.join(skill_names)}"
|
|
35
|
+
|
|
36
|
+
class ListSkillsTool(Tool):
|
|
37
|
+
name = "list_skills"
|
|
38
|
+
description = f"List all available skills. Returns a mapping of skill names to their descriptions. {skills_info}"
|
|
39
|
+
inputs = {}
|
|
40
|
+
output_type = "object"
|
|
41
|
+
|
|
42
|
+
def forward(self) -> dict[str, str]:
|
|
43
|
+
return registry.list_skills()
|
|
44
|
+
|
|
45
|
+
class LoadSkillTool(Tool):
|
|
46
|
+
name = "load_skill"
|
|
47
|
+
description = f"Load full instructions for a specific skill by name. {skills_info}"
|
|
48
|
+
inputs = {
|
|
49
|
+
"skill_name": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Name of the skill to load",
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
output_type = "string"
|
|
55
|
+
|
|
56
|
+
def forward(self, skill_name: str) -> str:
|
|
57
|
+
return registry.load_skill(skill_name)
|
|
58
|
+
|
|
59
|
+
class ReadSkillResourceTool(Tool):
|
|
60
|
+
name = "read_skill_resource"
|
|
61
|
+
description = (
|
|
62
|
+
f"Load full resource file, script, or function for a specific skill. {skills_info}"
|
|
63
|
+
)
|
|
64
|
+
inputs = {
|
|
65
|
+
"skill_name": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "Name of the skill",
|
|
68
|
+
},
|
|
69
|
+
"resource_name": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "Name of the resource to read",
|
|
72
|
+
},
|
|
73
|
+
"args": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"description": "Optional arguments for the resource",
|
|
76
|
+
"nullable": True,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
output_type = "string"
|
|
80
|
+
|
|
81
|
+
def forward(self, skill_name: str, resource_name: str, args: dict | None = None) -> str:
|
|
82
|
+
result = registry.read_skill_resource(skill_name, resource_name, args)
|
|
83
|
+
return str(result) if not isinstance(result, str) else result
|
|
84
|
+
|
|
85
|
+
class RunSkillTool(Tool):
|
|
86
|
+
name = "run_skill"
|
|
87
|
+
description = f"Execute skill scripts or functions with optional arguments. {skills_info}"
|
|
88
|
+
inputs = {
|
|
89
|
+
"skill_name": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"description": "Name of the skill",
|
|
92
|
+
},
|
|
93
|
+
"function_name": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "Name of the function or script to run",
|
|
96
|
+
},
|
|
97
|
+
"args": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"description": "Optional arguments for the function",
|
|
100
|
+
"nullable": True,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
output_type = "string"
|
|
104
|
+
|
|
105
|
+
def forward(self, skill_name: str, function_name: str, args: dict | None = None) -> str:
|
|
106
|
+
return registry.run_skill(skill_name, function_name, args)
|
|
107
|
+
|
|
108
|
+
tool_classes = {
|
|
109
|
+
"list_skills": ListSkillsTool,
|
|
110
|
+
"load_skill": LoadSkillTool,
|
|
111
|
+
"read_skill_resource": ReadSkillResourceTool,
|
|
112
|
+
"run_skill": RunSkillTool,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for name in tool_names:
|
|
116
|
+
if name in tool_classes:
|
|
117
|
+
result.append(tool_classes[name]())
|
|
118
|
+
|
|
119
|
+
return result
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured Skills Validation Logic.
|
|
3
|
+
|
|
4
|
+
Extended from: https://github.com/agentskills/agentskills/tree/main
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import unicodedata
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import strictyaml
|
|
13
|
+
|
|
14
|
+
MAX_SKILL_NAME_LENGTH = 64
|
|
15
|
+
MAX_DESCRIPTION_LENGTH = 1024
|
|
16
|
+
MAX_COMPATIBILITY_LENGTH = 500
|
|
17
|
+
|
|
18
|
+
# Allowed frontmatter fields per Agent Skills Spec
|
|
19
|
+
ALLOWED_FIELDS = {
|
|
20
|
+
"name",
|
|
21
|
+
"description",
|
|
22
|
+
"license",
|
|
23
|
+
"allowed-tools",
|
|
24
|
+
"metadata",
|
|
25
|
+
"compatibility",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_skill_md(skill_dir: Path) -> Optional[Path]:
|
|
30
|
+
"""Find the SKILL.md file in a skill directory.
|
|
31
|
+
|
|
32
|
+
Prefers SKILL.md (uppercase) but accepts skill.md (lowercase).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
skill_dir: Path to the skill directory
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path to the SKILL.md file, or None if not found
|
|
39
|
+
"""
|
|
40
|
+
for name in ("SKILL.md", "skill.md"):
|
|
41
|
+
path = skill_dir / name
|
|
42
|
+
if path.exists():
|
|
43
|
+
return path
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
48
|
+
"""Parse YAML frontmatter from SKILL.md content.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
content: Raw content of SKILL.md file
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Tuple of (metadata dict, markdown body)
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If frontmatter is missing or invalid
|
|
58
|
+
"""
|
|
59
|
+
if not content.startswith("---"):
|
|
60
|
+
raise ValueError("SKILL.md must start with YAML frontmatter (---)")
|
|
61
|
+
|
|
62
|
+
parts = content.split("---", 2)
|
|
63
|
+
if len(parts) < 3:
|
|
64
|
+
raise ValueError("SKILL.md frontmatter not properly closed with ---")
|
|
65
|
+
|
|
66
|
+
frontmatter_str = parts[1]
|
|
67
|
+
body = parts[2].strip()
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
parsed = strictyaml.load(frontmatter_str)
|
|
71
|
+
metadata = parsed.data
|
|
72
|
+
except strictyaml.YAMLError as e:
|
|
73
|
+
raise ValueError(f"Invalid YAML in frontmatter: {e}")
|
|
74
|
+
|
|
75
|
+
if not isinstance(metadata, dict):
|
|
76
|
+
raise ValueError("SKILL.md frontmatter must be a YAML mapping")
|
|
77
|
+
|
|
78
|
+
if "metadata" in metadata and isinstance(metadata["metadata"], dict):
|
|
79
|
+
metadata["metadata"] = {str(k): str(v) for k, v in metadata["metadata"].items()}
|
|
80
|
+
|
|
81
|
+
return metadata, body
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_scripts(skill_dir: Path) -> list[Path]:
|
|
85
|
+
"""Checks the validity of python files in scripts/ (if exists)
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
skill_dir: Path to the skill directory
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: if scripts/ folder exists without any python files
|
|
92
|
+
"""
|
|
93
|
+
script_path = skill_dir.joinpath("scripts")
|
|
94
|
+
if not script_path.exists():
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
scripts: list[Path] = list(script_path.glob("*.py"))
|
|
98
|
+
if len(scripts) == 0:
|
|
99
|
+
raise ValueError("scripts/ directory exists but no Python scripts found")
|
|
100
|
+
return scripts
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _validate_name(name: str, skill_dir: Optional[Path] = None) -> list[str]:
|
|
104
|
+
"""Validate skill name format and directory match.
|
|
105
|
+
|
|
106
|
+
Skill names support i18n characters (Unicode letters) plus hyphens.
|
|
107
|
+
Names must be lowercase and cannot start/end with hyphens.
|
|
108
|
+
"""
|
|
109
|
+
errors = []
|
|
110
|
+
|
|
111
|
+
if not name or not isinstance(name, str) or not name.strip():
|
|
112
|
+
errors.append("Field 'name' must be a non-empty string")
|
|
113
|
+
return errors
|
|
114
|
+
|
|
115
|
+
name = unicodedata.normalize("NFKC", name.strip())
|
|
116
|
+
|
|
117
|
+
if len(name) > MAX_SKILL_NAME_LENGTH:
|
|
118
|
+
errors.append(
|
|
119
|
+
f"Skill name '{name}' exceeds {MAX_SKILL_NAME_LENGTH} character limit "
|
|
120
|
+
f"({len(name)} chars)"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if name != name.lower():
|
|
124
|
+
errors.append(f"Skill name '{name}' must be lowercase")
|
|
125
|
+
|
|
126
|
+
if name.startswith("-") or name.endswith("-"):
|
|
127
|
+
errors.append("Skill name cannot start or end with a hyphen")
|
|
128
|
+
|
|
129
|
+
if "--" in name:
|
|
130
|
+
errors.append("Skill name cannot contain consecutive hyphens")
|
|
131
|
+
|
|
132
|
+
if not all(c.isalnum() or c == "-" for c in name):
|
|
133
|
+
errors.append(
|
|
134
|
+
f"Skill name '{name}' contains invalid characters. "
|
|
135
|
+
"Only letters, digits, and hyphens are allowed."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if skill_dir is not None:
|
|
139
|
+
dir_name = unicodedata.normalize("NFKC", skill_dir.name)
|
|
140
|
+
if dir_name != name:
|
|
141
|
+
errors.append(f"Directory name '{skill_dir.name}' must match skill name '{name}'")
|
|
142
|
+
|
|
143
|
+
return errors
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _validate_description(description: str) -> list[str]:
|
|
147
|
+
"""Validate description format."""
|
|
148
|
+
errors = []
|
|
149
|
+
|
|
150
|
+
if not description or not isinstance(description, str) or not description.strip():
|
|
151
|
+
errors.append("Field 'description' must be a non-empty string")
|
|
152
|
+
return errors
|
|
153
|
+
|
|
154
|
+
if len(description) > MAX_DESCRIPTION_LENGTH:
|
|
155
|
+
errors.append(
|
|
156
|
+
f"Description exceeds {MAX_DESCRIPTION_LENGTH} character limit "
|
|
157
|
+
f"({len(description)} chars)"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return errors
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _validate_compatibility(compatibility: str) -> list[str]:
|
|
164
|
+
"""Validate compatibility format."""
|
|
165
|
+
errors = []
|
|
166
|
+
|
|
167
|
+
if not isinstance(compatibility, str):
|
|
168
|
+
errors.append("Field 'compatibility' must be a string")
|
|
169
|
+
return errors
|
|
170
|
+
|
|
171
|
+
if len(compatibility) > MAX_COMPATIBILITY_LENGTH:
|
|
172
|
+
errors.append(
|
|
173
|
+
f"Compatibility exceeds {MAX_COMPATIBILITY_LENGTH} character limit "
|
|
174
|
+
f"({len(compatibility)} chars)"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return errors
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _validate_metadata_fields(metadata: dict) -> list[str]:
|
|
181
|
+
"""Validate that only allowed fields are present."""
|
|
182
|
+
errors = []
|
|
183
|
+
|
|
184
|
+
extra_fields = set(metadata.keys()) - ALLOWED_FIELDS
|
|
185
|
+
if extra_fields:
|
|
186
|
+
errors.append(
|
|
187
|
+
f"Unexpected fields in frontmatter: {', '.join(sorted(extra_fields))}. "
|
|
188
|
+
f"Only {sorted(ALLOWED_FIELDS)} are allowed."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return errors
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate_metadata(metadata: dict, skill_dir: Optional[Path] = None) -> list[str]:
|
|
195
|
+
"""Validate parsed skill metadata.
|
|
196
|
+
|
|
197
|
+
This is the core validation function that works on already-parsed metadata,
|
|
198
|
+
avoiding duplicate file I/O when called from the parser.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
metadata: Parsed YAML frontmatter dictionary
|
|
202
|
+
skill_dir: Optional path to skill directory (for name-directory match check)
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of validation error messages. Empty list means valid.
|
|
206
|
+
"""
|
|
207
|
+
errors = []
|
|
208
|
+
errors.extend(_validate_metadata_fields(metadata))
|
|
209
|
+
|
|
210
|
+
if "name" not in metadata:
|
|
211
|
+
errors.append("Missing required field in frontmatter: name")
|
|
212
|
+
else:
|
|
213
|
+
errors.extend(_validate_name(metadata["name"], skill_dir))
|
|
214
|
+
|
|
215
|
+
if "description" not in metadata:
|
|
216
|
+
errors.append("Missing required field in frontmatter: description")
|
|
217
|
+
else:
|
|
218
|
+
errors.extend(_validate_description(metadata["description"]))
|
|
219
|
+
|
|
220
|
+
if "compatibility" in metadata:
|
|
221
|
+
errors.extend(_validate_compatibility(metadata["compatibility"]))
|
|
222
|
+
|
|
223
|
+
return errors
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def validate(skill_dir: Path) -> list[str]:
|
|
227
|
+
"""Validate a skill directory.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
skill_dir: Path to the skill directory
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
List of validation error messages. Empty list means valid.
|
|
234
|
+
"""
|
|
235
|
+
skill_dir = Path(skill_dir)
|
|
236
|
+
|
|
237
|
+
if not skill_dir.exists():
|
|
238
|
+
return [f"Path does not exist: {skill_dir}"]
|
|
239
|
+
|
|
240
|
+
if not skill_dir.is_dir():
|
|
241
|
+
return [f"Not a directory: {skill_dir}"]
|
|
242
|
+
|
|
243
|
+
skill_md = find_skill_md(skill_dir)
|
|
244
|
+
if skill_md is None:
|
|
245
|
+
return ["Missing required file: SKILL.md"]
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
content = skill_md.read_text()
|
|
249
|
+
metadata, _ = parse_frontmatter(content)
|
|
250
|
+
except ValueError as e:
|
|
251
|
+
return [str(e)]
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
_ = find_scripts(skill_dir)
|
|
255
|
+
except ValueError as e:
|
|
256
|
+
return [str(e)]
|
|
257
|
+
|
|
258
|
+
return validate_metadata(metadata, skill_dir)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
parser = argparse.ArgumentParser(
|
|
263
|
+
prog="SkillStructParser", description="Structured Skill Parsing"
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument("skill_dir")
|
|
266
|
+
args = parser.parse_args()
|
|
267
|
+
validate(Path(args.skill_dir))
|