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.
Files changed (39) hide show
  1. {skillup-0.6.0 → skillup-0.7.1}/PKG-INFO +1 -1
  2. {skillup-0.6.0 → skillup-0.7.1}/pyproject.toml +1 -1
  3. {skillup-0.6.0 → skillup-0.7.1}/skillup/cli.py +98 -11
  4. skillup-0.7.1/skillup/local.py +56 -0
  5. {skillup-0.6.0 → skillup-0.7.1}/.github/workflows/ci.yml +0 -0
  6. {skillup-0.6.0 → skillup-0.7.1}/.github/workflows/docs.yml +0 -0
  7. {skillup-0.6.0 → skillup-0.7.1}/.github/workflows/publish.yml +0 -0
  8. {skillup-0.6.0 → skillup-0.7.1}/.gitignore +0 -0
  9. {skillup-0.6.0 → skillup-0.7.1}/.python-version +0 -0
  10. {skillup-0.6.0 → skillup-0.7.1}/LICENSE +0 -0
  11. {skillup-0.6.0 → skillup-0.7.1}/README.md +0 -0
  12. {skillup-0.6.0 → skillup-0.7.1}/assets/logo-wordmark.svg +0 -0
  13. {skillup-0.6.0 → skillup-0.7.1}/assets/logo.svg +0 -0
  14. {skillup-0.6.0 → skillup-0.7.1}/docs/assets/extra.css +0 -0
  15. {skillup-0.6.0 → skillup-0.7.1}/docs/assets/logo.svg +0 -0
  16. {skillup-0.6.0 → skillup-0.7.1}/docs/changelog.md +0 -0
  17. {skillup-0.6.0 → skillup-0.7.1}/docs/commands.md +0 -0
  18. {skillup-0.6.0 → skillup-0.7.1}/docs/development.md +0 -0
  19. {skillup-0.6.0 → skillup-0.7.1}/docs/index.md +0 -0
  20. {skillup-0.6.0 → skillup-0.7.1}/docs/lock-file.md +0 -0
  21. {skillup-0.6.0 → skillup-0.7.1}/docs/quickstart.md +0 -0
  22. {skillup-0.6.0 → skillup-0.7.1}/docs/skill-definition.md +0 -0
  23. {skillup-0.6.0 → skillup-0.7.1}/skillup/__init__.py +0 -0
  24. {skillup-0.6.0 → skillup-0.7.1}/skillup/_tree_ui.py +0 -0
  25. {skillup-0.6.0 → skillup-0.7.1}/skillup/azdevops.py +0 -0
  26. {skillup-0.6.0 → skillup-0.7.1}/skillup/github.py +0 -0
  27. {skillup-0.6.0 → skillup-0.7.1}/skillup/http.py +0 -0
  28. {skillup-0.6.0 → skillup-0.7.1}/skillup/install.py +0 -0
  29. {skillup-0.6.0 → skillup-0.7.1}/skillup/lock.py +0 -0
  30. {skillup-0.6.0 → skillup-0.7.1}/skillup/settings.py +0 -0
  31. {skillup-0.6.0 → skillup-0.7.1}/tests/test_azdevops.py +0 -0
  32. {skillup-0.6.0 → skillup-0.7.1}/tests/test_cli.py +0 -0
  33. {skillup-0.6.0 → skillup-0.7.1}/tests/test_e2e.py +0 -0
  34. {skillup-0.6.0 → skillup-0.7.1}/tests/test_github_auth.py +0 -0
  35. {skillup-0.6.0 → skillup-0.7.1}/tests/test_install.py +0 -0
  36. {skillup-0.6.0 → skillup-0.7.1}/tests/test_migrate.py +0 -0
  37. {skillup-0.6.0 → skillup-0.7.1}/tests/test_sync.py +0 -0
  38. {skillup-0.6.0 → skillup-0.7.1}/uv.lock +0 -0
  39. {skillup-0.6.0 → skillup-0.7.1}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillup
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: A minimal CLI to manage agent skills from GitHub releases.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "skillup"
7
- version = "0.6.0"
7
+ version = "0.7.1"
8
8
  description = "A minimal CLI to manage agent skills from GitHub releases."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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) -> str:
23
- """Return 'azdevops' if the input is a recognised Azure DevOps URL, else 'github'."""
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, or a full Azure DevOps URL "
110
- "(https://dev.azure.com/… or https://org.visualstudio.com/…). "
111
- "Provider is auto-detected from the URL domain; bare 'owner/repo' defaults to GitHub."
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 or Azure DevOps repository."""
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 skills:
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