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.
@@ -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,4 @@
1
+ from structured_skills.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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()
@@ -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))