Skip to content

Commit 943a2da

Browse files
mballanceCopilot
andcommitted
Add SKILLS.md aggregation for agent skills discovery
Collect SKILLS.md / SKILL.md files from dependency packages and aggregate them into a top-level deps_dir/SKILLS.md file, similar to the existing packages.envrc aggregation. - New PackageHandlerSkills handler: - process_pkg(): searches each non-PyPI package for SKILLS.md (preferred) then SKILL.md; parses YAML frontmatter; warns and skips on missing/malformed frontmatter - update(): writes deps_dir/SKILLS.md with a YAML frontmatter header (name derived from project name), then one ## section per skill with description and relative link; propagates optional fields (license, compatibility, allowed-tools) - Register PackageHandlerSkills in PackageHandlerRgy - Add project_name field to ProjectUpdateInfo; populate from proj_info.name in project_ops.py - Test fixtures: skills_leaf1 (SKILLS.md), skills_leaf2 (SKILL.md), skills_both (both files, preference test), skills_bad_frontmatter - Tests (test_skills.py): SKILLS.md found, SKILL.md fallback, SKILLS.md preferred over SKILL.md, no file when no skills, bad frontmatter warns without crash, multiple packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 856fc23 commit 943a2da

File tree

14 files changed

+356
-1
lines changed

14 files changed

+356
-1
lines changed

src/ivpm/handlers/package_handler_rgy.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .package_handler_list import PackageHandlerList
2525
from .package_handler_python import PackageHandlerPython
2626
from .package_handler_direnv import PackageHandlerDirenv
27+
from .package_handler_skills import PackageHandlerSkills
2728

2829
_logger = logging.getLogger("ivpm.handlers.package_handler_rgy")
2930

@@ -41,6 +42,7 @@ def addHandler(self, h):
4142
def _load(self):
4243
self.addHandler(PackageHandlerPython)
4344
self.addHandler(PackageHandlerDirenv)
45+
self.addHandler(PackageHandlerSkills)
4446

