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.
- {agent_skill_manager-0.1.0.dist-info → agent_skill_manager-0.1.2.dist-info}/METADATA +46 -19
- agent_skill_manager-0.1.2.dist-info/RECORD +12 -0
- skill_manager/__init__.py +79 -0
- skill_manager/agents.py +137 -0
- skill_manager/cli.py +1177 -0
- skill_manager/deployment.py +259 -0
- skill_manager/github.py +177 -0
- skill_manager/metadata.py +140 -0
- skill_manager/removal.py +278 -0
- skill_manager/validation.py +83 -0
- agent_skill_manager-0.1.0.dist-info/RECORD +0 -4
- {agent_skill_manager-0.1.0.dist-info → agent_skill_manager-0.1.2.dist-info}/WHEEL +0 -0
- {agent_skill_manager-0.1.0.dist-info → agent_skill_manager-0.1.2.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
skill_manager/github.py
ADDED
|
@@ -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"
|