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.
@@ -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
- shutil.rmtree(target_dir)
94
-
95
- # Copy the skill directory
96
- shutil.copytree(skill_dir, target_dir)
97
- success_count += 1
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
- "skill_name": skill_dir.name,
122
- "skill_path": skill_dir,
123
- "metadata": metadata,
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
- "skill_name": skill_dir.name,
154
- "deleted_at": deleted_at,
155
- "trash_path": skill_dir,
156
- "timestamp_dir": timestamp_dir.name,
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,,