skillup 0.6.0__tar.gz → 0.7.1__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.
- {skillup-0.6.0 → skillup-0.7.1}/PKG-INFO +1 -1
- {skillup-0.6.0 → skillup-0.7.1}/pyproject.toml +1 -1
- {skillup-0.6.0 → skillup-0.7.1}/skillup/cli.py +98 -11
- skillup-0.7.1/skillup/local.py +56 -0
- {skillup-0.6.0 → skillup-0.7.1}/.github/workflows/ci.yml +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/.github/workflows/docs.yml +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/.github/workflows/publish.yml +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/.gitignore +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/.python-version +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/LICENSE +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/README.md +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/assets/logo-wordmark.svg +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/assets/logo.svg +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/assets/extra.css +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/assets/logo.svg +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/changelog.md +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/commands.md +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/development.md +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/index.md +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/lock-file.md +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/quickstart.md +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/docs/skill-definition.md +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/skillup/__init__.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/skillup/_tree_ui.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/skillup/azdevops.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/skillup/github.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/skillup/http.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/skillup/install.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/skillup/lock.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/skillup/settings.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/tests/test_azdevops.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/tests/test_cli.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/tests/test_e2e.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/tests/test_github_auth.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/tests/test_install.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/tests/test_migrate.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/tests/test_sync.py +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/uv.lock +0 -0
- {skillup-0.6.0 → skillup-0.7.1}/zensical.toml +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import shutil
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any, List, Optional
|
|
4
|
+
from typing import Any, List, Literal, Optional
|
|
5
5
|
from urllib.parse import urlparse
|
|
6
6
|
|
|
7
7
|
import questionary
|
|
@@ -11,6 +11,7 @@ from rich.console import Console
|
|
|
11
11
|
|
|
12
12
|
from .github import get_repo_source, parse_github_repo
|
|
13
13
|
from .install import download_release, ensure_dirs, get_skill_paths, install_skill
|
|
14
|
+
from .local import get_skill_paths_local, install_skill_local, is_local_path, resolve_local_path
|
|
14
15
|
from .lock import apply_source, get_sync_source, load_lock, normalize_repo_data, save_lock
|
|
15
16
|
from .settings import RepoSource, format_source_label, settings
|
|
16
17
|
from ._tree_ui import tree_checkbox
|
|
@@ -19,8 +20,10 @@ app = typer.Typer(help="Minimal CLI to manage agent skills from GitHub releases
|
|
|
19
20
|
console = Console()
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def _detect_provider(repo_or_url: str) ->
|
|
23
|
-
"""Return 'azdevops'
|
|
23
|
+
def _detect_provider(repo_or_url: str) -> Literal["local", "azdevops", "github"]:
|
|
24
|
+
"""Return 'local', 'azdevops', or 'github' based on the input format."""
|
|
25
|
+
if is_local_path(repo_or_url):
|
|
26
|
+
return "local"
|
|
24
27
|
parsed = urlparse(repo_or_url)
|
|
25
28
|
if not parsed.scheme:
|
|
26
29
|
return "github"
|
|
@@ -106,20 +109,26 @@ def add(
|
|
|
106
109
|
repo: str = typer.Argument(
|
|
107
110
|
...,
|
|
108
111
|
help=(
|
|
109
|
-
"GitHub 'owner/repo', a full GitHub URL,
|
|
110
|
-
"(https://dev.azure.com/… or https://org.visualstudio.com/…)
|
|
111
|
-
"
|
|
112
|
+
"GitHub 'owner/repo', a full GitHub URL, a full Azure DevOps URL "
|
|
113
|
+
"(https://dev.azure.com/… or https://org.visualstudio.com/…), "
|
|
114
|
+
"or a local path (/path/to/repo, C:\\path\\to\\repo, or file:///path)."
|
|
112
115
|
),
|
|
113
116
|
),
|
|
114
117
|
skills: Optional[List[str]] = typer.Option(None, "--skill", "-s", help="Specific skill(s) to add (non-interactive)"),
|
|
115
118
|
branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Branch to install from instead of the latest release"),
|
|
116
119
|
search: Optional[str] = typer.Option(None, "--search", "-f", help="Filter skills shown in the tree (matches skill name or path, case-insensitive)"),
|
|
120
|
+
all_skills: bool = typer.Option(False, "--all-skills", help="Install all available skills non-interactively"),
|
|
117
121
|
):
|
|
118
|
-
"""Add skills from a GitHub
|
|
119
|
-
lock_key, short_ref = _parse_repo_input(repo)
|
|
122
|
+
"""Add skills from a GitHub repository, Azure DevOps repository, or local path."""
|
|
120
123
|
lock = load_lock()
|
|
121
124
|
ensure_dirs()
|
|
122
125
|
|
|
126
|
+
if is_local_path(repo):
|
|
127
|
+
_add_local(repo, lock, skills, search)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
lock_key, short_ref = _parse_repo_input(repo)
|
|
131
|
+
|
|
123
132
|
try:
|
|
124
133
|
source = _resolve_source(lock_key, short_ref, branch)
|
|
125
134
|
except Exception as e:
|
|
@@ -129,7 +138,6 @@ def add(
|
|
|
129
138
|
try:
|
|
130
139
|
zip_path = _download(lock_key, source)
|
|
131
140
|
except requests.HTTPError as e:
|
|
132
|
-
|
|
133
141
|
console.print(f"[red]HTTP error downloading {short_ref}:[/red] {e}")
|
|
134
142
|
print(e.response.text)
|
|
135
143
|
raise typer.Exit(1)
|
|
@@ -142,7 +150,12 @@ def add(
|
|
|
142
150
|
repo_data = normalize_repo_data(lock["repos"].get(lock_key, {"skills": []}))
|
|
143
151
|
installed_skills = set(repo_data["skills"])
|
|
144
152
|
|
|
145
|
-
if
|
|
153
|
+
if all_skills:
|
|
154
|
+
selected = [s for s in available_skills if s not in installed_skills]
|
|
155
|
+
if not selected:
|
|
156
|
+
console.print(f"[yellow]No new skills available to add from {short_ref}.[/yellow]")
|
|
157
|
+
return
|
|
158
|
+
elif skills:
|
|
146
159
|
selected = [s for s in skills if s in available_skills and s not in installed_skills]
|
|
147
160
|
invalid = [s for s in skills if s not in available_skills]
|
|
148
161
|
already_installed = [s for s in skills if s in installed_skills]
|
|
@@ -186,6 +199,64 @@ def add(
|
|
|
186
199
|
console.print(f"[green]Skills from {short_ref} installed successfully![/green]")
|
|
187
200
|
|
|
188
201
|
|
|
202
|
+
def _add_local(repo: str, lock: dict, skills: Optional[List[str]], search: Optional[str]) -> None:
|
|
203
|
+
local_path = resolve_local_path(repo)
|
|
204
|
+
if not local_path.is_dir():
|
|
205
|
+
console.print(f"[red]Local path does not exist or is not a directory: {local_path}[/red]")
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
|
|
208
|
+
lock_key = f"local:{local_path}"
|
|
209
|
+
display = str(local_path)
|
|
210
|
+
skill_paths = get_skill_paths_local(local_path)
|
|
211
|
+
available_skills = sorted(skill_paths.keys())
|
|
212
|
+
repo_data = lock["repos"].get(lock_key, {"skills": [], "source": "local", "path": str(local_path)})
|
|
213
|
+
installed_skills = set(repo_data.get("skills", []))
|
|
214
|
+
|
|
215
|
+
if skills:
|
|
216
|
+
selected = [s for s in skills if s in available_skills and s not in installed_skills]
|
|
217
|
+
invalid = [s for s in skills if s not in available_skills]
|
|
218
|
+
already_installed = [s for s in skills if s in installed_skills]
|
|
219
|
+
|
|
220
|
+
if invalid:
|
|
221
|
+
console.print(f"[yellow]Warning: Skills not found in {display}: {', '.join(invalid)}[/yellow]")
|
|
222
|
+
if already_installed:
|
|
223
|
+
console.print(f"[blue]Note: Skills already installed from {display}: {', '.join(already_installed)}[/blue]")
|
|
224
|
+
else:
|
|
225
|
+
available_paths = {k: v for k, v in skill_paths.items() if k not in installed_skills}
|
|
226
|
+
|
|
227
|
+
if search:
|
|
228
|
+
needle = search.casefold()
|
|
229
|
+
available_paths = {k: v for k, v in available_paths.items() if needle in k.casefold() or needle in v.casefold()}
|
|
230
|
+
if not available_paths:
|
|
231
|
+
console.print(f"[yellow]No skills matching '{search}' found in {display}.[/yellow]")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
if not available_paths:
|
|
235
|
+
console.print(f"[yellow]No new skills available to add from {display}.[/yellow]")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
prompt = f"Select skills to add from {display}:"
|
|
239
|
+
if search:
|
|
240
|
+
prompt += f" [filter: '{search}']"
|
|
241
|
+
selected = tree_checkbox(prompt, available_paths)
|
|
242
|
+
|
|
243
|
+
if not selected:
|
|
244
|
+
console.print("No skills selected or available for installation.")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
for skill in selected:
|
|
248
|
+
console.print(f"Installing [cyan]{skill}[/cyan]...")
|
|
249
|
+
install_skill_local(skill, local_path)
|
|
250
|
+
if skill not in repo_data["skills"]:
|
|
251
|
+
repo_data["skills"].append(skill)
|
|
252
|
+
|
|
253
|
+
repo_data["source"] = "local"
|
|
254
|
+
repo_data["path"] = str(local_path)
|
|
255
|
+
lock["repos"][lock_key] = repo_data
|
|
256
|
+
save_lock(lock)
|
|
257
|
+
console.print(f"[green]Skills from {display} installed successfully![/green]")
|
|
258
|
+
|
|
259
|
+
|
|
189
260
|
@app.command()
|
|
190
261
|
def remove():
|
|
191
262
|
"""Interactively remove installed skills across all repositories."""
|
|
@@ -241,6 +312,10 @@ def update(repo: Optional[str] = typer.Option(None, "--repo", help="Specific rep
|
|
|
241
312
|
console.print(f"[red]Repository {r} is not tracked.[/red]")
|
|
242
313
|
continue
|
|
243
314
|
|
|
315
|
+
if r.startswith("local:"):
|
|
316
|
+
console.print(f"[dim]Skipping local path {r[6:]} (no remote to update from).[/dim]")
|
|
317
|
+
continue
|
|
318
|
+
|
|
244
319
|
repo_data = normalize_repo_data(lock["repos"][r])
|
|
245
320
|
short_ref = r[5:] if r.startswith("azdo:") else r
|
|
246
321
|
console.print(f"Checking updates for [cyan]{short_ref}[/cyan]...")
|
|
@@ -284,9 +359,21 @@ def sync():
|
|
|
284
359
|
return
|
|
285
360
|
|
|
286
361
|
for repo, repo_data in lock["repos"].items():
|
|
362
|
+
skills = repo_data["skills"]
|
|
363
|
+
|
|
364
|
+
if repo.startswith("local:"):
|
|
365
|
+
local_path = Path(repo_data.get("path", repo[6:]))
|
|
366
|
+
console.print(f"Syncing [cyan]{local_path}[/cyan] (local)...")
|
|
367
|
+
if not local_path.is_dir():
|
|
368
|
+
console.print(f"[red] Local path not found: {local_path}[/red]")
|
|
369
|
+
continue
|
|
370
|
+
for skill in skills:
|
|
371
|
+
console.print(f" Installing [cyan]{skill}[/cyan]...")
|
|
372
|
+
install_skill_local(skill, local_path)
|
|
373
|
+
continue
|
|
374
|
+
|
|
287
375
|
source = get_sync_source(repo, repo_data)
|
|
288
376
|
short_ref = repo[5:] if repo.startswith("azdo:") else repo
|
|
289
|
-
skills = repo_data["skills"]
|
|
290
377
|
console.print(f"Syncing [cyan]{short_ref}[/cyan] at [green]{format_source_label(source)}[/green]...")
|
|
291
378
|
|
|
292
379
|
try:
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_local_path(s: str) -> bool:
|
|
8
|
+
"""True for paths starting with /, a Windows drive letter, or file:// URLs."""
|
|
9
|
+
if s.startswith("/"):
|
|
10
|
+
return True
|
|
11
|
+
if re.match(r"^[a-zA-Z]:[/\\]", s):
|
|
12
|
+
return True
|
|
13
|
+
if urlparse(s).scheme == "file":
|
|
14
|
+
return True
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_local_path(s: str) -> Path:
|
|
19
|
+
"""Convert a local path input (including file:// URLs) to an absolute Path."""
|
|
20
|
+
parsed = urlparse(s)
|
|
21
|
+
if parsed.scheme == "file":
|
|
22
|
+
from urllib.request import url2pathname
|
|
23
|
+
return Path(url2pathname(parsed.path)).resolve()
|
|
24
|
+
return Path(s).resolve()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_skill_paths_local(local_path: Path) -> dict[str, str]:
|
|
28
|
+
"""Return {skill_name: repo-relative path} by scanning local_path for SKILL.md files."""
|
|
29
|
+
skills: dict[str, str] = {}
|
|
30
|
+
for p in local_path.rglob("*"):
|
|
31
|
+
if p.name.upper() == "SKILL.MD":
|
|
32
|
+
skill_name = p.parent.name
|
|
33
|
+
rel = p.parent.relative_to(local_path)
|
|
34
|
+
path = str(rel).replace("\\", "/")
|
|
35
|
+
skills[skill_name] = path
|
|
36
|
+
return skills
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def install_skill_local(skill_name: str, local_path: Path) -> None:
|
|
40
|
+
"""Copy a skill directory from local_path into all configured target dirs."""
|
|
41
|
+
from .settings import settings
|
|
42
|
+
|
|
43
|
+
skill_dir: Path | None = None
|
|
44
|
+
for p in local_path.rglob("*"):
|
|
45
|
+
if p.name.upper() == "SKILL.MD" and p.parent.name == skill_name:
|
|
46
|
+
skill_dir = p.parent
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if not skill_dir:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
for target_dir in settings.target_dirs:
|
|
53
|
+
dest = target_dir / skill_name
|
|
54
|
+
if dest.exists():
|
|
55
|
+
shutil.rmtree(dest)
|
|
56
|
+
shutil.copytree(skill_dir, dest)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|