skillup 0.3.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.
skillup/__init__.py ADDED
File without changes
skillup/cli.py ADDED
@@ -0,0 +1,260 @@
1
+ import json
2
+ import shutil
3
+ from pathlib import Path
4
+ from typing import Any, List, Optional
5
+
6
+ import questionary
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from .github import get_repo_source
11
+ from .install import download_release, ensure_dirs, get_skills_in_zip, install_skill
12
+ from .lock import apply_source, get_sync_source, load_lock, normalize_repo_data, save_lock
13
+ from .settings import format_source_label, settings
14
+
15
+ app = typer.Typer(help="Minimal CLI to manage agent skills from GitHub releases or branches.")
16
+ console = Console()
17
+
18
+
19
+ @app.callback()
20
+ def main(
21
+ is_global: bool = typer.Option(False, "--global", "-g", help="Use home directory instead of current directory"),
22
+ ):
23
+ """Minimal CLI to manage agent skills from GitHub releases or branches."""
24
+ settings.is_global = is_global
25
+
26
+
27
+ @app.command()
28
+ def add(
29
+ repo: str = typer.Argument(..., help="GitHub repository (owner/repo)"),
30
+ skills: Optional[List[str]] = typer.Option(None, "--skill", "-s", help="Specific skill(s) to add (non-interactive)"),
31
+ branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Branch to install from instead of the latest release"),
32
+ ):
33
+ """Add skills from a GitHub release or branch."""
34
+ ensure_dirs()
35
+ lock = load_lock()
36
+
37
+ try:
38
+ source = get_repo_source(repo, branch)
39
+ except Exception as e:
40
+ console.print(f"[red]Error fetching source info for {repo}:[/red] {e}")
41
+ raise typer.Exit(1)
42
+
43
+ zip_path = download_release(repo, source.cache_key, source.zip_url)
44
+ available_skills = get_skills_in_zip(zip_path)
45
+
46
+ repo_data = normalize_repo_data(lock["repos"].get(repo, {"skills": []}))
47
+ installed_skills = set(repo_data["skills"])
48
+
49
+ if skills:
50
+ selected = [s for s in skills if s in available_skills and s not in installed_skills]
51
+ invalid = [s for s in skills if s not in available_skills]
52
+ already_installed = [s for s in skills if s in installed_skills]
53
+
54
+ if invalid:
55
+ console.print(f"[yellow]Warning: Skills not found in {repo}: {', '.join(invalid)}[/yellow]")
56
+ if already_installed:
57
+ console.print(f"[blue]Note: Skills already installed from {repo}: {', '.join(already_installed)}[/blue]")
58
+ else:
59
+ to_show = [s for s in available_skills if s not in installed_skills]
60
+
61
+ if not to_show:
62
+ console.print(f"[yellow]No new skills available to add from {repo}.[/yellow]")
63
+ return
64
+
65
+ selected = questionary.checkbox(
66
+ f"Select skills to add from {repo}:",
67
+ choices=to_show,
68
+ ).ask()
69
+
70
+ if not selected:
71
+ console.print("No skills selected or available for installation.")
72
+ return
73
+
74
+ for skill in selected:
75
+ console.print(f"Installing [cyan]{skill}[/cyan]...")
76
+ install_skill(skill, zip_path)
77
+ if skill not in repo_data["skills"]:
78
+ repo_data["skills"].append(skill)
79
+
80
+ repo_data = apply_source(repo_data, source)
81
+ lock["repos"][repo] = repo_data
82
+ save_lock(lock)
83
+ console.print(f"[green]Skills from {repo} installed successfully![/green]")
84
+
85
+
86
+ @app.command()
87
+ def remove():
88
+ """Interactively remove installed skills across all repositories."""
89
+ lock = load_lock()
90
+
91
+ all_installed = []
92
+ for repo, data in lock["repos"].items():
93
+ for skill in data["skills"]:
94
+ all_installed.append(f"{repo}: {skill}")
95
+
96
+ if not all_installed:
97
+ console.print("[yellow]No skills installed.[/yellow]")
98
+ return
99
+
100
+ selected = questionary.checkbox(
101
+ "Select skills to remove:",
102
+ choices=all_installed,
103
+ ).ask()
104
+
105
+ if not selected:
106
+ console.print("No skills selected.")
107
+ return
108
+
109
+ for item in selected:
110
+ repo, skill = item.split(": ", 1)
111
+ console.print(f"Removing [red]{skill}[/red] from {repo}...")
112
+ for target_dir in [settings.skills_dir_agents, settings.skills_dir_claude]:
113
+ dest = target_dir / skill
114
+ if dest.exists():
115
+ import shutil
116
+ shutil.rmtree(dest)
117
+
118
+ lock["repos"][repo]["skills"].remove(skill)
119
+ if not lock["repos"][repo]["skills"]:
120
+ del lock["repos"][repo]
121
+
122
+ save_lock(lock)
123
+ console.print("[green]Skills removed successfully![/green]")
124
+
125
+
126
+ @app.command()
127
+ def update(repo: Optional[str] = typer.Option(None, "--repo", help="Specific repository to update")):
128
+ """Update installed skills to the latest tracked release or branch commit."""
129
+ lock = load_lock()
130
+ repos_to_update = [repo] if repo else list(lock["repos"].keys())
131
+
132
+ if not repos_to_update:
133
+ console.print("[yellow]No skills installed to update.[/yellow]")
134
+ return
135
+
136
+ for r in repos_to_update:
137
+ if r not in lock["repos"]:
138
+ if repo:
139
+ console.print(f"[red]Repository {r} is not tracked.[/red]")
140
+ continue
141
+
142
+ repo_data = normalize_repo_data(lock["repos"][r])
143
+ console.print(f"Checking updates for [cyan]{r}[/cyan]...")
144
+
145
+ try:
146
+ tracked_branch = repo_data.get("branch") if repo_data.get("source") == "branch" else None
147
+ source = get_repo_source(r, tracked_branch)
148
+ except Exception as e:
149
+ console.print(f"[red]Error fetching source info for {r}:[/red] {e}")
150
+ continue
151
+
152
+ current_version = repo_data.get("commit") or repo_data.get("tag") or repo_data.get("branch")
153
+ new_version = source.commit or source.ref
154
+ if current_version == new_version:
155
+ console.print(f" [green]{r} is already up-to-date ({format_source_label(source)}).[/green]")
156
+ continue
157
+
158
+ previous_label = repo_data.get("tag") or repo_data.get("branch") or repo_data.get("commit", "unknown")
159
+ next_label = format_source_label(source)
160
+ console.print(f" Updating from {previous_label} to [cyan]{next_label}[/cyan]...", highlight=False)
161
+ zip_path = download_release(r, source.cache_key, source.zip_url)
162
+
163
+ for skill in repo_data["skills"]:
164
+ console.print(f" Updating [cyan]{skill}[/cyan]...")
165
+ install_skill(skill, zip_path)
166
+
167
+ repo_data = apply_source(repo_data, source)
168
+ lock["repos"][r] = repo_data
169
+ save_lock(lock)
170
+ console.print(f" [green]Successfully updated {r} to {next_label}![/green]")
171
+
172
+
173
+ @app.command()
174
+ def sync():
175
+ """Install all skills as defined in the lock file."""
176
+ ensure_dirs()
177
+ lock = load_lock()
178
+
179
+ if not lock["repos"]:
180
+ console.print("[yellow]No skills defined in lock file. Use 'add' to add skills.[/yellow]")
181
+ return
182
+
183
+ for repo, repo_data in lock["repos"].items():
184
+ source = get_sync_source(repo, repo_data)
185
+ skills = repo_data["skills"]
186
+ console.print(f"Syncing [cyan]{repo}[/cyan] at [green]{format_source_label(source)}[/green]...")
187
+
188
+ try:
189
+ zip_path = download_release(repo, source.cache_key, source.zip_url)
190
+ for skill in skills:
191
+ console.print(f" Installing [cyan]{skill}[/cyan]...")
192
+ install_skill(skill, zip_path)
193
+ except Exception as e:
194
+ console.print(f"[red]Error syncing {repo}:[/red] {e}")
195
+ continue
196
+
197
+ console.print("[green]Synchronization complete![/green]")
198
+
199
+
200
+ @app.command()
201
+ def migrate(
202
+ input_file: Optional[Path] = typer.Argument(None, help="Path to skills-lock.json (defaults to .claude/skills-lock.json)"),
203
+ ):
204
+ """Migrate a Claude Code skills-lock.json to the skillup lock format."""
205
+ resolved = input_file if input_file is not None else settings.base_dir / "skills-lock.json"
206
+
207
+ if not resolved.exists():
208
+ console.print(f"[red]File not found: {resolved}[/red]")
209
+ raise typer.Exit(1)
210
+
211
+ try:
212
+ data = json.loads(resolved.read_text())
213
+ except Exception as e:
214
+ console.print(f"[red]Failed to parse {resolved}:[/red] {e}")
215
+ raise typer.Exit(1)
216
+
217
+ skills_map: dict[str, Any] = data.get("skills", {})
218
+ if not skills_map:
219
+ console.print("[yellow]No skills found in the input file.[/yellow]")
220
+ return
221
+
222
+ repos: dict[str, list[str]] = {}
223
+ for skill_name, skill_data in skills_map.items():
224
+ if skill_data.get("sourceType") != "github":
225
+ console.print(f"[yellow]Skipping {skill_name}: unsupported sourceType '{skill_data.get('sourceType')}'[/yellow]")
226
+ continue
227
+ repo = skill_data["source"]
228
+ repos.setdefault(repo, []).append(skill_name)
229
+
230
+ if not repos:
231
+ console.print("[yellow]No GitHub skills found to migrate.[/yellow]")
232
+ return
233
+
234
+ lock = load_lock()
235
+
236
+ for repo, skill_names in repos.items():
237
+ console.print(f"Resolving [cyan]{repo}[/cyan]...")
238
+ try:
239
+ source = get_repo_source(repo)
240
+ except Exception as e:
241
+ console.print(f"[red]Error fetching source info for {repo}:[/red] {e}")
242
+ continue
243
+
244
+ repo_data = lock["repos"].get(repo, {"skills": []})
245
+ existing = set(repo_data.get("skills", []))
246
+ for skill in skill_names:
247
+ if skill not in existing:
248
+ repo_data.setdefault("skills", []).append(skill)
249
+
250
+ repo_data = apply_source(repo_data, source)
251
+ lock["repos"][repo] = repo_data
252
+ console.print(f" [green]Migrated {len(skill_names)} skill(s) at {format_source_label(source)}[/green]")
253
+
254
+ ensure_dirs()
255
+ save_lock(lock)
256
+ console.print(f"[green]Migration complete! Lock file saved to {settings.lock_file}[/green]")
257
+
258
+
259
+ if __name__ == "__main__":
260
+ app()
skillup/github.py ADDED
@@ -0,0 +1,89 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ from typing import Optional
5
+
6
+ import requests
7
+ from rich.console import Console
8
+
9
+ from .settings import RepoSource, settings
10
+
11
+ console = Console()
12
+
13
+
14
+ def get_github_token() -> Optional[str]:
15
+ token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
16
+ if token:
17
+ return token
18
+
19
+ gh_path = shutil.which("gh")
20
+ if gh_path:
21
+ try:
22
+ result = subprocess.run(
23
+ [gh_path, "auth", "token"],
24
+ capture_output=True,
25
+ text=True,
26
+ check=True,
27
+ )
28
+ token = result.stdout.strip()
29
+ if token:
30
+ return token
31
+ except (subprocess.CalledProcessError, FileNotFoundError):
32
+ pass
33
+
34
+ console.print("[yellow]Warning: GitHub authentication not found. API rate limits may apply.[/yellow]")
35
+ return None
36
+
37
+
38
+ def get_github_headers() -> dict[str, str]:
39
+ headers: dict[str, str] = {}
40
+ token = get_github_token()
41
+ if token:
42
+ headers["Authorization"] = f"token {token}"
43
+ return headers
44
+
45
+
46
+ def get_latest_release(repo: str) -> tuple[str, str]:
47
+ url = f"https://api.github.com/repos/{repo}/releases/latest"
48
+ response = requests.get(url, headers=get_github_headers())
49
+ response.raise_for_status()
50
+ data = response.json()
51
+ return data["tag_name"], data["zipball_url"]
52
+
53
+
54
+ def get_commit_sha(repo: str, ref: str) -> str:
55
+ url = f"https://api.github.com/repos/{repo}/commits/{ref}"
56
+ response = requests.get(url, headers=get_github_headers())
57
+ response.raise_for_status()
58
+ data = response.json()
59
+ return data["sha"]
60
+
61
+
62
+ def get_repo_source(repo: str, branch: Optional[str] = None) -> RepoSource:
63
+ if branch:
64
+ commit = get_commit_sha(repo, branch)
65
+ return RepoSource(
66
+ kind="branch",
67
+ ref=branch,
68
+ commit=commit,
69
+ zip_url=f"https://api.github.com/repos/{repo}/zipball/{commit}",
70
+ )
71
+
72
+ try:
73
+ tag, zip_url = get_latest_release(repo)
74
+ return RepoSource(
75
+ kind="release",
76
+ ref=tag,
77
+ commit=get_commit_sha(repo, tag),
78
+ zip_url=zip_url,
79
+ )
80
+ except requests.HTTPError as exc:
81
+ if exc.response is not None and exc.response.status_code == 404:
82
+ commit = get_commit_sha(repo, "main")
83
+ return RepoSource(
84
+ kind="branch",
85
+ ref="main",
86
+ commit=commit,
87
+ zip_url=f"https://api.github.com/repos/{repo}/zipball/{commit}",
88
+ )
89
+ raise
skillup/install.py ADDED
@@ -0,0 +1,82 @@
1
+ import shutil
2
+ import zipfile
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ import requests
7
+ from rich.progress import Progress, SpinnerColumn, TextColumn
8
+
9
+ from .github import get_github_headers
10
+ from .settings import settings
11
+
12
+
13
+ def ensure_dirs() -> None:
14
+ settings.skills_dir_agents.mkdir(parents=True, exist_ok=True)
15
+ settings.skills_dir_claude.mkdir(parents=True, exist_ok=True)
16
+ settings.cache_dir.mkdir(parents=True, exist_ok=True)
17
+
18
+
19
+ def download_release(repo: str, version: str, url: str) -> Path:
20
+ cache_path = settings.cache_dir / f"{repo.replace('/', '_')}_{version}.zip"
21
+
22
+ if cache_path.exists():
23
+ return cache_path
24
+
25
+ with Progress(
26
+ SpinnerColumn(),
27
+ TextColumn("[progress.description]{task.description}"),
28
+ transient=True,
29
+ ) as progress:
30
+ progress.add_task(description=f"Downloading {repo} {version}...", total=None)
31
+
32
+ response = requests.get(url, headers=get_github_headers(), stream=True)
33
+ response.raise_for_status()
34
+ with open(cache_path, "wb") as f:
35
+ shutil.copyfileobj(response.raw, f)
36
+
37
+ return cache_path
38
+
39
+
40
+ def get_skills_in_zip(zip_path: Path) -> List[str]:
41
+ skills = set()
42
+ with zipfile.ZipFile(zip_path, "r") as z:
43
+ for name in z.namelist():
44
+ parts = Path(name).parts
45
+ if len(parts) >= 4 and parts[1] == "skills" and parts[-1].upper() == "SKILL.MD":
46
+ skills.add(parts[2])
47
+ return sorted(list(skills))
48
+
49
+
50
+ def install_skill(skill_name: str, zip_path: Path) -> None:
51
+ with zipfile.ZipFile(zip_path, "r") as z:
52
+ skill_prefix = ""
53
+ for name in z.namelist():
54
+ parts = Path(name).parts
55
+ if len(parts) >= 3 and parts[1] == "skills" and parts[2] == skill_name:
56
+ skill_prefix = "/".join(parts[:3]) + "/"
57
+ break
58
+
59
+ if not skill_prefix:
60
+ return
61
+
62
+ skill_files = [f for f in z.namelist() if f.startswith(skill_prefix)]
63
+
64
+ for target_dir in [settings.skills_dir_agents, settings.skills_dir_claude]:
65
+ dest = target_dir / skill_name
66
+ if dest.exists():
67
+ shutil.rmtree(dest)
68
+ dest.mkdir(parents=True, exist_ok=True)
69
+
70
+ for file_info in skill_files:
71
+ rel_path_str = file_info[len(skill_prefix):]
72
+ if not rel_path_str:
73
+ continue
74
+
75
+ target_file = dest / rel_path_str
76
+
77
+ if file_info.endswith("/"):
78
+ target_file.mkdir(parents=True, exist_ok=True)
79
+ else:
80
+ target_file.parent.mkdir(parents=True, exist_ok=True)
81
+ with z.open(file_info) as source, open(target_file, "wb") as target:
82
+ shutil.copyfileobj(source, target)
skillup/lock.py ADDED
@@ -0,0 +1,84 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from .settings import RepoSource, settings
5
+
6
+
7
+ def load_lock() -> dict:
8
+ if settings.lock_file.exists():
9
+ try:
10
+ data = json.loads(settings.lock_file.read_text())
11
+ if "repos" not in data:
12
+ return {"repos": {}}
13
+ return data
14
+ except Exception:
15
+ return {"repos": {}}
16
+ return {"repos": {}}
17
+
18
+
19
+ def save_lock(data: dict) -> None:
20
+ if not data.get("repos"):
21
+ if settings.lock_file.exists():
22
+ settings.lock_file.unlink()
23
+ return
24
+
25
+ settings.lock_file.parent.mkdir(parents=True, exist_ok=True)
26
+ settings.lock_file.write_text(json.dumps(data, indent=2))
27
+
28
+
29
+ def normalize_repo_data(repo_data: dict[str, Any]) -> dict[str, Any]:
30
+ skills = list(repo_data.get("skills", []))
31
+ normalized: dict[str, Any] = {"skills": skills}
32
+
33
+ if repo_data.get("source") in {"release", "branch"}:
34
+ normalized["source"] = repo_data["source"]
35
+ normalized["ref"] = repo_data.get("ref") or repo_data.get("tag") or repo_data.get("branch")
36
+ if repo_data.get("tag"):
37
+ normalized["tag"] = repo_data["tag"]
38
+ if repo_data.get("branch"):
39
+ normalized["branch"] = repo_data["branch"]
40
+ if repo_data.get("commit"):
41
+ normalized["commit"] = repo_data["commit"]
42
+ return normalized
43
+
44
+ if repo_data.get("branch"):
45
+ normalized["source"] = "branch"
46
+ normalized["ref"] = repo_data["branch"]
47
+ normalized["branch"] = repo_data["branch"]
48
+ elif repo_data.get("tag"):
49
+ normalized["source"] = "release"
50
+ normalized["ref"] = repo_data["tag"]
51
+ normalized["tag"] = repo_data["tag"]
52
+
53
+ if repo_data.get("commit"):
54
+ normalized["commit"] = repo_data["commit"]
55
+
56
+ return normalized
57
+
58
+
59
+ def apply_source(repo_data: dict[str, Any], source: RepoSource) -> dict[str, Any]:
60
+ repo_data["source"] = source.kind
61
+ repo_data["ref"] = source.ref
62
+ repo_data["commit"] = source.commit
63
+
64
+ if source.kind == "release":
65
+ repo_data["tag"] = source.ref
66
+ repo_data.pop("branch", None)
67
+ else:
68
+ repo_data["branch"] = source.ref
69
+ repo_data.pop("tag", None)
70
+
71
+ return repo_data
72
+
73
+
74
+ def get_sync_source(repo: str, repo_data: dict[str, Any]) -> RepoSource:
75
+ normalized = normalize_repo_data(repo_data)
76
+ ref = normalized.get("ref") or normalized.get("tag") or normalized.get("branch") or "main"
77
+ commit = normalized.get("commit")
78
+ zip_ref = commit or ref
79
+ return RepoSource(
80
+ kind=normalized.get("source", "release"),
81
+ ref=ref,
82
+ commit=commit,
83
+ zip_url=f"https://api.github.com/repos/{repo}/zipball/{zip_ref}",
84
+ )
skillup/settings.py ADDED
@@ -0,0 +1,58 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class RepoSource:
9
+ kind: str
10
+ ref: str
11
+ zip_url: str
12
+ commit: Optional[str] = None
13
+
14
+ @property
15
+ def cache_key(self) -> str:
16
+ return self.commit or self.ref
17
+
18
+
19
+ def format_source_label(source: RepoSource) -> str:
20
+ if source.kind == "release" or not source.commit:
21
+ return source.ref
22
+ short_commit = source.commit[: min(len(source.commit), 7)]
23
+ return f"{source.ref} ({short_commit})"
24
+
25
+
26
+ @dataclass
27
+ class Settings:
28
+ is_global: bool = False
29
+
30
+ @property
31
+ def base_dir(self) -> Path:
32
+ return Path.home() if self.is_global else Path.cwd()
33
+
34
+ @property
35
+ def agents_dir(self) -> Path:
36
+ return self.base_dir / ".agents"
37
+
38
+ @property
39
+ def skills_dir_agents(self) -> Path:
40
+ return self.agents_dir / "skills"
41
+
42
+ @property
43
+ def skills_dir_claude(self) -> Path:
44
+ return self.base_dir / ".claude" / "skills"
45
+
46
+ @property
47
+ def cache_dir(self) -> Path:
48
+ env_cache = os.getenv("SKILLUP_CACHE_DIR")
49
+ if env_cache:
50
+ return Path(env_cache)
51
+ return Path(os.getenv("TEMP", "/tmp")) / "skillup_cache"
52
+
53
+ @property
54
+ def lock_file(self) -> Path:
55
+ return self.agents_dir / "skills.lock.json"
56
+
57
+
58
+ settings = Settings()
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: skillup
3
+ Version: 0.3.2
4
+ Summary: A minimal CLI to manage agent skills from GitHub releases.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: questionary
8
+ Requires-Dist: requests
9
+ Requires-Dist: rich
10
+ Requires-Dist: typer
11
+ Description-Content-Type: text/markdown
12
+
13
+ # skillup
14
+
15
+ A minimal, user-friendly Python CLI to manage agent skills from GitHub releases or branches. It installs skills to both `~/.agents/skills` and `~/.claude/skills` for seamless integration across platforms.
16
+
17
+ ## Features
18
+
19
+ - **Interactive Installation:** Select specific skills to add from any GitHub repository.
20
+ - **Multi-Repo Support:** Manage skills from multiple repositories independently.
21
+ - **Lock File State:** Tracks installed sources (release tags or branches), pinned commit SHAs, and skills in `~/.agents/skills.lock.json` for reproducibility.
22
+ - **Automated Updates:** Easily upgrade all or specific repositories to their latest GitHub release or tracked branch head.
23
+ - **Smart Caching:** Downloads are cached in a temporary directory (`TEMP` or `/tmp`) to avoid redundant network usage. Can be overridden with `SKILLUP_CACHE_DIR`.
24
+ - **GitHub CLI Integration:** Uses the `gh` tool for fast downloads if available, with a reliable `requests` fallback.
25
+
26
+ ## Installation
27
+
28
+ Install using `pip` or `uv`:
29
+
30
+ ```bash
31
+ pip install skillup
32
+ # or
33
+ uv tool install skillup
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### 1. Add Skills
39
+ Interactively select skills to add from a GitHub repository's latest release:
40
+
41
+ ```bash
42
+ skillup add google/gemini-cli-skills
43
+ ```
44
+
45
+ If a repository has no releases, the CLI automatically falls back to the `main` branch. You can also install directly from a branch:
46
+
47
+ ```bash
48
+ skillup add anthropics/skills --branch main --skill pdf
49
+ ```
50
+
51
+ ### 2. Remove Skills
52
+ Interactively select installed skills to remove from your system:
53
+
54
+ ```bash
55
+ skillup remove
56
+ ```
57
+
58
+ ### 3. Update Skills
59
+ Update all installed skills to their latest tracked versions:
60
+
61
+ ```bash
62
+ skillup update
63
+ ```
64
+
65
+ Or update a specific repository:
66
+
67
+ ```bash
68
+ skillup update --repo google/gemini-cli-skills
69
+ ```
70
+
71
+ ### 4. Sync Skills
72
+ Install all skills as defined in the lock file using the pinned commit SHAs (useful for setting up a new machine):
73
+
74
+ ```bash
75
+ skillup sync
76
+ ```
77
+
78
+ ### 5. Migrate from NPX Skills CLI
79
+ If you already have a `skills-lock.json` in your repository root, you can import it into the skillup lock format. The latest release or branch commit is resolved from GitHub at migration time.
80
+
81
+ ```bash
82
+ skillup migrate
83
+ ```
84
+
85
+ A custom path can be provided if the file is elsewhere:
86
+
87
+ ```bash
88
+ skillup migrate path/to/skills-lock.json
89
+ ```
90
+
91
+ ## Skill Definition
92
+ A folder is recognized as a valid skill if it resides within a `skills/` directory at the repository root and contains a `SKILL.md` file.
93
+
94
+ ## Development
95
+
96
+ This project uses [uv](https://github.com/astral-sh/uv) for dependency management.
97
+
98
+ ```bash
99
+ # Install dependencies
100
+ uv sync
101
+
102
+ # Run locally
103
+ uv run skillup --help
104
+
105
+ # Run tests
106
+ uv run pytest
107
+
108
+ # Type check
109
+ uv run pyright skillup
110
+ ```
111
+
112
+ ## License
113
+ MIT
@@ -0,0 +1,11 @@
1
+ skillup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ skillup/cli.py,sha256=1yZo3yjGMA4caA1KIf5Ol1KxDYS7s71491KHQ2ESWTw,9488
3
+ skillup/github.py,sha256=bDzDQW1Zlyd03IjXpW5WFuQq2fMLLU0nextHB8OZqUU,2563
4
+ skillup/install.py,sha256=lFP7YlaqcqbZ7GxHlWaeAoHTNpeqCZ9nitTifvVTKnk,2788
5
+ skillup/lock.py,sha256=8n1lIyBLRrFl29BBitCHKeDqO58A-zEaIuj8qZllK1s,2691
6
+ skillup/settings.py,sha256=EauRCCtwLahMmLDFvoSL6dr37WFqZkyBLW9CXBPSkUw,1338
7
+ skillup-0.3.2.dist-info/METADATA,sha256=rxijKtk1UQQwiguvNuEM4TvSyxPS4QwP_nhuOHTe5F8,3020
8
+ skillup-0.3.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ skillup-0.3.2.dist-info/entry_points.txt,sha256=ffDpkmht1Ub1_074GYGsqOFJlYwG-yVJEOiQZDiUKz8,44
10
+ skillup-0.3.2.dist-info/licenses/LICENSE,sha256=PsVY3G6ck1WrSyjJQsqpK_kLC4LBLXg40u-SRnrRNcE,1057
11
+ skillup-0.3.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skillup = skillup.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.