agent-skill-manager 0.1.3__py3-none-any.whl → 0.2.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.
- {agent_skill_manager-0.1.3.dist-info → agent_skill_manager-0.2.0.dist-info}/METADATA +123 -28
- agent_skill_manager-0.2.0.dist-info/RECORD +12 -0
- skill_manager/__init__.py +17 -2
- skill_manager/agents.py +226 -38
- skill_manager/cli.py +559 -64
- skill_manager/deployment.py +118 -7
- skill_manager/github.py +155 -6
- skill_manager/metadata.py +7 -5
- skill_manager/removal.py +9 -10
- agent_skill_manager-0.1.3.dist-info/RECORD +0 -12
- {agent_skill_manager-0.1.3.dist-info → agent_skill_manager-0.2.0.dist-info}/WHEEL +0 -0
- {agent_skill_manager-0.1.3.dist-info → agent_skill_manager-0.2.0.dist-info}/entry_points.txt +0 -0
skill_manager/deployment.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
Skill deployment functionality.
|
|
4
|
-
Handles copying skills to agent directories.
|
|
4
|
+
Handles copying skills to agent directories with optional symlink support.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import os
|
|
7
8
|
import shutil
|
|
8
9
|
from collections.abc import Callable
|
|
9
10
|
from pathlib import Path
|
|
@@ -17,6 +18,102 @@ from .metadata import (
|
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def is_symlink_supported() -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Check if the current system supports symlinks.
|
|
24
|
+
|
|
25
|
+
On Windows, symlinks require admin privileges or developer mode.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
True if symlinks are supported, False otherwise
|
|
29
|
+
"""
|
|
30
|
+
if os.name == "nt":
|
|
31
|
+
# Windows: test by trying to create a symlink
|
|
32
|
+
import tempfile
|
|
33
|
+
|
|
34
|
+
test_dir = Path(tempfile.gettempdir()) / ".symlink_test"
|
|
35
|
+
test_link = Path(tempfile.gettempdir()) / ".symlink_test_link"
|
|
36
|
+
try:
|
|
37
|
+
test_dir.mkdir(exist_ok=True)
|
|
38
|
+
if test_link.exists() or test_link.is_symlink():
|
|
39
|
+
test_link.unlink()
|
|
40
|
+
test_link.symlink_to(test_dir, target_is_directory=True)
|
|
41
|
+
test_link.unlink()
|
|
42
|
+
test_dir.rmdir()
|
|
43
|
+
return True
|
|
44
|
+
except OSError:
|
|
45
|
+
if test_dir.exists():
|
|
46
|
+
test_dir.rmdir()
|
|
47
|
+
return False
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_symlink(source: Path, target: Path) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Create a symlink from target to source.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
source: The source directory (the actual skill)
|
|
57
|
+
target: The target path where the symlink will be created
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if symlink was created successfully, False otherwise
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
# Ensure target parent directory exists
|
|
64
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
# Remove existing target if present
|
|
67
|
+
if target.exists() or target.is_symlink():
|
|
68
|
+
if target.is_symlink():
|
|
69
|
+
target.unlink()
|
|
70
|
+
elif target.is_dir():
|
|
71
|
+
shutil.rmtree(target)
|
|
72
|
+
else:
|
|
73
|
+
target.unlink()
|
|
74
|
+
|
|
75
|
+
# Create symlink
|
|
76
|
+
target.symlink_to(source, target_is_directory=True)
|
|
77
|
+
return True
|
|
78
|
+
except OSError:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def remove_symlink(target: Path) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
Remove a symlink (or regular directory).
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
target: The symlink/directory to remove
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if removed successfully, False otherwise
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
if target.is_symlink():
|
|
94
|
+
target.unlink()
|
|
95
|
+
elif target.is_dir():
|
|
96
|
+
shutil.rmtree(target)
|
|
97
|
+
elif target.exists():
|
|
98
|
+
target.unlink()
|
|
99
|
+
return True
|
|
100
|
+
except OSError:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_skill_symlink(skill_path: Path) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Check if a skill is deployed as a symlink.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
skill_path: Path to the skill directory
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if the skill is a symlink, False otherwise
|
|
113
|
+
"""
|
|
114
|
+
return skill_path.is_symlink()
|
|
115
|
+
|
|
116
|
+
|
|
20
117
|
def deploy_skill(
|
|
21
118
|
skill_path: Path,
|
|
22
119
|
skills_dir: Path,
|
|
@@ -61,6 +158,7 @@ def deploy_skill_to_agents(
|
|
|
61
158
|
agents: list[str],
|
|
62
159
|
deployment_type: str = "global",
|
|
63
160
|
project_root: Path | None = None,
|
|
161
|
+
use_symlink: bool = False,
|
|
64
162
|
) -> tuple[int, int]:
|
|
65
163
|
"""
|
|
66
164
|
Deploy a skill directory to multiple agents.
|
|
@@ -72,6 +170,7 @@ def deploy_skill_to_agents(
|
|
|
72
170
|
agents: List of agent IDs to deploy to
|
|
73
171
|
deployment_type: Either "global" or "project"
|
|
74
172
|
project_root: Project root directory (required for project deployment)
|
|
173
|
+
use_symlink: If True, create symlinks instead of copying
|
|
75
174
|
|
|
76
175
|
Returns:
|
|
77
176
|
Tuple of (success_count, failure_count)
|
|
@@ -89,12 +188,24 @@ def deploy_skill_to_agents(
|
|
|
89
188
|
target_base.mkdir(parents=True, exist_ok=True)
|
|
90
189
|
|
|
91
190
|
# Remove existing skill if present
|
|
92
|
-
if target_dir.exists():
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
191
|
+
if target_dir.exists() or target_dir.is_symlink():
|
|
192
|
+
if target_dir.is_symlink():
|
|
193
|
+
target_dir.unlink()
|
|
194
|
+
else:
|
|
195
|
+
shutil.rmtree(target_dir)
|
|
196
|
+
|
|
197
|
+
if use_symlink:
|
|
198
|
+
# Create symlink
|
|
199
|
+
if create_symlink(skill_dir.resolve(), target_dir):
|
|
200
|
+
success_count += 1
|
|
201
|
+
else:
|
|
202
|
+
# Fallback to copy if symlink fails
|
|
203
|
+
shutil.copytree(skill_dir, target_dir)
|
|
204
|
+
success_count += 1
|
|
205
|
+
else:
|
|
206
|
+
# Copy the skill directory
|
|
207
|
+
shutil.copytree(skill_dir, target_dir)
|
|
208
|
+
success_count += 1
|
|
98
209
|
except Exception:
|
|
99
210
|
fail_count += 1
|
|
100
211
|
|
skill_manager/github.py
CHANGED
|
@@ -4,6 +4,7 @@ GitHub download functionality for skills.
|
|
|
4
4
|
Handles URL parsing and downloading files/directories from GitHub.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import tempfile
|
|
7
8
|
from collections.abc import Callable
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from urllib.parse import urlparse
|
|
@@ -11,6 +12,16 @@ from urllib.parse import urlparse
|
|
|
11
12
|
import httpx
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
def get_system_temp_dir() -> Path:
|
|
16
|
+
"""
|
|
17
|
+
Get the system temporary directory (cross-platform).
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Path to the system temp directory
|
|
21
|
+
"""
|
|
22
|
+
return Path(tempfile.gettempdir()) / "skill-manager"
|
|
23
|
+
|
|
24
|
+
|
|
14
25
|
def parse_github_url(url: str) -> tuple[str, str, str, str]:
|
|
15
26
|
"""
|
|
16
27
|
Parse GitHub URL to extract repository information.
|
|
@@ -92,9 +103,7 @@ def download_file(url: str, dest_path: Path) -> None:
|
|
|
92
103
|
dest_path.write_bytes(response.content)
|
|
93
104
|
|
|
94
105
|
|
|
95
|
-
def download_directory(
|
|
96
|
-
owner: str, repo: str, path: str, dest_dir: Path, branch: str = "main"
|
|
97
|
-
) -> None:
|
|
106
|
+
def download_directory(owner: str, repo: str, path: str, dest_dir: Path, branch: str = "main") -> None:
|
|
98
107
|
"""
|
|
99
108
|
Recursively download an entire directory from GitHub.
|
|
100
109
|
|
|
@@ -124,9 +133,7 @@ def download_directory(
|
|
|
124
133
|
download_directory(owner, repo, item_path, subdir_dest, branch)
|
|
125
134
|
|
|
126
135
|
|
|
127
|
-
def download_skill_from_github(
|
|
128
|
-
url: str, dest_dir: Path, progress_callback: Callable | None = None
|
|
129
|
-
) -> tuple[Path, dict]:
|
|
136
|
+
def download_skill_from_github(url: str, dest_dir: Path, progress_callback: Callable | None = None) -> tuple[Path, dict]:
|
|
130
137
|
"""
|
|
131
138
|
Download a skill from GitHub to a local directory.
|
|
132
139
|
|
|
@@ -175,3 +182,145 @@ def download_skill_from_github(
|
|
|
175
182
|
}
|
|
176
183
|
|
|
177
184
|
return skill_dest, metadata
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def discover_skills_in_repo(url: str, progress_callback: Callable | None = None) -> list[dict]:
|
|
188
|
+
"""
|
|
189
|
+
Discover all SKILL.md files in a GitHub repository or directory.
|
|
190
|
+
|
|
191
|
+
This function scans a GitHub URL to find all directories containing SKILL.md files,
|
|
192
|
+
which conform to the skill specification.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
url: GitHub URL (can be repo root or a subdirectory)
|
|
196
|
+
progress_callback: Optional callback for progress updates
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of dictionaries containing skill information:
|
|
200
|
+
- name: skill directory name
|
|
201
|
+
- path: path within the repository
|
|
202
|
+
- url: full GitHub URL to the skill directory
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
httpx.HTTPStatusError: If the API request fails
|
|
206
|
+
"""
|
|
207
|
+
owner, repo, branch, path = parse_github_url(url)
|
|
208
|
+
|
|
209
|
+
if progress_callback:
|
|
210
|
+
progress_callback(f"Scanning {owner}/{repo}...")
|
|
211
|
+
|
|
212
|
+
skills = []
|
|
213
|
+
_scan_for_skills(owner, repo, branch, path, skills, progress_callback)
|
|
214
|
+
return skills
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _scan_for_skills(
|
|
218
|
+
owner: str,
|
|
219
|
+
repo: str,
|
|
220
|
+
branch: str,
|
|
221
|
+
path: str,
|
|
222
|
+
skills: list[dict],
|
|
223
|
+
progress_callback: Callable | None = None,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Recursively scan a directory for SKILL.md files.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
owner: Repository owner
|
|
230
|
+
repo: Repository name
|
|
231
|
+
branch: Branch name
|
|
232
|
+
path: Current path being scanned
|
|
233
|
+
skills: List to append found skills to
|
|
234
|
+
progress_callback: Optional callback for progress updates
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
content = get_github_content(owner, repo, path, branch)
|
|
238
|
+
except httpx.HTTPStatusError:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
if not isinstance(content, list):
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
has_skill_md = False
|
|
245
|
+
subdirs = []
|
|
246
|
+
|
|
247
|
+
for item in content:
|
|
248
|
+
item_name = item["name"]
|
|
249
|
+
item_path = item["path"]
|
|
250
|
+
item_type = item["type"]
|
|
251
|
+
|
|
252
|
+
if item_type == "file" and item_name == "SKILL.md":
|
|
253
|
+
has_skill_md = True
|
|
254
|
+
elif item_type == "dir":
|
|
255
|
+
subdirs.append(item_path)
|
|
256
|
+
|
|
257
|
+
if has_skill_md:
|
|
258
|
+
skill_name = path.split("/")[-1] if path else repo
|
|
259
|
+
skill_url = f"https://github.com/{owner}/{repo}/tree/{branch}/{path}" if path else f"https://github.com/{owner}/{repo}"
|
|
260
|
+
skills.append(
|
|
261
|
+
{
|
|
262
|
+
"name": skill_name,
|
|
263
|
+
"path": path,
|
|
264
|
+
"url": skill_url,
|
|
265
|
+
"owner": owner,
|
|
266
|
+
"repo": repo,
|
|
267
|
+
"branch": branch,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
if progress_callback:
|
|
271
|
+
progress_callback(f"Found skill: {skill_name}")
|
|
272
|
+
|
|
273
|
+
# Recursively scan subdirectories
|
|
274
|
+
for subdir in subdirs:
|
|
275
|
+
_scan_for_skills(owner, repo, branch, subdir, skills, progress_callback)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def download_multiple_skills(
|
|
279
|
+
skill_infos: list[dict],
|
|
280
|
+
dest_dir: Path,
|
|
281
|
+
progress_callback: Callable | None = None,
|
|
282
|
+
) -> list[tuple[Path, dict]]:
|
|
283
|
+
"""
|
|
284
|
+
Download multiple skills from GitHub.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
skill_infos: List of skill info dictionaries (from discover_skills_in_repo)
|
|
288
|
+
dest_dir: Destination directory
|
|
289
|
+
progress_callback: Optional callback for progress updates
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
List of tuples (skill_path, metadata)
|
|
293
|
+
"""
|
|
294
|
+
results = []
|
|
295
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
|
|
297
|
+
for skill_info in skill_infos:
|
|
298
|
+
owner = skill_info["owner"]
|
|
299
|
+
repo = skill_info["repo"]
|
|
300
|
+
branch = skill_info["branch"]
|
|
301
|
+
path = skill_info["path"]
|
|
302
|
+
skill_name = skill_info["name"]
|
|
303
|
+
|
|
304
|
+
if progress_callback:
|
|
305
|
+
progress_callback(f"Downloading {skill_name}...")
|
|
306
|
+
|
|
307
|
+
skill_dest = dest_dir / skill_name
|
|
308
|
+
skill_dest.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
download_directory(owner, repo, path, skill_dest, branch)
|
|
312
|
+
|
|
313
|
+
metadata = {
|
|
314
|
+
"owner": owner,
|
|
315
|
+
"repo": repo,
|
|
316
|
+
"branch": branch,
|
|
317
|
+
"path": path,
|
|
318
|
+
"url": skill_info["url"],
|
|
319
|
+
}
|
|
320
|
+
results.append((skill_dest, metadata))
|
|
321
|
+
except Exception:
|
|
322
|
+
# Skip failed downloads
|
|
323
|
+
if progress_callback:
|
|
324
|
+
progress_callback(f"Failed to download {skill_name}")
|
|
325
|
+
|
|
326
|
+
return results
|
skill_manager/metadata.py
CHANGED
|
@@ -117,11 +117,13 @@ def list_updatable_skills(
|
|
|
117
117
|
# Check for metadata
|
|
118
118
|
metadata = read_skill_metadata(skill_dir)
|
|
119
119
|
if metadata and metadata.get("source") == "github":
|
|
120
|
-
updatable_skills.append(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
updatable_skills.append(
|
|
121
|
+
{
|
|
122
|
+
"skill_name": skill_dir.name,
|
|
123
|
+
"skill_path": skill_dir,
|
|
124
|
+
"metadata": metadata,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
125
127
|
|
|
126
128
|
return sorted(updatable_skills, key=lambda x: x["skill_name"])
|
|
127
129
|
|
skill_manager/removal.py
CHANGED
|
@@ -65,10 +65,7 @@ def soft_delete_skill(
|
|
|
65
65
|
# Create metadata file
|
|
66
66
|
metadata_file = trash_dest / ".trash_metadata"
|
|
67
67
|
metadata_file.write_text(
|
|
68
|
-
f"deleted_at: {timestamp}\n"
|
|
69
|
-
f"original_path: {skill_dir}\n"
|
|
70
|
-
f"agent_id: {agent_id}\n"
|
|
71
|
-
f"deployment_type: {deployment_type}\n"
|
|
68
|
+
f"deleted_at: {timestamp}\noriginal_path: {skill_dir}\nagent_id: {agent_id}\ndeployment_type: {deployment_type}\n"
|
|
72
69
|
)
|
|
73
70
|
|
|
74
71
|
return True
|
|
@@ -149,12 +146,14 @@ def list_trashed_skills(
|
|
|
149
146
|
if line.startswith("deleted_at:"):
|
|
150
147
|
deleted_at = line.split(":", 1)[1].strip()
|
|
151
148
|
|
|
152
|
-
trashed_skills.append(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
149
|
+
trashed_skills.append(
|
|
150
|
+
{
|
|
151
|
+
"skill_name": skill_dir.name,
|
|
152
|
+
"deleted_at": deleted_at,
|
|
153
|
+
"trash_path": skill_dir,
|
|
154
|
+
"timestamp_dir": timestamp_dir.name,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
158
157
|
|
|
159
158
|
return trashed_skills
|
|
160
159
|
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
skill_manager/__init__.py,sha256=vpdmk64us5E0-KDMxYY8GV9-jcC4kAcqC-YTjrZMIVU,1717
|
|
2
|
-
skill_manager/agents.py,sha256=1sqdXQwoZCXhedyfo608crZp3rjPX6-LpFU9WsK45sU,3590
|
|
3
|
-
skill_manager/cli.py,sha256=J5Vy0slLFkrB7349gu68kanqD8VDK90K7NXDjP9c9-M,36621
|
|
4
|
-
skill_manager/deployment.py,sha256=EVA-HdBcqcqz6IAZLoGU-BWEw0tOz_kjkRRxD_4ZC5Q,7672
|
|
5
|
-
skill_manager/github.py,sha256=WIju94QHVcJnCprwHujticgkeWs0Z07AXOJ6a39wKTg,4841
|
|
6
|
-
skill_manager/metadata.py,sha256=YF3vBxeujnbMwOJCM8G_zGBD6ZH5ZDMIBOpcKepBwAo,3457
|
|
7
|
-
skill_manager/removal.py,sha256=3yAn0Fd92_X40Z_SmxELHKKrWxew0vUGS8WO6Uq3730,8040
|
|
8
|
-
skill_manager/validation.py,sha256=fRUJDs4TiuKlH3doey31SPJfe671JJQcBFSAhJwoSIE,1970
|
|
9
|
-
agent_skill_manager-0.1.3.dist-info/METADATA,sha256=aiLSYDpFC8myh5CCJrU582Mo2hpYJG0IZMaTpkq9K8A,7547
|
|
10
|
-
agent_skill_manager-0.1.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
11
|
-
agent_skill_manager-0.1.3.dist-info/entry_points.txt,sha256=ac3L07OC98p1llk259d9PUhDp-cl3ifV2nmYHGb3WO8,85
|
|
12
|
-
agent_skill_manager-0.1.3.dist-info/RECORD,,
|
|
File without changes
|
{agent_skill_manager-0.1.3.dist-info → agent_skill_manager-0.2.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|