4547
def mkHandler(self):
4648
h = PackageHandlerList()
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#****************************************************************************
2+
#* package_handler_skills.py
3+
#*
4+
#* Copyright 2024 Matthew Ballance and Contributors
5+
#*
6+
#* Licensed under the Apache License, Version 2.0 (the "License"); you may
7+
#* not use this file except in compliance with the License.
8+
#* You may obtain a copy of the License at:
9+
#*
10+
#* http://www.apache.org/licenses/LICENSE-2.0
11+
#*
12+
#* Unless required by applicable law or agreed to in writing, software
13+
#* distributed under the License is distributed on an "AS IS" BASIS,
14+
#* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
#* See the License for the specific language governing permissions and
16+
#* limitations under the License.
17+
#*
18+
#****************************************************************************
19+
import dataclasses as dc
20+
import logging
21+
import os
22+
import re
23+
from typing import Dict, Optional, Tuple
24+
from ..package import Package
25+
from ..project_ops_info import ProjectUpdateInfo
26+
from .package_handler import PackageHandler
27+
28+
_logger = logging.getLogger("ivpm.handlers.package_handler_skills")
29+
30+
# Frontmatter is delimited by lines containing only '---'
31+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
32+
_FIELD_RE = re.compile(r"^(\w[\w-]*):\s*(.+)$", re.MULTILINE)
33+
34+
35+
def _parse_frontmatter(path: str) -> Optional[Dict[str, str]]:
36+
"""Return a dict of frontmatter fields, or None on failure."""
37+
try:
38+
with open(path) as fh:
39+
content = fh.read()
40+
except OSError as exc:
41+
_logger.warning("Could not read %s: %s", path, exc)
42+
return None
43+
44+
m = _FRONTMATTER_RE.match(content)
45+
if not m:
46+
return None
47+
48+
fields: Dict[str, str] = {}
49+
for fm in _FIELD_RE.finditer(m.group(1)):
50+
fields[fm.group(1)] = fm.group(2).strip()
51+
return fields
52+
53+
54+
@dc.dataclass
55+
class PackageHandlerSkills(PackageHandler):
56+
name = "skills"
57+
# package name -> (Package, skill filename, skill name, description, extra fields)
58+
skill_pkgs: Dict[str, Tuple] = dc.field(default_factory=dict)
59+
60+
def process_pkg(self, pkg: Package):
61+
"""Record packages that provide a SKILLS.md or SKILL.md file."""
62+
if not hasattr(pkg, "path") or pkg.path is None:
63+
return
64+
if getattr(pkg, "src_type", None) == "pypi":
65+
return
66+
67+
for candidate in ("SKILLS.md", "SKILL.md"):
68+
skill_path = os.path.join(pkg.path, candidate)
69+
if os.path.isfile(skill_path):
70+
fields = _parse_frontmatter(skill_path)
71+
if fields is None:
72+
_logger.warning(
73+
"Package %s: %s has missing or malformed frontmatter; skipping",
74+
pkg.name, candidate,
75+
)
76+
break
77+
skill_name = fields.get("name", "").strip()
78+
description = fields.get("description", "").strip()
79+
if not skill_name or not description:
80+
_logger.warning(
81+
"Package %s: %s frontmatter missing required 'name' or 'description'; skipping",
82+
pkg.name, candidate,
83+
)
84+
break
85+
_logger.debug("Package %s has %s (skill: %s)", pkg.name, candidate, skill_name)
86+
self.skill_pkgs[pkg.name] = (pkg, candidate, skill_name, description, fields)
87+
break
88+
89+
def update(self, update_info: ProjectUpdateInfo):
90+
if not self.skill_pkgs:
91+
_logger.debug("No packages with skill files; skipping SKILLS.md generation")
92+
return
93+
94+
deps_dir = update_info.deps_dir
95+
output_path = os.path.join(deps_dir, "SKILLS.md")
96+
97+
project_name = getattr(update_info, "project_name", None) or "project"
98+
99+
_logger.debug("Writing SKILLS.md to %s", output_path)
100+
101+
with open(output_path, "w") as fp:
102+
fp.write("---\n")
103+
fp.write("name: %s-skills\n" % project_name)
104+
fp.write("description: Aggregated agent skills from %s dependencies.\n" % project_name)
105+
fp.write("---\n\n")
106+
fp.write("<!-- Generated by IVPM — do not edit manually -->\n\n")
107+
fp.write("# Agent Skills\n\n")
108+
109+
for pkg_name, (pkg, filename, skill_name, description, fields) in sorted(self.skill_pkgs.items()):
110+
fp.write("## %s\n\n" % skill_name)
111+
fp.write("%s\n\n" % description)
112+
113+
# Propagate optional fields if present
114+
for field in ("license", "compatibility", "allowed-tools"):
115+
val = fields.get(field, "").strip()
116+
if val:
117+
fp.write("**%s**: %s\n\n" % (field, val))
118+
119+
fp.write("[Skill source](./%s/%s)\n\n" % (pkg_name, filename))
120+
121+
from ..utils import note
122+
note("Generated SKILLS.md with %d skill(s)" % len(self.skill_pkgs))

src/ivpm/project_ops.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ def update(self,
163163

164164
# Call the handlers to take care of project-level setup work
165165
update_info = ProjectUpdateInfo(
166-
args, deps_dir,
166+
args, deps_dir,
167+
project_name=proj_info.name,
167168
force_py_install=force_py_install,
168169
skip_venv=skip_venv,
169170
suppress_output=suppress_output

src/ivpm/project_ops_info.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class ProjectStatusResult(object):
6262

6363
@dc.dataclass
6464
class ProjectUpdateInfo(ProjectOpsInfo):
65+
project_name : Optional[str] = None
6566
force_py_install : bool = False
6667
skip_venv : bool = False
6768
cache: Optional['Cache'] = None
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
This is not valid frontmatter at all.
2+
3+
# No Frontmatter Here
4+
5+
Just some content.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package:
2+
name: skills_bad_frontmatter
3+
4+
dep-sets:
5+
- name: default-dev
6+
deps: []
7+
- name: default
8+
deps: []
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
name: skill-both-dot
3+
description: This is the SKILL.md version and should NOT be selected.
4+
---
5+
6+
# Skill Both (SKILL.md)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
name: skill-both
3+
description: Package with both SKILLS.md and SKILL.md for preference testing.
4+
---
5+
6+
# Skill Both (SKILLS.md)
7+
8+
This is the SKILLS.md version.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package:
2+
name: skills_both
3+
4+
dep-sets:
5+
- name: default-dev
6+
deps: []
7+
- name: default
8+
deps: []
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
name: skill-leaf1
3+
description: Provides leaf1 capability for testing IVPM skill aggregation.
4+
license: Apache-2.0
5+
---
6+
7+
# Skill Leaf 1
8+
9+
Instructions for skill-leaf1.

0 commit comments

Comments
 (0)