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