agent-skill-manager 0.1.0__py3-none-any.whl → 0.1.2__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.
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Skill deployment functionality.
4
+ Handles copying skills to agent directories.
5
+ """
6
+
7
+ import shutil
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+
11
+ from .agents import get_agent_path
12
+ from .github import download_skill_from_github
13
+ from .metadata import (
14
+ list_updatable_skills,
15
+ read_skill_metadata,
16
+ update_skill_metadata,
17
+ )
18
+
19
+
20
+ def deploy_skill(
21
+ skill_path: Path,
22
+ skills_dir: Path,
23
+ agent_id: str,
24
+ deployment_type: str = "global",
25
+ project_root: Path | None = None,
26
+ ) -> bool:
27
+ """
28
+ Deploy a single skill to an agent's directory.
29
+
30
+ Args:
31
+ skill_path: Relative path to the skill (relative to skills_dir)
32
+ skills_dir: Base skills directory
33
+ agent_id: Target agent identifier
34
+ deployment_type: Either "global" or "project"
35
+ project_root: Project root directory (required for project deployment)
36
+
37
+ Returns:
38
+ True if deployment succeeded, False otherwise
39
+ """
40
+ try:
41
+ source = skills_dir / skill_path
42
+ target_base = get_agent_path(agent_id, deployment_type, project_root)
43
+ target = target_base / skill_path
44
+
45
+ # Ensure target directory exists
46
+ target.parent.mkdir(parents=True, exist_ok=True)
47
+
48
+ # Remove existing skill if present
49
+ if target.exists():
50
+ shutil.rmtree(target)
51
+
52
+ # Copy the skill directory
53
+ shutil.copytree(source, target)
54
+ return True
55
+ except Exception:
56
+ return False
57
+
58
+
59
+ def deploy_skill_to_agents(
60
+ skill_dir: Path,
61
+ agents: list[str],
62
+ deployment_type: str = "global",
63
+ project_root: Path | None = None,
64
+ ) -> tuple[int, int]:
65
+ """
66
+ Deploy a skill directory to multiple agents.
67
+
68
+ This is used when installing a skill directly from GitHub.
69
+
70
+ Args:
71
+ skill_dir: Path to the skill directory
72
+ agents: List of agent IDs to deploy to
73
+ deployment_type: Either "global" or "project"
74
+ project_root: Project root directory (required for project deployment)
75
+
76
+ Returns:
77
+ Tuple of (success_count, failure_count)
78
+ """
79
+ skill_name = skill_dir.name
80
+ success_count = 0
81
+ fail_count = 0
82
+
83
+ for agent_id in agents:
84
+ try:
85
+ target_base = get_agent_path(agent_id, deployment_type, project_root)
86
+ target_dir = target_base / skill_name
87
+
88
+ # Ensure target directory exists
89
+ target_base.mkdir(parents=True, exist_ok=True)
90
+
91
+ # 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
98
+ except Exception:
99
+ fail_count += 1
100
+
101
+ return success_count, fail_count
102
+
103
+
104
+ def deploy_multiple_skills(
105
+ skill_paths: list[Path],
106
+ skills_dir: Path,
107
+ agents: list[str],
108
+ deployment_type: str = "global",
109
+ project_root: Path | None = None,
110
+ progress_callback: Callable | None = None,
111
+ ) -> tuple[int, int]:
112
+ """
113
+ Deploy multiple skills to multiple agents.
114
+
115
+ Args:
116
+ skill_paths: List of relative skill paths (relative to skills_dir)
117
+ skills_dir: Base skills directory
118
+ agents: List of agent IDs to deploy to
119
+ deployment_type: Either "global" or "project"
120
+ project_root: Project root directory (required for project deployment)
121
+ progress_callback: Optional callback for progress updates
122
+
123
+ Returns:
124
+ Tuple of (total_deployed, total_failed)
125
+ """
126
+ total_deployed = 0
127
+ total_failed = 0
128
+
129
+ for agent_id in agents:
130
+ for skill_path in skill_paths:
131
+ if progress_callback:
132
+ progress_callback(agent_id, skill_path)
133
+
134
+ if deploy_skill(skill_path, skills_dir, agent_id, deployment_type, project_root):
135
+ total_deployed += 1
136
+ else:
137
+ total_failed += 1
138
+
139
+ return total_deployed, total_failed
140
+
141
+
142
+ def update_skill(
143
+ skill_name: str,
144
+ agent_id: str,
145
+ deployment_type: str = "global",
146
+ project_root: Path | None = None,
147
+ progress_callback: Callable | None = None,
148
+ ) -> bool:
149
+ """
150
+ Update a single skill from its GitHub source.
151
+
152
+ Args:
153
+ skill_name: Name of the skill to update
154
+ agent_id: Target agent identifier
155
+ deployment_type: Either "global" or "project"
156
+ project_root: Project root directory (required for project deployment)
157
+ progress_callback: Optional callback for progress updates
158
+
159
+ Returns:
160
+ True if update succeeded, False otherwise
161
+ """
162
+ try:
163
+ agent_path = get_agent_path(agent_id, deployment_type, project_root)
164
+ skill_dir = agent_path / skill_name
165
+
166
+ if not skill_dir.exists():
167
+ return False
168
+
169
+ # Read metadata
170
+ metadata = read_skill_metadata(skill_dir)
171
+ if not metadata or metadata.get("source") != "github":
172
+ return False
173
+
174
+ # Get GitHub info from metadata
175
+ github_url = metadata["github_url"]
176
+
177
+ if progress_callback:
178
+ progress_callback(f"Updating {skill_name}...")
179
+
180
+ # Download updated version to temporary location
181
+ temp_dir = agent_path.parent / ".tmp_update"
182
+ temp_dir.mkdir(exist_ok=True)
183
+
184
+ try:
185
+ updated_skill_dir, _ = download_skill_from_github(github_url, temp_dir)
186
+
187
+ # Backup current version
188
+ backup_dir = agent_path.parent / ".backup"
189
+ backup_dir.mkdir(exist_ok=True)
190
+ backup_path = backup_dir / skill_name
191
+
192
+ if backup_path.exists():
193
+ shutil.rmtree(backup_path)
194
+ shutil.copytree(skill_dir, backup_path)
195
+
196
+ # Remove old version
197
+ shutil.rmtree(skill_dir)
198
+
199
+ # Move updated version
200
+ shutil.move(str(updated_skill_dir), str(skill_dir))
201
+
202
+ # Update metadata timestamp
203
+ update_skill_metadata(skill_dir)
204
+
205
+ # Clean up
206
+ shutil.rmtree(temp_dir, ignore_errors=True)
207
+ shutil.rmtree(backup_dir, ignore_errors=True)
208
+
209
+ return True
210
+
211
+ except Exception:
212
+ # Restore from backup if update failed
213
+ backup_dir = agent_path.parent / ".backup"
214
+ backup_path = backup_dir / skill_name
215
+ if backup_path.exists():
216
+ if skill_dir.exists():
217
+ shutil.rmtree(skill_dir)
218
+ shutil.copytree(backup_path, skill_dir)
219
+ shutil.rmtree(temp_dir, ignore_errors=True)
220
+ shutil.rmtree(backup_dir, ignore_errors=True)
221
+ return False
222
+
223
+ except Exception:
224
+ return False
225
+
226
+
227
+ def update_all_skills(
228
+ agent_id: str,
229
+ deployment_type: str = "global",
230
+ project_root: Path | None = None,
231
+ progress_callback: Callable | None = None,
232
+ ) -> tuple[int, int]:
233
+ """
234
+ Update all skills from GitHub for an agent.
235
+
236
+ Args:
237
+ agent_id: Target agent identifier
238
+ deployment_type: Either "global" or "project"
239
+ project_root: Project root directory (required for project deployment)
240
+ progress_callback: Optional callback for progress updates
241
+
242
+ Returns:
243
+ Tuple of (success_count, failure_count)
244
+ """
245
+ agent_path = get_agent_path(agent_id, deployment_type, project_root)
246
+ updatable_skills = list_updatable_skills(agent_path)
247
+
248
+ success_count = 0
249
+ fail_count = 0
250
+
251
+ for skill_info in updatable_skills:
252
+ skill_name = skill_info["skill_name"]
253
+
254
+ if update_skill(skill_name, agent_id, deployment_type, project_root, progress_callback):
255
+ success_count += 1
256
+ else:
257
+ fail_count += 1
258
+
259
+ return success_count, fail_count
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ GitHub download functionality for skills.
4
+ Handles URL parsing and downloading files/directories from GitHub.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ import httpx
12
+
13
+
14
+ def parse_github_url(url: str) -> tuple[str, str, str, str]:
15
+ """
16
+ Parse GitHub URL to extract repository information.
17
+
18
+ Examples:
19
+ https://github.com/owner/repo/tree/main/path/to/dir
20
+ https://github.com/owner/repo/blob/main/path/to/file.py
21
+
22
+ Args:
23
+ url: GitHub URL
24
+
25
+ Returns:
26
+ Tuple of (owner, repo, branch, path)
27
+
28
+ Raises:
29
+ ValueError: If the URL is invalid
30
+ """
31
+ parsed = urlparse(url)
32
+ parts = parsed.path.strip("/").split("/")
33
+
34
+ if len(parts) < 2:
35
+ raise ValueError("Invalid GitHub URL")
36
+
37
+ owner = parts[0]
38
+ repo = parts[1]
39
+ branch = "main"
40
+ path = ""
41
+
42
+ if len(parts) > 3:
43
+ # parts[2] is 'tree' or 'blob'
44
+ # parts[3] is the branch name
45
+ branch = parts[3]
46
+ if len(parts) > 4:
47
+ path = "/".join(parts[4:])
48
+
49
+ return owner, repo, branch, path
50
+
51
+
52
+ def get_github_content(owner: str, repo: str, path: str, branch: str = "main") -> dict:
53
+ """
54
+ Fetch file or directory content using GitHub API.
55
+
56
+ Args:
57
+ owner: Repository owner
58
+ repo: Repository name
59
+ path: Path within the repository
60
+ branch: Branch name (default: "main")
61
+
62
+ Returns:
63
+ JSON response from GitHub API
64
+
65
+ Raises:
66
+ httpx.HTTPStatusError: If the request fails
67
+ """
68
+ api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}"
69
+ params = {"ref": branch}
70
+
71
+ with httpx.Client() as client:
72
+ response = client.get(api_url, params=params, follow_redirects=True)
73
+ response.raise_for_status()
74
+ return response.json()
75
+
76
+
77
+ def download_file(url: str, dest_path: Path) -> None:
78
+ """
79
+ Download a single file from a URL.
80
+
81
+ Args:
82
+ url: File download URL
83
+ dest_path: Local destination path
84
+
85
+ Raises:
86
+ httpx.HTTPStatusError: If the download fails
87
+ """
88
+ with httpx.Client() as client:
89
+ response = client.get(url, follow_redirects=True)
90
+ response.raise_for_status()
91
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
92
+ dest_path.write_bytes(response.content)
93
+
94
+
95
+ def download_directory(
96
+ owner: str, repo: str, path: str, dest_dir: Path, branch: str = "main"
97
+ ) -> None:
98
+ """
99
+ Recursively download an entire directory from GitHub.
100
+
101
+ Args:
102
+ owner: Repository owner
103
+ repo: Repository name
104
+ path: Path to directory in the repository
105
+ dest_dir: Local destination directory
106
+ branch: Branch name (default: "main")
107
+
108
+ Raises:
109
+ httpx.HTTPStatusError: If the download fails
110
+ """
111
+ content = get_github_content(owner, repo, path, branch)
112
+
113
+ for item in content:
114
+ item_name = item["name"]
115
+ item_path = item["path"]
116
+ item_type = item["type"]
117
+
118
+ if item_type == "file":
119
+ download_url = item["download_url"]
120
+ file_dest = dest_dir / item_name
121
+ download_file(download_url, file_dest)
122
+ elif item_type == "dir":
123
+ subdir_dest = dest_dir / item_name
124
+ download_directory(owner, repo, item_path, subdir_dest, branch)
125
+
126
+
127
+ def download_skill_from_github(
128
+ url: str, dest_dir: Path, progress_callback: Callable | None = None
129
+ ) -> tuple[Path, dict]:
130
+ """
131
+ Download a skill from GitHub to a local directory.
132
+
133
+ Args:
134
+ url: GitHub URL (can be a directory or file)
135
+ dest_dir: Destination directory
136
+ progress_callback: Optional callback for progress updates
137
+
138
+ Returns:
139
+ Tuple of (path to downloaded skill directory, metadata dict with owner/repo/branch/path)
140
+
141
+ Raises:
142
+ ValueError: If the URL doesn't point to a directory
143
+ httpx.HTTPStatusError: If the download fails
144
+ """
145
+ # Parse the URL
146
+ owner, repo, branch, path = parse_github_url(url)
147
+
148
+ # Get content information
149
+ content = get_github_content(owner, repo, path, branch)
150
+
151
+ # Must be a directory
152
+ if not isinstance(content, list):
153
+ raise ValueError("The provided URL does not point to a directory")
154
+
155
+ # Determine skill name from path
156
+ skill_name = path.split("/")[-1] if path else repo
157
+ skill_dest = dest_dir / skill_name
158
+
159
+ # Create destination directory
160
+ skill_dest.mkdir(parents=True, exist_ok=True)
161
+
162
+ # Download the directory
163
+ if progress_callback:
164
+ progress_callback(f"Downloading {skill_name}...")
165
+
166
+ download_directory(owner, repo, path, skill_dest, branch)
167
+
168
+ # Return path and metadata
169
+ metadata = {
170
+ "owner": owner,
171
+ "repo": repo,
172
+ "branch": branch,
173
+ "path": path,
174
+ "url": url,
175
+ }
176
+
177
+ return skill_dest, metadata
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Skill metadata management.
4
+ Tracks installation source and update information for skills.
5
+ """
6
+
7
+ import json
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ METADATA_FILENAME = ".skill_metadata.json"
12
+
13
+
14
+ def save_skill_metadata(
15
+ skill_dir: Path,
16
+ github_url: str,
17
+ owner: str,
18
+ repo: str,
19
+ branch: str,
20
+ path: str,
21
+ ) -> None:
22
+ """
23
+ Save metadata for a skill installed from GitHub.
24
+
25
+ Args:
26
+ skill_dir: Path to the skill directory
27
+ github_url: Original GitHub URL
28
+ owner: Repository owner
29
+ repo: Repository name
30
+ branch: Branch name
31
+ path: Path within the repository
32
+ """
33
+ metadata = {
34
+ "source": "github",
35
+ "github_url": github_url,
36
+ "owner": owner,
37
+ "repo": repo,
38
+ "branch": branch,
39
+ "path": path,
40
+ "installed_at": datetime.now(UTC).isoformat(),
41
+ "updated_at": datetime.now(UTC).isoformat(),
42
+ }
43
+
44
+ metadata_file = skill_dir / METADATA_FILENAME
45
+ metadata_file.write_text(json.dumps(metadata, indent=2))
46
+
47
+
48
+ def read_skill_metadata(skill_dir: Path) -> dict | None:
49
+ """
50
+ Read metadata for a skill.
51
+
52
+ Args:
53
+ skill_dir: Path to the skill directory
54
+
55
+ Returns:
56
+ Metadata dictionary or None if not found
57
+ """
58
+ metadata_file = skill_dir / METADATA_FILENAME
59
+ if not metadata_file.exists():
60
+ return None
61
+
62
+ try:
63
+ return json.loads(metadata_file.read_text())
64
+ except Exception:
65
+ return None
66
+
67
+
68
+ def update_skill_metadata(skill_dir: Path) -> bool:
69
+ """
70
+ Update the 'updated_at' timestamp for a skill.
71
+
72
+ Args:
73
+ skill_dir: Path to the skill directory
74
+
75
+ Returns:
76
+ True if updated successfully, False otherwise
77
+ """
78
+ metadata = read_skill_metadata(skill_dir)
79
+ if not metadata:
80
+ return False
81
+
82
+ metadata["updated_at"] = datetime.now(UTC).isoformat()
83
+
84
+ metadata_file = skill_dir / METADATA_FILENAME
85
+ try:
86
+ metadata_file.write_text(json.dumps(metadata, indent=2))
87
+ return True
88
+ except Exception:
89
+ return False
90
+
91
+
92
+ def list_updatable_skills(
93
+ agent_path: Path,
94
+ ) -> list[dict]:
95
+ """
96
+ List all skills that have GitHub metadata and can be updated.
97
+
98
+ Args:
99
+ agent_path: Path to the agent's skills directory
100
+
101
+ Returns:
102
+ List of dictionaries containing skill info and metadata
103
+ """
104
+ if not agent_path.exists():
105
+ return []
106
+
107
+ updatable_skills = []
108
+
109
+ for skill_dir in agent_path.iterdir():
110
+ if not skill_dir.is_dir() or skill_dir.name.startswith("."):
111
+ continue
112
+
113
+ # Check for SKILL.md
114
+ if not (skill_dir / "SKILL.md").exists():
115
+ continue
116
+
117
+ # Check for metadata
118
+ metadata = read_skill_metadata(skill_dir)
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
+ })
125
+
126
+ return sorted(updatable_skills, key=lambda x: x["skill_name"])
127
+
128
+
129
+ def has_github_source(skill_dir: Path) -> bool:
130
+ """
131
+ Check if a skill was installed from GitHub.
132
+
133
+ Args:
134
+ skill_dir: Path to the skill directory
135
+
136
+ Returns:
137
+ True if the skill has GitHub metadata, False otherwise
138
+ """
139
+ metadata = read_skill_metadata(skill_dir)
140
+ return metadata is not None and metadata.get("source") == "github"