skillup 0.3.2__tar.gz

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,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v3
16
+ with:
17
+ enable-cache: true
18
+ - name: Set up Python
19
+ run: uv python install
20
+ - name: Install dependencies
21
+ run: uv sync
22
+ - name: Type check
23
+ run: uv run pyright skillup
24
+ - name: Run tests
25
+ run: uv run pytest
@@ -0,0 +1,19 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write # Required for trusted publishing
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v3
16
+ - name: Build
17
+ run: uv build
18
+ - name: Publish to PyPI
19
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.11
skillup-0.3.2/LICENSE ADDED
@@ -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.
skillup-0.3.2/PKG-INFO ADDED
@@ -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,101 @@
1
+ # skillup
2
+
3
+ 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.
4
+
5
+ ## Features
6
+
7
+ - **Interactive Installation:** Select specific skills to add from any GitHub repository.
8
+ - **Multi-Repo Support:** Manage skills from multiple repositories independently.
9
+ - **Lock File State:** Tracks installed sources (release tags or branches), pinned commit SHAs, and skills in `~/.agents/skills.lock.json` for reproducibility.
10
+ - **Automated Updates:** Easily upgrade all or specific repositories to their latest GitHub release or tracked branch head.
11
+ - **Smart Caching:** Downloads are cached in a temporary directory (`TEMP` or `/tmp`) to avoid redundant network usage. Can be overridden with `SKILLUP_CACHE_DIR`.
12
+ - **GitHub CLI Integration:** Uses the `gh` tool for fast downloads if available, with a reliable `requests` fallback.
13
+
14
+ ## Installation
15
+
16
+ Install using `pip` or `uv`:
17
+
18
+ ```bash
19
+ pip install skillup
20
+ # or
21
+ uv tool install skillup
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### 1. Add Skills
27
+ Interactively select skills to add from a GitHub repository's latest release:
28
+
29
+ ```bash
30
+ skillup add google/gemini-cli-skills
31
+ ```
32
+
33
+ If a repository has no releases, the CLI automatically falls back to the `main` branch. You can also install directly from a branch:
34
+
35
+ ```bash
36
+ skillup add anthropics/skills --branch main --skill pdf
37
+ ```
38
+
39
+ ### 2. Remove Skills
40
+ Interactively select installed skills to remove from your system:
41
+
42
+ ```bash
43
+ skillup remove
44
+ ```
45
+
46
+ ### 3. Update Skills
47
+ Update all installed skills to their latest tracked versions:
48
+
49
+ ```bash
50
+ skillup update
51
+ ```
52
+
53
+ Or update a specific repository:
54
+
55
+ ```bash
56
+ skillup update --repo google/gemini-cli-skills
57
+ ```
58
+
59
+ ### 4. Sync Skills
60
+ Install all skills as defined in the lock file using the pinned commit SHAs (useful for setting up a new machine):
61
+
62
+ ```bash
63
+ skillup sync
64
+ ```
65
+
66
+ ### 5. Migrate from NPX Skills CLI
67
+ 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.
68
+
69
+ ```bash
70
+ skillup migrate
71
+ ```
72
+
73
+ A custom path can be provided if the file is elsewhere:
74
+
75
+ ```bash
76
+ skillup migrate path/to/skills-lock.json
77
+ ```
78
+
79
+ ## Skill Definition
80
+ 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.
81
+
82
+ ## Development
83
+
84
+ This project uses [uv](https://github.com/astral-sh/uv) for dependency management.
85
+
86
+ ```bash
87
+ # Install dependencies
88
+ uv sync
89
+
90
+ # Run locally
91
+ uv run skillup --help
92
+
93
+ # Run tests
94
+ uv run pytest
95
+
96
+ # Type check
97
+ uv run pyright skillup
98
+ ```
99
+
100
+ ## License
101
+ MIT
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "skillup"
7
+ version = "0.3.2"
8
+ description = "A minimal CLI to manage agent skills from GitHub releases."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "typer",
13
+ "rich",
14
+ "questionary",
15
+ "requests",
16
+ ]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pyright",
21
+ "pytest",
22
+ ]
23
+
24
+ [project.scripts]
25
+ skillup = "skillup.cli:app"
26
+
27
+ [tool.uv]
28
+ package = true
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["skillup"]
File without changes
@@ -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()
@@ -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