broskill 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.
- broskill/__init__.py +14 -0
- broskill/data_specs/__init__.py +0 -0
- broskill/data_specs/skill.py +40 -0
- broskill/data_specs/tool.py +54 -0
- broskill/processing/__init__.py +0 -0
- broskill/processing/path.py +38 -0
- broskill/processing/skill.py +169 -0
- broskill/processing/tool.py +108 -0
- broskill-0.1.0.dist-info/METADATA +106 -0
- broskill-0.1.0.dist-info/RECORD +12 -0
- broskill-0.1.0.dist-info/WHEEL +4 -0
- broskill-0.1.0.dist-info/licenses/LICENSE +21 -0
broskill/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from broskill.data_specs.skill import Skill, SkillStatus
|
|
2
|
+
from broskill.data_specs.tool import Arg, Tool
|
|
3
|
+
from broskill.processing.skill import SkillControl
|
|
4
|
+
from broskill.processing.tool import ToolControl
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Arg",
|
|
9
|
+
"Skill",
|
|
10
|
+
"SkillControl",
|
|
11
|
+
"SkillStatus",
|
|
12
|
+
"Tool",
|
|
13
|
+
"ToolControl"
|
|
14
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SkillStatus(StrEnum):
|
|
7
|
+
STABLE = 'stable'
|
|
8
|
+
EXPERIMENT = 'experiment'
|
|
9
|
+
DEPRECATED = 'deprecated'
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Skill:
|
|
13
|
+
name:str = field(
|
|
14
|
+
metadata={'description': "a skill name in frontmatter"}
|
|
15
|
+
)
|
|
16
|
+
description:str = field(
|
|
17
|
+
metadata={'description': "a description of the skill in frontmatter"}
|
|
18
|
+
)
|
|
19
|
+
version:str = field(
|
|
20
|
+
metadata={'description': "a skill's version recommending as v0.1.0 (vMajor.Minor.Patch)"}
|
|
21
|
+
)
|
|
22
|
+
path:Path = field(
|
|
23
|
+
metadata={'description': "a directory path of the skill."}
|
|
24
|
+
)
|
|
25
|
+
tags:list[str] | None = field(
|
|
26
|
+
metadata={'description': "tags will use later for searching and filtering"},
|
|
27
|
+
default=None
|
|
28
|
+
)
|
|
29
|
+
keywords:list[str] | None = field(
|
|
30
|
+
metadata={'description': "keywords will use later for searching and filtering"},
|
|
31
|
+
default=None
|
|
32
|
+
)
|
|
33
|
+
default:bool = field(
|
|
34
|
+
metadata={'description': "default here tells a program that this skill must be loaded or display"},
|
|
35
|
+
default=False
|
|
36
|
+
)
|
|
37
|
+
status:SkillStatus | None = field(
|
|
38
|
+
metadata={'description': "status will be: stable if you test it and pass, experiment if you are in developing phase, deprecated if you plan not to use it."},
|
|
39
|
+
default=SkillStatus.EXPERIMENT
|
|
40
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
DTYPE_MAP = {
|
|
6
|
+
str: "string",
|
|
7
|
+
int: "integer",
|
|
8
|
+
float: "number",
|
|
9
|
+
bool: "boolean",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Arg:
|
|
14
|
+
name:str = field(
|
|
15
|
+
metadata={"description": "the argument's name, taken from its dest in get_args()"}
|
|
16
|
+
)
|
|
17
|
+
type:Any = field(
|
|
18
|
+
metadata={"description": "the argument's Python type (e.g. str, int) from get_args(), converted to its JSON-schema type string (e.g. 'string', 'integer') on init"}
|
|
19
|
+
)
|
|
20
|
+
description:str | None = field(
|
|
21
|
+
metadata={"description": "the argument's help text, taken from get_args()"},
|
|
22
|
+
default=None
|
|
23
|
+
)
|
|
24
|
+
required:bool | None = field(
|
|
25
|
+
metadata={"description": "whether the argument is required to call the tool"},
|
|
26
|
+
default=True
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
"""Resolve `type` to its JSON-schema string, if given as a Python type.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If `type` is a Python type not in `DTYPE_MAP`.
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(self.type, str):
|
|
36
|
+
return
|
|
37
|
+
if self.type not in DTYPE_MAP:
|
|
38
|
+
raise ValueError(f"Unsupported type for arg '{self.name}': {self.type}")
|
|
39
|
+
self.type = DTYPE_MAP[self.type]
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Tool:
|
|
43
|
+
name:str = field(
|
|
44
|
+
metadata={"description": "the tool's name, taken from the script's filename"}
|
|
45
|
+
)
|
|
46
|
+
description:str = field(
|
|
47
|
+
metadata={"description": "the tool's description, taken from get_args()'s parser description"}
|
|
48
|
+
)
|
|
49
|
+
args:list[Arg] = field(
|
|
50
|
+
metadata={"description": "the tool's arguments"}
|
|
51
|
+
)
|
|
52
|
+
path:Path = field(
|
|
53
|
+
metadata={"description": "the path to the script that implements this tool"}
|
|
54
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def strip_path(path: str) -> str:
|
|
6
|
+
"""Stripping the path in to the bare path
|
|
7
|
+
Args:
|
|
8
|
+
path (str): The path to be stripped
|
|
9
|
+
Returns:
|
|
10
|
+
str: The stripped path
|
|
11
|
+
Examples:
|
|
12
|
+
>>> path = strip_path(path='*/references/*')
|
|
13
|
+
>>> print(path)
|
|
14
|
+
references
|
|
15
|
+
"""
|
|
16
|
+
path = re.sub(r'^[*/]+', '', path)
|
|
17
|
+
path = re.sub(r'[*/]+$', '', path)
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
def find_root(start: Path | None = None, marker: str = "skills") -> Path:
|
|
21
|
+
"""Walk upward from `start` (default: cwd) looking for a folder
|
|
22
|
+
containing `marker`, the same trick git uses for `.git`.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
start (Path): Where to begin the search. Defaults to Path.cwd().
|
|
26
|
+
marker (str): Folder name that identifies the project root.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Path: The first ancestor (including `start`) containing `marker/`.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If no ancestor contains `marker/`.
|
|
33
|
+
"""
|
|
34
|
+
current = (start or Path.cwd()).resolve()
|
|
35
|
+
for candidate in [current, *current.parents]:
|
|
36
|
+
if (candidate / marker).is_dir():
|
|
37
|
+
return candidate
|
|
38
|
+
raise ValueError(f"No '{marker}/' folder found in {current} or any parent")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from broskill.data_specs.skill import Skill
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def split_frontmatter(text: str) -> tuple[dict, str]:
|
|
10
|
+
"""Split a SKILL.md file's text into its frontmatter and body.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
text (str): Raw file contents, expected to start with a `---`
|
|
14
|
+
delimited YAML block followed by the markdown body.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
tuple[dict, str]: The parsed frontmatter as a dict, and the
|
|
18
|
+
remaining body text with surrounding whitespace stripped.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: If no `---` delimited frontmatter block is found.
|
|
22
|
+
"""
|
|
23
|
+
match = re.match(r'^---\n(.*?)\n---\n?(.*)', text, re.DOTALL)
|
|
24
|
+
if not match:
|
|
25
|
+
raise ValueError('No frontmatter block found')
|
|
26
|
+
metadata = yaml.safe_load(match.group(1)) or {}
|
|
27
|
+
body = match.group(2).strip()
|
|
28
|
+
return metadata, body
|
|
29
|
+
|
|
30
|
+
class SkillControl:
|
|
31
|
+
"""Discovers, loads, and reads skills from a skills directory.
|
|
32
|
+
|
|
33
|
+
`root` is the skills directory itself (e.g. `project_root / "skills"`),
|
|
34
|
+
not the project root — every method searches directly under `root`,
|
|
35
|
+
with no implicit `"skills"` subfolder appended.
|
|
36
|
+
|
|
37
|
+
Skills loaded via `load_skill`/`list_skills` are cached on `self.skills`
|
|
38
|
+
(keyed by skill name), so later calls that only need a skill's path
|
|
39
|
+
(`load_skill_extension`) don't need to search the filesystem again.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
root (Path): Default skills directory (e.g. `project_root / "skills"`),
|
|
43
|
+
not the project root.
|
|
44
|
+
skills (dict[str, Skill]): Skills loaded so far, keyed by name.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, root:Path):
|
|
48
|
+
"""Initialize the controller.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
root (Path): Default skills directory (e.g. `project_root / "skills"`),
|
|
52
|
+
not the project root.
|
|
53
|
+
"""
|
|
54
|
+
self.root:Path = root
|
|
55
|
+
self.skills:dict[str, Skill] = {}
|
|
56
|
+
|
|
57
|
+
def get_skill_path(self, skill_name:str)->Path | None:
|
|
58
|
+
"""Get a previously loaded skill's directory path.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
skill_name (str): Name of the skill, as registered by `load_skill`.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Path | None: The skill's directory, or None if it hasn't been
|
|
65
|
+
loaded yet (call `load_skill` first).
|
|
66
|
+
"""
|
|
67
|
+
skill = self.skills.get(skill_name, None)
|
|
68
|
+
if skill:
|
|
69
|
+
return skill.path
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def get_root(self, root:Path|None)->Path|None:
|
|
73
|
+
"""Resolve the skills directory to use for a call.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
root (Path | None): An explicit skills directory to use, or None
|
|
77
|
+
to fall back to `self.root`.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Path | None: `root` if given, otherwise `self.root`.
|
|
81
|
+
"""
|
|
82
|
+
if root is None:
|
|
83
|
+
return self.root
|
|
84
|
+
return root
|
|
85
|
+
|
|
86
|
+
def list_skills(self, root:Path|None=None)->list[Skill]|list:
|
|
87
|
+
"""List and load every skill under the skills directory.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
root (Path | None): Skills directory to search. Defaults to `self.root`.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
list[Skill]: Every skill discovered by scanning `root` for
|
|
94
|
+
`SKILL.md` files.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
FileNotFoundError: If `root` doesn't exist.
|
|
98
|
+
"""
|
|
99
|
+
root = self.get_root(root)
|
|
100
|
+
if not root.exists():
|
|
101
|
+
raise FileNotFoundError(f"Skills directory not found: {root}")
|
|
102
|
+
skills = list(root.rglob("SKILL.md"))
|
|
103
|
+
_ = [self.load_skill(s.parent.name, root) for s in skills]
|
|
104
|
+
return list(self.skills.values())
|
|
105
|
+
|
|
106
|
+
def load_skill(self, skill_name:str, root:Path|None=None)->str|None:
|
|
107
|
+
"""Load a skill by name and register it into `self.skills`.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
skill_name (str): Name of the skill's directory under the skills directory.
|
|
111
|
+
root (Path | None): Skills directory to search. Defaults to `self.root`.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Skill | None: The loaded skill, or None if no directory named
|
|
115
|
+
`skill_name` exists under `root`.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If a path named `skill_name` exists but none of the
|
|
119
|
+
matches is a valid skill directory, or if its `SKILL.md`
|
|
120
|
+
frontmatter doesn't match the `Skill` dataclass fields.
|
|
121
|
+
FileNotFoundError: If the matched directory has no `SKILL.md`.
|
|
122
|
+
"""
|
|
123
|
+
root = self.get_root(root)
|
|
124
|
+
skills = list(root.rglob(skill_name))
|
|
125
|
+
if len(skills) == 0:
|
|
126
|
+
return None
|
|
127
|
+
candidates = [s for s in skills if s.is_dir() and s.exists()]
|
|
128
|
+
if not candidates:
|
|
129
|
+
raise ValueError(f"No skill directory found for '{skill_name}' under {root}")
|
|
130
|
+
skill = candidates[0]
|
|
131
|
+
skill_md = skill / "SKILL.md"
|
|
132
|
+
if not skill_md.is_file():
|
|
133
|
+
raise FileNotFoundError(f"'{skill_md}' not found for skill '{skill_name}'")
|
|
134
|
+
text = skill_md.read_text(encoding='utf-8')
|
|
135
|
+
metadata, body = split_frontmatter(text)
|
|
136
|
+
try:
|
|
137
|
+
_skill = Skill(**metadata, path=skill)
|
|
138
|
+
except TypeError as e:
|
|
139
|
+
raise ValueError(f"'{skill_md}' frontmatter doesn't match the Skill schema: {e}") from e
|
|
140
|
+
self.skills[skill_name] = _skill
|
|
141
|
+
return body
|
|
142
|
+
|
|
143
|
+
def load_skill_extension(self, skill_name:str, path:str, root:Path|None=None)->str|None:
|
|
144
|
+
"""Load any skill artifact under its `references/`, `scripts/`, or `assets/` folder.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
skill_name (str): Name of the skill, as registered by `load_skill`.
|
|
148
|
+
path (str): Path to the artifact, relative to the skill's directory
|
|
149
|
+
(e.g. `references/script.md`, `scripts/read_file.py`).
|
|
150
|
+
root (Path | None): Project root, currently unused directly (the
|
|
151
|
+
skill's own registered path is used) but accepted for a
|
|
152
|
+
consistent signature with the other loaders.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
str | None: The artifact's text content, or None if `path` doesn't
|
|
156
|
+
exist under the skill's directory.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If `skill_name` hasn't been loaded yet — call
|
|
160
|
+
`load_skill` first.
|
|
161
|
+
"""
|
|
162
|
+
root = self.get_root(root)
|
|
163
|
+
skill_path = self.get_skill_path(skill_name)
|
|
164
|
+
if skill_path is None:
|
|
165
|
+
raise ValueError(f"Skill '{skill_name}' is not loaded — call load_skill first")
|
|
166
|
+
target = skill_path / path
|
|
167
|
+
if target.is_file():
|
|
168
|
+
return target.read_text(encoding='utf-8')
|
|
169
|
+
return None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from broskill.data_specs.tool import Arg, Tool
|
|
5
|
+
from broskill.processing.skill import SkillControl
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_args(args: dict) -> list[Any]:
|
|
9
|
+
"""Convert a dict of tool arguments into a CLI argument list.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
args (dict): Argument names mapped to their values, e.g. `{"path": "a.md"}`.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
list[Any]: A flat `--name value` list suitable for `subprocess.run`,
|
|
16
|
+
e.g. `["--path", "a.md"]`. Empty list if `args` is falsy.
|
|
17
|
+
"""
|
|
18
|
+
if not args:
|
|
19
|
+
return []
|
|
20
|
+
args_list = []
|
|
21
|
+
for k, v in args.items():
|
|
22
|
+
args_list.extend([f"--{k}", v])
|
|
23
|
+
return args_list
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ToolControl:
|
|
27
|
+
"""Loads a skill's `scripts/*.py` files as callable `Tool` schemas.
|
|
28
|
+
|
|
29
|
+
Composes with a `SkillControl` for skill_name -> directory resolution,
|
|
30
|
+
so both classes share one source of truth for where a skill lives.
|
|
31
|
+
Every script must define a module-level `get_args()` returning an
|
|
32
|
+
`argparse.ArgumentParser` (see `skills/create-skill/references/script.md`);
|
|
33
|
+
that parser is introspected to build the `Tool`/`Arg` schema.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
skills (SkillControl): Used to resolve a skill name to its directory.
|
|
37
|
+
tools (dict[str, Tool]): Tools loaded so far, keyed by `"skill_name:path"`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, skills: SkillControl):
|
|
41
|
+
"""Initialize the controller.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
skills (SkillControl): The skill controller to resolve skill
|
|
45
|
+
directories through.
|
|
46
|
+
"""
|
|
47
|
+
self.skills = skills
|
|
48
|
+
self.tools: dict[str, Tool] = {}
|
|
49
|
+
|
|
50
|
+
def load_tool(self, skill_name: str, path: str) -> Tool:
|
|
51
|
+
"""Load a skill's script as a `Tool` schema, caching the result.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
skill_name (str): Name of the skill, as registered by `self.skills.load_skill`.
|
|
55
|
+
path (str): Path to the script, relative to the skill's directory
|
|
56
|
+
(e.g. `scripts/read_file.py`).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Tool: The tool schema built from the script's `get_args()` parser.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If `skill_name` isn't loaded, the file can't be loaded
|
|
63
|
+
as a Python module, it has no `get_args()`, or one of its
|
|
64
|
+
arguments uses a type `Arg` doesn't recognize (see `DTYPE_MAP`
|
|
65
|
+
in `broskill.data_specs.tool`).
|
|
66
|
+
FileNotFoundError: If `path` doesn't exist under the skill's directory.
|
|
67
|
+
"""
|
|
68
|
+
skill_path = self.skills.get_skill_path(skill_name)
|
|
69
|
+
if skill_path is None:
|
|
70
|
+
raise ValueError(f"Skill '{skill_name}' is not loaded — call load_skill first")
|
|
71
|
+
|
|
72
|
+
cache_key = f"{skill_name}:{path}"
|
|
73
|
+
if cache_key in self.tools:
|
|
74
|
+
return self.tools[cache_key]
|
|
75
|
+
|
|
76
|
+
script_path = skill_path / path
|
|
77
|
+
if not script_path.is_file():
|
|
78
|
+
raise FileNotFoundError(f"Script not found: {script_path}")
|
|
79
|
+
|
|
80
|
+
spec = importlib.util.spec_from_file_location(script_path.stem, script_path)
|
|
81
|
+
if spec is None or spec.loader is None:
|
|
82
|
+
raise ValueError(f"Could not load '{script_path}' as a Python module")
|
|
83
|
+
module = importlib.util.module_from_spec(spec)
|
|
84
|
+
spec.loader.exec_module(module)
|
|
85
|
+
|
|
86
|
+
if not hasattr(module, "get_args"):
|
|
87
|
+
raise ValueError(f"'{script_path}' must define a get_args() function")
|
|
88
|
+
parser = module.get_args()
|
|
89
|
+
|
|
90
|
+
args = []
|
|
91
|
+
for action in parser._actions:
|
|
92
|
+
if action.dest == "help":
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
args.append(
|
|
96
|
+
Arg(
|
|
97
|
+
name=action.dest,
|
|
98
|
+
type=action.type,
|
|
99
|
+
description=action.help,
|
|
100
|
+
required=action.required,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
except ValueError as e:
|
|
104
|
+
raise ValueError(f"{e} (in '{script_path}')") from e
|
|
105
|
+
|
|
106
|
+
tool = Tool(name=script_path.stem, description=parser.description, args=args, path=script_path)
|
|
107
|
+
self.tools[cache_key] = tool
|
|
108
|
+
return tool
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: broskill
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight, quick-loading skill loader for agentic projects — discovers skills (SKILL.md + references/scripts/assets), loads their instructions, and turns their scripts into callable tools.
|
|
5
|
+
Project-URL: Repository, https://github.com/datanooblol/broskill
|
|
6
|
+
Project-URL: Changelog, https://github.com/datanooblol/broskill/blob/main/VERSIONS.md
|
|
7
|
+
Author: datanooblol
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# broskill
|
|
15
|
+
|
|
16
|
+
yo. **broskill** is a lightweight, quick-loading skill loader for agentic projects — a "skill" being a folder with instructions (`SKILL.md`) plus optional reference docs, python scripts as tools, and static assets. broskill's whole job is finding those folders, loading them, and turning their scripts into callable tools. That's it. That's the lib.
|
|
17
|
+
|
|
18
|
+
part of the **bro lib family** — small, opinionated, do-one-thing tools built to stop us from writing the same glue code every time we spin up a new agentic project.
|
|
19
|
+
|
|
20
|
+
## why this exists
|
|
21
|
+
|
|
22
|
+
every time you start a new agent project you end up rebuilding the same boring plumbing: some way to discover "skills," some way to load their instructions, some way to turn a script into a tool the model can call. broskill is that plumbing, ripped out of real projects where we got tired of writing it again and again.
|
|
23
|
+
|
|
24
|
+
**heads up:** this is an opinionated lib. it works one way — SKILL.md + `references/` + `scripts/` + `assets/`, discovered off a `skills/` folder. if that convention fits your project, great, you just saved yourself a bunch of setup. if it doesn't, this probably isn't your lib, and that's fine — go build your own, that's basically how broskill got made in the first place. inspired by real work and real struggle. voilà, here it is.
|
|
25
|
+
|
|
26
|
+
## install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## the shape of a skill
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
skills/
|
|
36
|
+
read-file/
|
|
37
|
+
SKILL.md # frontmatter (name, description, version, ...) + instructions
|
|
38
|
+
references/
|
|
39
|
+
script.md # optional deep-dive docs, loaded on demand
|
|
40
|
+
scripts/
|
|
41
|
+
read_file.py # a get_args()-based python script -> becomes a callable tool
|
|
42
|
+
assets/
|
|
43
|
+
template.md # optional static files a skill hands out
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`SKILL.md`'s frontmatter has to match the [`Skill`](src/broskill/data_specs/skill.py) dataclass field-for-field (`name`, `description`, `version` required; `tags`, `keywords`, `default`, `status` optional). Want to build one from scratch? There's a skill for that: [`skills/create-skill`](skills/create-skill/SKILL.md).
|
|
47
|
+
|
|
48
|
+
## quick start
|
|
49
|
+
|
|
50
|
+
### load skills — `SkillControl`
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from broskill.processing.path import find_root
|
|
54
|
+
from broskill.processing.skill import SkillControl
|
|
55
|
+
|
|
56
|
+
root = find_root() # walks up 'til it finds a skills/ folder
|
|
57
|
+
sc = SkillControl(root=root / "skills")
|
|
58
|
+
|
|
59
|
+
for skill in sc.list_skills():
|
|
60
|
+
print(skill.name, "-", skill.description)
|
|
61
|
+
|
|
62
|
+
body = sc.load_skill("read-file") # -> SKILL.md's instructions, as a str
|
|
63
|
+
ref = sc.load_skill_extension("read-file", "references/script.md")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Full runnable version: [`examples/case1.py`](examples/case1.py).
|
|
67
|
+
|
|
68
|
+
### turn a script into a tool — `ToolControl`
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from broskill.processing.skill import SkillControl
|
|
72
|
+
from broskill.processing.tool import ToolControl, to_args
|
|
73
|
+
import subprocess, sys
|
|
74
|
+
|
|
75
|
+
sc = SkillControl(root=root / "skills")
|
|
76
|
+
sc.load_skill("read-file")
|
|
77
|
+
|
|
78
|
+
tc = ToolControl(sc)
|
|
79
|
+
tool = tc.load_tool("read-file", "scripts/list_files.py")
|
|
80
|
+
# tool.name, tool.description, tool.args -> ready to hand to an LLM's tool schema
|
|
81
|
+
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
[sys.executable, str(tool.path), *to_args({"path": "skills/**/*.md"})],
|
|
84
|
+
capture_output=True, text=True,
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A script only needs one thing to become a tool: a module-level `get_args()` returning an `argparse.ArgumentParser`. broskill reads that parser and builds the schema for you — no separate tool-definition file to keep in sync. See [`skills/create-skill/references/script.md`](skills/create-skill/references/script.md) for the full contract.
|
|
89
|
+
|
|
90
|
+
Full runnable version: [`examples/case2.py`](examples/case2.py).
|
|
91
|
+
|
|
92
|
+
## running the tests
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv run pytest
|
|
96
|
+
uv run ruff check
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## more
|
|
100
|
+
|
|
101
|
+
- [`VERSIONS.md`](VERSIONS.md) — changelog
|
|
102
|
+
- [`LICENSE`](LICENSE) — MIT
|
|
103
|
+
- [`skills/create-skill`](skills/create-skill/SKILL.md) — a skill that helps you build more skills
|
|
104
|
+
- [`skills/read-file`](skills/read-file/SKILL.md) — the skill used in both examples above, a decent reference for what a real skill looks like
|
|
105
|
+
|
|
106
|
+
stay chill, ship skills. 🤙
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
broskill/__init__.py,sha256=VDKp94tx9alpwa69Ca1ZvHMxMncXAdUoc0z7cMcAlWI,334
|
|
2
|
+
broskill/data_specs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
broskill/data_specs/skill.py,sha256=XmZAi4qhqD9pTsv7AeeSoZ9RjibAu3fyS-ZtQ2HzNnc,1375
|
|
4
|
+
broskill/data_specs/tool.py,sha256=dEuafTTAyf7cYlhYcn0Ir--_SjzV3gqA8ebetMQTINs,1739
|
|
5
|
+
broskill/processing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
broskill/processing/path.py,sha256=UbPo84sS7OcblBx4A3r0h9pN_K6ls4xD1Z4n9AeRwTE,1194
|
|
7
|
+
broskill/processing/skill.py,sha256=Mc0OCvkFkJ3lUuX7SILnc9_VNDa3UX3UtyEj59SR5jg,6462
|
|
8
|
+
broskill/processing/tool.py,sha256=i4iaEkybDHrOiO_I_Xhd5hRrBSi_lVLUVPZ3x309cTc,4066
|
|
9
|
+
broskill-0.1.0.dist-info/METADATA,sha256=y8cni_eMO6rx4qNn4yPsQb3aUoyHVRHG_XxUR7Jq8U8,4629
|
|
10
|
+
broskill-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
broskill-0.1.0.dist-info/licenses/LICENSE,sha256=n-AwFsgZAbsTeTLfRf93WkKsAgYP_qZUDlNmf5jwZlM,1067
|
|
12
|
+
broskill-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 doublebank
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|