pluck-cli 0.2.1__tar.gz → 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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluck-cli
3
- Version: 0.2.1
3
+ Version: 0.3.2
4
4
  Summary: Pluck any git repo from any forge — auto-detect, auto-install, done!
5
5
  Author: pluck contributors
6
6
  License: MIT
@@ -175,7 +175,11 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
175
175
  | `stats` | Show statistics | `pluck stats` |
176
176
  | `doctor` | Check tool availability | `pluck doctor` |
177
177
  | `config [key] [val]` | View/set config | `pluck config install_dir ~/Apps` |
178
- | `search <query> [--forge <name>]` | Search repos (github\|gitlab\|codeberg\|bitbucket) | `pluck search python installer --forge gitlab` |
178
+ | `search <query> [--forge <name>] [--all] [--output <file>]` | Search repos across forges | `pluck search python installer --all --output results.txt` |
179
+ | `pin <name>` | Pin an app to prevent updates | `pluck pin myapp` |
180
+ | `unpin <name>` | Unpin an app | `pluck unpin myapp` |
181
+ | `self-update` | Update pluck itself | `pluck self-update` |
182
+ | `cache prune` | Clear download cache | `pluck cache prune` |
179
183
  | `export <file>` | Export registry | `pluck export ~/backup.json` |
180
184
  | `import <file>` | Import registry | `pluck import ~/backup.json` |
181
185
  | `completion <shell>` | Generate shell completion | `pluck completion bash` |
@@ -197,6 +201,8 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
197
201
  | `--no-color` | Disable colored output |
198
202
  | `--timeout <secs>` | Timeout for git clone in seconds |
199
203
  | `--retries <n>` | Number of retries for failed git clone |
204
+ | `--jobs <n>` | Number of parallel installs (default: 1) |
205
+ | `--release` | Install from pre-built release assets instead of cloning |
200
206
  | `--verbose` | Show detailed git clone output |
201
207
 
202
208
  ## 📥 Installation
@@ -150,7 +150,11 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
150
150
  | `stats` | Show statistics | `pluck stats` |
151
151
  | `doctor` | Check tool availability | `pluck doctor` |
152
152
  | `config [key] [val]` | View/set config | `pluck config install_dir ~/Apps` |
153
- | `search <query> [--forge <name>]` | Search repos (github\|gitlab\|codeberg\|bitbucket) | `pluck search python installer --forge gitlab` |
153
+ | `search <query> [--forge <name>] [--all] [--output <file>]` | Search repos across forges | `pluck search python installer --all --output results.txt` |
154
+ | `pin <name>` | Pin an app to prevent updates | `pluck pin myapp` |
155
+ | `unpin <name>` | Unpin an app | `pluck unpin myapp` |
156
+ | `self-update` | Update pluck itself | `pluck self-update` |
157
+ | `cache prune` | Clear download cache | `pluck cache prune` |
154
158
  | `export <file>` | Export registry | `pluck export ~/backup.json` |
155
159
  | `import <file>` | Import registry | `pluck import ~/backup.json` |
156
160
  | `completion <shell>` | Generate shell completion | `pluck completion bash` |
@@ -172,6 +176,8 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
172
176
  | `--no-color` | Disable colored output |
173
177
  | `--timeout <secs>` | Timeout for git clone in seconds |
174
178
  | `--retries <n>` | Number of retries for failed git clone |
179
+ | `--jobs <n>` | Number of parallel installs (default: 1) |
180
+ | `--release` | Install from pre-built release assets instead of cloning |
175
181
  | `--verbose` | Show detailed git clone output |
176
182
 
177
183
  ## 📥 Installation
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pluck-cli"
7
- version = "0.2.1"
7
+ version = "0.3.2"
8
8
  description = "Pluck any git repo from any forge — auto-detect, auto-install, done!"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -5,6 +5,7 @@ GitHub App Installer - Paste URL, Auto-Install, Done!
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import concurrent.futures
8
9
  import json
9
10
  import os
10
11
  import re
@@ -18,7 +19,7 @@ import urllib.request
18
19
  from datetime import datetime
19
20
  from pathlib import Path
20
21
 
21
- __version__ = "0.2.1"
22
+ __version__ = "0.3.2"
22
23
 
23
24
  # Configuration
24
25
  DEFAULT_INSTALL_DIR_MACOS = Path.home() / "Applications"
@@ -33,13 +34,14 @@ CONFIG_FILE = (
33
34
  Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "pluck" / "config.json"
34
35
  )
35
36
  _CONFIG_OLD_DIR = Path.home() / ".config" / "gh-install"
37
+ CACHE_DIR = Path.home() / ".cache" / "pluck"
36
38
  SHARED_PATHS = {
37
39
  Path.home() / "go" / "bin",
38
40
  Path.home() / "Applications",
39
41
  Path.home() / ".local" / "opt",
40
42
  Path.home() / "bin",
41
43
  }
42
- VALID_METHODS = {"script", "binary", "python", "node", "go", "rust", "make", "download"}
44
+ VALID_METHODS = {"script", "binary", "python", "node", "go", "rust", "make", "download", "release"}
43
45
  GIST_PATTERN = r"gist\.github\.com[:/]([^/]+)/([a-f0-9]+)"
44
46
  # GitLab personal snippet: gitlab.com/-/snippets/12345
45
47
  # GitLab project snippet: gitlab.com/owner/repo/-/snippets/12345
@@ -88,10 +90,15 @@ def print_usage():
88
90
  ("stats", "Show installation statistics"),
89
91
  ("doctor", "Check tool availability"),
90
92
  ("config [key] [value]", "View/set config"),
91
- ("search <query> [--forge <name>]", "Search repos (github|gitlab|codeberg|bitbucket)"),
93
+ ("search <query> [--forge <name>] [--all] [--output <file>]",
94
+ "Search repos (github|gitlab|codeberg|bitbucket)"),
92
95
  ("export <file>", "Export registry"),
93
96
  ("import <file>", "Import registry"),
94
97
  ("completion <shell>", "Generate shell completion"),
98
+ ("pin <name>", "Pin an app to prevent updates"),
99
+ ("unpin <name>", "Unpin an app"),
100
+ ("self-update", "Update pluck itself"),
101
+ ("cache <prune|path>", "Manage download cache"),
95
102
  ("version", "Show version"),
96
103
  ("help", "Show this help"),
97
104
  ]
@@ -107,6 +114,7 @@ def print_usage():
107
114
  ("--no-color", "Disable colored output"),
108
115
  ("--timeout <secs>", "Timeout for git clone in seconds"),
109
116
  ("--retries <n>", "Number of retries for failed git clone"),
117
+ ("--jobs <n>", "Number of parallel installs (default: 1)"),
110
118
  ("--verbose", "Show detailed git clone output"),
111
119
  ]
112
120
 
@@ -462,12 +470,15 @@ def install_python(repo_path, install_dir):
462
470
 
463
471
  try:
464
472
  app_dir = install_dir / repo_path.name
473
+ ignore = shutil.ignore_patterns(".git", "__pycache__", ".venv", "venv")
474
+ shutil.copytree(repo_path, app_dir, dirs_exist_ok=True, ignore=ignore)
475
+
465
476
  venv_path = app_dir / ".venv"
466
477
  venv_path.parent.mkdir(parents=True, exist_ok=True)
467
478
  subprocess.run([sys.executable, "-m", "venv", str(venv_path)], check=True)
468
479
 
469
480
  pip_path = venv_path / "bin" / "pip"
470
- subprocess.run([str(pip_path), "install", "-e", str(repo_path)], check=True)
481
+ subprocess.run([str(pip_path), "install", "-e", str(app_dir)], check=True)
471
482
 
472
483
  print_success(f"Installed to {app_dir}")
473
484
 
@@ -647,6 +658,14 @@ def download_and_install(
647
658
  else:
648
659
  install_dir = DEFAULT_INSTALL_DIR
649
660
 
661
+ # Check if this is a local path instead of a URL
662
+ local_candidate = Path(repo_url).expanduser()
663
+ if local_candidate.exists():
664
+ return _install_local_path(
665
+ repo_url, install_dir,
666
+ dry_run=dry_run, method_override=method_override,
667
+ )
668
+
650
669
  # Parse repository URL
651
670
  repo_info = parse_repo_url(repo_url)
652
671
  if not repo_info:
@@ -663,6 +682,25 @@ def download_and_install(
663
682
  print_error(f"Invalid repository name: {repo_info['repo']}")
664
683
  return None
665
684
 
685
+ # If release method requested, skip cloning and try release assets
686
+ if method_override == "release":
687
+ if dry_run:
688
+ print(f" [DRY RUN] Would install release assets for: {repo_info['owner']}/{repo_info['repo']}")
689
+ return install_dir / safe_name
690
+ install_dir.mkdir(parents=True, exist_ok=True)
691
+ print(" Attempting release asset install...")
692
+ installed_path = install_release_asset(repo_info, install_dir)
693
+ if installed_path:
694
+ register_app(repo_info["repo"], repo_url, installed_path, "release")
695
+ print()
696
+ print(f" {Colors.CYAN}Summary:{Colors.END}")
697
+ print(f" Name: {repo_info['repo']}")
698
+ print(" Method: release")
699
+ print(f" Location: {installed_path}")
700
+ print(f" Size: {_get_disk_size(installed_path)}")
701
+ return installed_path
702
+ print_warning("Release asset install failed, falling back to clone...")
703
+
666
704
  # Dry-run check before doing any I/O
667
705
  if dry_run:
668
706
  print(f" [DRY RUN] Would install to: {install_dir / safe_name}")
@@ -732,10 +770,11 @@ def download_and_install(
732
770
  }
733
771
 
734
772
  install_func = install_funcs.get(install_method, install_binary)
735
- installed_path = install_func(repo_path, install_dir)
736
-
737
- # Clean up temp directory
738
- shutil.rmtree(temp_dir)
773
+ try:
774
+ installed_path = install_func(repo_path, install_dir)
775
+ finally:
776
+ # Clean up temp directory
777
+ shutil.rmtree(temp_dir, ignore_errors=True)
739
778
 
740
779
  # Register the installation
741
780
  if installed_path:
@@ -773,6 +812,12 @@ def update_app(
773
812
  url = app_info["url"]
774
813
  old_path = Path(app_info["path"])
775
814
 
815
+ # Check if app is pinned
816
+ if app_info.get("pinned", False):
817
+ print_warning(f"{repo_name} is pinned — skipping update")
818
+ print(" Use 'pluck unpin' first, or --force to override")
819
+ return True
820
+
776
821
  print_header(f"Updating {repo_name}")
777
822
  print(f" Current: {app_info['installed_at']}")
778
823
  print(f" URL: {url}")
@@ -783,7 +828,12 @@ def update_app(
783
828
  return True
784
829
 
785
830
  # Remove old installation
786
- if old_path.exists() and old_path.resolve() not in SHARED_PATHS:
831
+ resolved_shared_paths = {p.resolve() for p in SHARED_PATHS}
832
+ if (
833
+ old_path.exists()
834
+ and old_path.resolve() not in resolved_shared_paths
835
+ and old_path.resolve() != Path.home().resolve()
836
+ ):
787
837
  if old_path.is_file():
788
838
  old_path.unlink()
789
839
  else:
@@ -829,11 +879,13 @@ def info_app(repo_name, json_output=False):
829
879
  app_info = registry["apps"][repo_name]
830
880
  install_path = Path(app_info["path"])
831
881
 
882
+ pinned = app_info.get("pinned", False)
832
883
  if json_output:
833
884
  data = {
834
885
  "name": repo_name,
835
886
  "url": app_info["url"],
836
887
  "method": app_info["method"],
888
+ "pinned": pinned,
837
889
  "path": app_info["path"],
838
890
  "installed_at": app_info["installed_at"],
839
891
  "size": _get_disk_size(install_path),
@@ -843,11 +895,12 @@ def info_app(repo_name, json_output=False):
843
895
  return True
844
896
 
845
897
  print_header(f"App Info: {repo_name}")
846
- labels = ["URL", "Method", "Path", "Installed", "Size", "Exists"]
898
+ labels = ["URL", "Method", "Path", "Pinned", "Installed", "Size", "Exists"]
847
899
  values = [
848
900
  app_info["url"],
849
901
  app_info["method"],
850
902
  app_info["path"],
903
+ "Yes" if pinned else "No",
851
904
  app_info["installed_at"],
852
905
  _get_disk_size(install_path),
853
906
  "Yes" if install_path.exists() else "No (files may have been moved)",
@@ -879,7 +932,6 @@ def doctor(json_output=False):
879
932
  if not found and exe == "python3":
880
933
  found = bool(shutil.which("python"))
881
934
  results.append({"tool": tool, "required": req, "found": found, "purpose": purpose})
882
- results.append({"tool": tool, "required": req, "found": found, "purpose": purpose})
883
935
  if not found and req == "Required":
884
936
  all_ok = False
885
937
 
@@ -943,8 +995,10 @@ def _search_print_result(index, name, desc, stars, lang, url, star_char="★"):
943
995
  print()
944
996
 
945
997
 
946
- def search_github(query, limit=10):
947
- """Search repositories using the GitHub API."""
998
+ def search_github(query, limit=10, results=None):
999
+ """Search repositories using the GitHub API.
1000
+ If results (list) is provided, appends result dicts instead of printing.
1001
+ """
948
1002
  print(f" Searching GitHub for '{query}'...")
949
1003
  url = f"https://api.github.com/search/repositories?q={urllib.parse.quote(query)}&sort=stars&order=desc&per_page={limit}"
950
1004
  try:
@@ -960,19 +1014,29 @@ def search_github(query, limit=10):
960
1014
  print_warning("No results found")
961
1015
  return
962
1016
 
963
- print_header(f"GitHub Results '{query}' ({len(items)} found)")
964
- for i, repo in enumerate(items, 1):
965
- _search_print_result(
966
- i,
967
- repo["full_name"],
968
- repo.get("description") or "No description",
969
- repo["stargazers_count"],
970
- repo.get("language") or "Unknown",
971
- repo["html_url"],
972
- )
1017
+ if results is not None:
1018
+ for i, repo in enumerate(items, 1):
1019
+ results.append({
1020
+ "index": i, "name": repo["full_name"],
1021
+ "description": repo.get("description") or "No description",
1022
+ "stars": repo["stargazers_count"],
1023
+ "language": repo.get("language") or "Unknown",
1024
+ "url": repo["html_url"],
1025
+ })
1026
+ else:
1027
+ print_header(f"GitHub Results — '{query}' ({len(items)} found)")
1028
+ for i, repo in enumerate(items, 1):
1029
+ _search_print_result(
1030
+ i,
1031
+ repo["full_name"],
1032
+ repo.get("description") or "No description",
1033
+ repo["stargazers_count"],
1034
+ repo.get("language") or "Unknown",
1035
+ repo["html_url"],
1036
+ )
973
1037
 
974
1038
 
975
- def search_gitlab(query, limit=10):
1039
+ def search_gitlab(query, limit=10, collector=None):
976
1040
  """Search repositories using the GitLab API."""
977
1041
  print(f" Searching GitLab for '{query}'...")
978
1042
  url = f"https://gitlab.com/api/v4/projects?search={urllib.parse.quote(query)}&per_page={limit}&order_by=stars&sort=desc"
@@ -1003,12 +1067,23 @@ def search_gitlab(query, limit=10):
1003
1067
 
1004
1068
  results.sort(key=lambda r: r["stars"], reverse=True)
1005
1069
 
1006
- print_header(f"GitLab Results '{query}' ({len(results)} found)")
1007
- for i, r in enumerate(results[:limit], 1):
1008
- _search_print_result(i, r["name"], r["description"], r["stars"], r["language"], r["url"], star_char="\u2605")
1070
+ if collector is not None:
1071
+ for i, r in enumerate(results[:limit], 1):
1072
+ collector.append({
1073
+ "index": i, "name": r["name"],
1074
+ "description": r["description"],
1075
+ "stars": r["stars"],
1076
+ "language": r["language"],
1077
+ "url": r["url"],
1078
+ })
1079
+ else:
1080
+ print_header(f"GitLab Results — '{query}' ({len(results)} found)")
1081
+ for i, r in enumerate(results[:limit], 1):
1082
+ _search_print_result(i, r["name"], r["description"],
1083
+ r["stars"], r["language"], r["url"], star_char="\u2605")
1009
1084
 
1010
1085
 
1011
- def search_codeberg(query, limit=10):
1086
+ def search_codeberg(query, limit=10, results=None):
1012
1087
  """Search repositories using the Codeberg (Gitea/Forgejo) API."""
1013
1088
  print(f" Searching Codeberg for '{query}'...")
1014
1089
  url = f"https://codeberg.org/api/v1/repos/search?q={urllib.parse.quote(query)}&limit={limit}&sort=stars"
@@ -1030,19 +1105,29 @@ def search_codeberg(query, limit=10):
1030
1105
  print_warning("No results found")
1031
1106
  return
1032
1107
 
1033
- print_header(f"Codeberg Results '{query}' ({len(items)} found)")
1034
- for i, repo in enumerate(items, 1):
1035
- _search_print_result(
1036
- i,
1037
- repo.get("full_name", "unknown"),
1038
- repo.get("description") or "No description",
1039
- repo.get("stars_count", 0),
1040
- repo.get("language") or "Unknown",
1041
- repo.get("html_url", ""),
1042
- )
1108
+ if results is not None:
1109
+ for i, repo in enumerate(items, 1):
1110
+ results.append({
1111
+ "index": i, "name": repo.get("full_name", "unknown"),
1112
+ "description": repo.get("description") or "No description",
1113
+ "stars": repo.get("stars_count", 0),
1114
+ "language": repo.get("language") or "Unknown",
1115
+ "url": repo.get("html_url", ""),
1116
+ })
1117
+ else:
1118
+ print_header(f"Codeberg Results — '{query}' ({len(items)} found)")
1119
+ for i, repo in enumerate(items, 1):
1120
+ _search_print_result(
1121
+ i,
1122
+ repo.get("full_name", "unknown"),
1123
+ repo.get("description") or "No description",
1124
+ repo.get("stars_count", 0),
1125
+ repo.get("language") or "Unknown",
1126
+ repo.get("html_url", ""),
1127
+ )
1043
1128
 
1044
1129
 
1045
- def search_bitbucket(query, limit=10):
1130
+ def search_bitbucket(query, limit=10, results=None):
1046
1131
  """Search repositories using the Bitbucket Cloud API."""
1047
1132
  print(f" Searching Bitbucket for '{query}'...")
1048
1133
  url = f"https://api.bitbucket.org/2.0/repositories?q=name~\"{urllib.parse.quote(query)}\"&sort=-updated_on"
@@ -1059,13 +1144,23 @@ def search_bitbucket(query, limit=10):
1059
1144
  print_warning("No results found")
1060
1145
  return
1061
1146
 
1062
- print_header(f"Bitbucket Results '{query}' ({len(items)} found)")
1063
- for i, repo in enumerate(items, 1):
1064
- full_name = repo.get("full_name", "unknown")
1065
- desc = repo.get("description") or "No description"
1066
- lang = repo.get("language") or "Unknown"
1067
- url = repo.get("links", {}).get("html", {}).get("href", "")
1068
- _search_print_result(i, full_name, desc, 0, lang, url, star_char="\u2022")
1147
+ if results is not None:
1148
+ for i, repo in enumerate(items, 1):
1149
+ results.append({
1150
+ "index": i, "name": repo.get("full_name", "unknown"),
1151
+ "description": repo.get("description") or "No description",
1152
+ "stars": 0,
1153
+ "language": repo.get("language") or "Unknown",
1154
+ "url": repo.get("links", {}).get("html", {}).get("href", ""),
1155
+ })
1156
+ else:
1157
+ print_header(f"Bitbucket Results — '{query}' ({len(items)} found)")
1158
+ for i, repo in enumerate(items, 1):
1159
+ full_name = repo.get("full_name", "unknown")
1160
+ desc = repo.get("description") or "No description"
1161
+ lang = repo.get("language") or "Unknown"
1162
+ url = repo.get("links", {}).get("html", {}).get("href", "")
1163
+ _search_print_result(i, full_name, desc, 0, lang, url, star_char="\u2022")
1069
1164
 
1070
1165
 
1071
1166
  def export_registry(filepath):
@@ -1114,6 +1209,7 @@ def register_app(repo_name, repo_url, install_path, install_method, skip_hook=Fa
1114
1209
  "url": repo_url,
1115
1210
  "path": str(install_path),
1116
1211
  "method": install_method,
1212
+ "pinned": False,
1117
1213
  "installed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
1118
1214
  }
1119
1215
 
@@ -1243,7 +1339,9 @@ def list_installed(json_output=False):
1243
1339
  install_path = Path(info["path"])
1244
1340
  size = _get_disk_size(install_path)
1245
1341
  exists = "✓" if install_path.exists() else "✗"
1246
- print(f"\n{Colors.GREEN}{name}{Colors.END} [{exists}]")
1342
+ pinned = info.get("pinned", False)
1343
+ pin_tag = f" {Colors.YELLOW}[PINNED]{Colors.END}" if pinned else ""
1344
+ print(f"\n{Colors.GREEN}{name}{Colors.END}{pin_tag} [{exists}]")
1247
1345
  print(f" URL: {info['url']}")
1248
1346
  print(f" Method: {info['method']}")
1249
1347
  print(f" Path: {info['path']}")
@@ -1270,7 +1368,8 @@ def uninstall_app(repo_name, force=False):
1270
1368
 
1271
1369
  # Remove installed files — but never delete shared system directories
1272
1370
  install_path = Path(app_info["path"])
1273
- if install_path.resolve() in SHARED_PATHS or install_path.resolve() == Path.home():
1371
+ resolved_shared_paths = {p.resolve() for p in SHARED_PATHS}
1372
+ if install_path.resolve() in resolved_shared_paths or install_path.resolve() == Path.home().resolve():
1274
1373
  print_error(f"Refusing to uninstall: {install_path} is a shared directory")
1275
1374
  print_warning("Remove files from this directory manually instead")
1276
1375
  return False
@@ -1303,6 +1402,7 @@ def _parse_args(args):
1303
1402
  verbose = False
1304
1403
  timeout = None
1305
1404
  retries = 0
1405
+ jobs = 1
1306
1406
  urls = []
1307
1407
 
1308
1408
  i = 0
@@ -1349,6 +1449,12 @@ def _parse_args(args):
1349
1449
  except ValueError:
1350
1450
  pass
1351
1451
  i += 2
1452
+ elif args[i] == "--jobs" and i + 1 < len(args):
1453
+ try:
1454
+ jobs = max(1, int(args[i + 1]))
1455
+ except ValueError:
1456
+ pass
1457
+ i += 2
1352
1458
  else:
1353
1459
  urls.append(args[i])
1354
1460
  i += 1
@@ -1358,7 +1464,8 @@ def _parse_args(args):
1358
1464
  if no_color:
1359
1465
  _enable_colors(False)
1360
1466
 
1361
- return install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, urls
1467
+ return (install_dir, dry_run, force, shallow, ref, method, json_output,
1468
+ verbose, no_color, timeout, retries, jobs, urls)
1362
1469
 
1363
1470
 
1364
1471
  def verify_apps(json_output=False):
@@ -1511,6 +1618,328 @@ def _migrate_old_registry():
1511
1618
  pass
1512
1619
 
1513
1620
 
1621
+ # ── Pin / Unpin ──
1622
+
1623
+
1624
+ def pin_app(repo_name):
1625
+ """Pin an app to prevent updates."""
1626
+ registry = load_registry()
1627
+ if repo_name not in registry["apps"]:
1628
+ print_error(f"{repo_name} is not installed")
1629
+ return False
1630
+ registry["apps"][repo_name]["pinned"] = True
1631
+ save_registry(registry)
1632
+ print_success(f"Pinned {repo_name}")
1633
+ return True
1634
+
1635
+
1636
+ def unpin_app(repo_name):
1637
+ """Unpin an app."""
1638
+ registry = load_registry()
1639
+ if repo_name not in registry["apps"]:
1640
+ print_error(f"{repo_name} is not installed")
1641
+ return False
1642
+ registry["apps"][repo_name]["pinned"] = False
1643
+ save_registry(registry)
1644
+ print_success(f"Unpinned {repo_name}")
1645
+ return True
1646
+
1647
+
1648
+ # ── Cache Management ──
1649
+
1650
+
1651
+ def cache_command(action):
1652
+ """Manage the download cache."""
1653
+ if action == "prune":
1654
+ if not CACHE_DIR.exists():
1655
+ print_success("Cache is already empty")
1656
+ return True
1657
+ total = 0
1658
+ for entry in CACHE_DIR.iterdir():
1659
+ if entry.is_file():
1660
+ total += entry.stat().st_size
1661
+ entry.unlink()
1662
+ elif entry.is_dir():
1663
+ total += sum(f.stat().st_size for f in entry.rglob("*") if f.is_file())
1664
+ shutil.rmtree(entry, ignore_errors=True)
1665
+ CACHE_DIR.rmdir() if CACHE_DIR.exists() else None
1666
+ print_success(f"Cleared cache ({_format_bytes(total)})")
1667
+ return True
1668
+ elif action == "path":
1669
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
1670
+ print(CACHE_DIR)
1671
+ return True
1672
+ else:
1673
+ print_error(f"Unknown cache action: {action} (try: prune, path)")
1674
+ return False
1675
+
1676
+
1677
+ # ── Install from Local Path ──
1678
+
1679
+
1680
+ def _install_local_path(repo_url, install_dir, dry_run=False, method_override=None):
1681
+ """Install a project from a local directory path."""
1682
+ local_path = Path(repo_url).expanduser().resolve()
1683
+ safe_name = _sanitize_repo_name(local_path.name)
1684
+ if not safe_name:
1685
+ print_error(f"Invalid local path name: {local_path.name}")
1686
+ return None
1687
+
1688
+ if dry_run:
1689
+ print(f" [DRY RUN] Would install from local path: {local_path}")
1690
+ print(f" [DRY RUN] Would use method: {method_override or '(auto-detected)'}")
1691
+ return install_dir / safe_name
1692
+
1693
+ install_dir.mkdir(parents=True, exist_ok=True)
1694
+ print(f" Installing from local path: {local_path}")
1695
+
1696
+ install_method = method_override or detect_install_method(local_path)
1697
+ print(f" Detected install method: {install_method}")
1698
+
1699
+ install_funcs = {
1700
+ "python": install_python, "node": install_node, "go": install_go,
1701
+ "rust": install_rust, "binary": install_binary, "make": install_make,
1702
+ "script": install_script, "download": install_binary,
1703
+ }
1704
+ install_func = install_funcs.get(install_method, install_binary)
1705
+ installed_path = install_func(local_path, install_dir)
1706
+
1707
+ if installed_path:
1708
+ register_app(safe_name, str(local_path), installed_path, install_method)
1709
+ print()
1710
+ print(f" {Colors.CYAN}Summary:{Colors.END}")
1711
+ print(f" Name: {safe_name}")
1712
+ print(f" Method: {install_method}")
1713
+ print(f" Location: {installed_path}")
1714
+ print(f" Size: {_get_disk_size(installed_path)}")
1715
+ return installed_path
1716
+ return None
1717
+
1718
+
1719
+ # ── Self-Update ──
1720
+
1721
+
1722
+ def self_update():
1723
+ """Update pluck itself via PyPI."""
1724
+ print_header("Updating pluck")
1725
+ try:
1726
+ subprocess.run(
1727
+ [sys.executable, "-m", "pip", "install", "--upgrade", "pluck-cli"],
1728
+ check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
1729
+ )
1730
+ print_success("pluck updated to latest version")
1731
+ return True
1732
+ except subprocess.CalledProcessError as e:
1733
+ print_error(f"Update failed: {e}")
1734
+ return False
1735
+
1736
+
1737
+ # ── Search All Forges + Export ──
1738
+
1739
+
1740
+ def _search_with_results(query, limit, searcher_func):
1741
+ """Run a searcher and return results as a list of dicts."""
1742
+ results = []
1743
+ searcher_func(query, limit=limit, results=results)
1744
+ return results
1745
+
1746
+
1747
+ def search_all_forges(query, limit=5, output_file=None):
1748
+ """Search all supported forges and optionally export results."""
1749
+ all_results = []
1750
+ forges = [
1751
+ ("GitHub", search_github),
1752
+ ("GitLab", search_gitlab),
1753
+ ("Codeberg", search_codeberg),
1754
+ ("Bitbucket", search_bitbucket),
1755
+ ]
1756
+
1757
+ for name, searcher in forges:
1758
+ print(f" Searching {name} for '{query}'...")
1759
+ results = _search_with_results(query, limit, searcher)
1760
+ all_results.append((name, results))
1761
+
1762
+ # Print aggregated results
1763
+ print_header(f"Aggregated Search Results — '{query}'")
1764
+ for forge_name, results in all_results:
1765
+ if results:
1766
+ print(f"\n{Colors.CYAN}── {forge_name} ({len(results)} results) ──{Colors.END}")
1767
+ for r in results:
1768
+ print(f" {r['index']}. {Colors.GREEN}{r['name']}{Colors.END}")
1769
+ print(f" {r['description']}")
1770
+ print(f" \u2605 {r['stars']:,} | Language: {r['language']}")
1771
+ print(f" URL: {r['url']}")
1772
+ print()
1773
+
1774
+ # Export to file if requested
1775
+ if output_file:
1776
+ out_path = Path(output_file).expanduser()
1777
+ lines = [f"Search Results: '{query}' ({datetime.now():%Y-%m-%d %H:%M})", "=" * 60, ""]
1778
+ for forge_name, results in all_results:
1779
+ if results:
1780
+ lines.append(f"── {forge_name} ──")
1781
+ for r in results:
1782
+ lines.append(f"{r['name']} | \u2605 {r['stars']:,} | {r['language']}")
1783
+ lines.append(f" {r['url']}")
1784
+ lines.append(f" {r['description']}")
1785
+ lines.append("")
1786
+ else:
1787
+ lines.append(f"── {forge_name} ── (no results)")
1788
+ lines.append("")
1789
+
1790
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1791
+ out_path.write_text("\n".join(lines) + "\n")
1792
+ print_success(f"Exported results to {out_path}")
1793
+
1794
+
1795
+ # ── Release Asset Install ──
1796
+
1797
+
1798
+ def _github_release_url(repo_info, install_dir):
1799
+ """Try to download a pre-built release asset from GitHub."""
1800
+ owner = repo_info["owner"]
1801
+ repo = repo_info["repo"]
1802
+ api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
1803
+ try:
1804
+ headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "pluck"}
1805
+ req = urllib.request.Request(api_url, headers=headers)
1806
+ with urllib.request.urlopen(req, timeout=15) as resp:
1807
+ data = json.loads(resp.read().decode())
1808
+ except Exception as e:
1809
+ print_warning(f"Could not fetch GitHub release: {e}")
1810
+ return None
1811
+
1812
+ assets = data.get("assets", [])
1813
+ if not assets:
1814
+ print_warning("No release assets found")
1815
+ return None
1816
+
1817
+ # Pick a matching asset: prefer the one matching the current platform
1818
+ arch_hints = []
1819
+ if sys.platform == "linux":
1820
+ arch_hints = ["linux", "Linux", "x86_64", "amd64"]
1821
+ elif sys.platform == "darwin":
1822
+ arch_hints = ["macos", "darwin", "Darwin", "macOS", "x86_64", "amd64", "arm64"]
1823
+
1824
+ best = None
1825
+ for asset in assets:
1826
+ name = asset["name"]
1827
+ if all(hint in name for hint in arch_hints):
1828
+ best = asset
1829
+ break
1830
+
1831
+ if not best and assets:
1832
+ # Fallback to first asset
1833
+ best = assets[0]
1834
+
1835
+ if not best:
1836
+ print_warning("No suitable asset found")
1837
+ return None
1838
+
1839
+ # Download the asset
1840
+ print(f" Downloading release asset: {best['name']}")
1841
+ dl_url = best["browser_download_url"]
1842
+ dest = CACHE_DIR / best["name"]
1843
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
1844
+
1845
+ try:
1846
+ req = urllib.request.Request(dl_url, headers={"User-Agent": "pluck"})
1847
+ with urllib.request.urlopen(req, timeout=60) as resp:
1848
+ dest.write_bytes(resp.read())
1849
+ except Exception as e:
1850
+ print_error(f"Download failed: {e}")
1851
+ return None
1852
+
1853
+ print_success(f"Downloaded to {dest}")
1854
+
1855
+ # If it's an archive, extract it
1856
+ if best["name"].endswith(".tar.gz") or best["name"].endswith(".tgz"):
1857
+ import tarfile
1858
+ extract_dir = install_dir / repo
1859
+ extract_dir.mkdir(parents=True, exist_ok=True)
1860
+ with tarfile.open(dest) as tar:
1861
+ tar.extractall(extract_dir)
1862
+ print_success(f"Extracted to {extract_dir}")
1863
+ return extract_dir
1864
+ elif best["name"].endswith(".zip"):
1865
+ import zipfile
1866
+ extract_dir = install_dir / repo
1867
+ extract_dir.mkdir(parents=True, exist_ok=True)
1868
+ with zipfile.ZipFile(dest) as zf:
1869
+ zf.extractall(extract_dir)
1870
+ print_success(f"Extracted to {extract_dir}")
1871
+ return extract_dir
1872
+ else:
1873
+ # Single binary
1874
+ dest.chmod(0o755)
1875
+ bin_dir = install_dir / repo
1876
+ bin_dir.mkdir(parents=True, exist_ok=True)
1877
+ final = bin_dir / best["name"]
1878
+ shutil.move(str(dest), str(final))
1879
+ print_success(f"Installed binary to {final}")
1880
+ return bin_dir
1881
+
1882
+
1883
+ def _gitlab_release_url(repo_info, install_dir):
1884
+ """Try to download a pre-built release asset from GitLab."""
1885
+ owner = repo_info["owner"]
1886
+ repo = repo_info["repo"]
1887
+ # GitLab generic packages API
1888
+ encoded = urllib.parse.quote(owner + "/" + repo, safe="")
1889
+ api_url = f"https://gitlab.com/api/v4/projects/{encoded}/releases/permalink/latest"
1890
+ try:
1891
+ req = urllib.request.Request(api_url, headers={"Accept": "application/json", "User-Agent": "pluck"})
1892
+ with urllib.request.urlopen(req, timeout=15) as resp:
1893
+ data = json.loads(resp.read().decode())
1894
+ except Exception as e:
1895
+ print_warning(f"Could not fetch GitLab release: {e}")
1896
+ return None
1897
+
1898
+ links = data.get("assets", {}).get("links", [])
1899
+ if not links:
1900
+ print_warning("No release assets found")
1901
+ return None
1902
+
1903
+ # Pick first binary link
1904
+ for link in links:
1905
+ url = link.get("direct_asset_url") or link.get("url", "")
1906
+ if url:
1907
+ name = link.get("name", "asset")
1908
+ dest = CACHE_DIR / name
1909
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
1910
+ print(f" Downloading release asset: {name}")
1911
+ try:
1912
+ req = urllib.request.Request(url, headers={"User-Agent": "pluck"})
1913
+ with urllib.request.urlopen(req, timeout=60) as resp:
1914
+ dest.write_bytes(resp.read())
1915
+ except Exception as e:
1916
+ print_warning(f"Download failed: {e}")
1917
+ continue
1918
+
1919
+ bin_dir = install_dir / repo
1920
+ bin_dir.mkdir(parents=True, exist_ok=True)
1921
+ final = bin_dir / name
1922
+ dest.chmod(0o755)
1923
+ shutil.move(str(dest), str(final))
1924
+ print_success(f"Installed release asset to {final}")
1925
+ return bin_dir
1926
+
1927
+ print_warning("Could not download any release asset")
1928
+ return None
1929
+
1930
+
1931
+ def install_release_asset(repo_info, install_dir):
1932
+ """Install from pre-built release assets instead of cloning."""
1933
+ host_type = repo_info.get("host_type", "")
1934
+ if host_type == "github":
1935
+ return _github_release_url(repo_info, install_dir)
1936
+ elif host_type == "gitlab":
1937
+ return _gitlab_release_url(repo_info, install_dir)
1938
+ else:
1939
+ print_warning(f"Release asset install not yet supported for {host_type}")
1940
+ return None
1941
+
1942
+
1514
1943
  def main():
1515
1944
  """Main entry point"""
1516
1945
  # Auto-migrate from old gh-install paths
@@ -1533,9 +1962,8 @@ def main():
1533
1962
  command = sys.argv[1]
1534
1963
 
1535
1964
  if command == "install":
1536
- install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, urls = (
1537
- _parse_args(sys.argv[2:])
1538
- )
1965
+ (install_dir, dry_run, force, shallow, ref, method, json_output,
1966
+ verbose, no_color, timeout, retries, jobs, urls) = _parse_args(sys.argv[2:])
1539
1967
 
1540
1968
  if not urls:
1541
1969
  print_error("Please provide a repository URL")
@@ -1548,24 +1976,42 @@ def main():
1548
1976
  if dry_run:
1549
1977
  print_header("Dry Run — No changes will be made")
1550
1978
 
1551
- for url in urls:
1552
- print(f"\nInstalling: {url}")
1553
- download_and_install(
1554
- url,
1555
- install_dir=install_dir,
1556
- dry_run=dry_run,
1557
- shallow=shallow,
1558
- ref=ref,
1559
- method_override=method,
1560
- verbose=verbose,
1561
- timeout=timeout,
1562
- retries=retries,
1563
- )
1979
+ if jobs > 1 and len(urls) > 1:
1980
+ # Parallel install
1981
+ print_header(f"Installing {len(urls)} repos with {jobs} workers")
1982
+ with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
1983
+ futures = {
1984
+ pool.submit(
1985
+ download_and_install, url,
1986
+ install_dir=install_dir, dry_run=dry_run, shallow=shallow,
1987
+ ref=ref, method_override=method, verbose=verbose,
1988
+ timeout=timeout, retries=retries,
1989
+ ): url for url in urls
1990
+ }
1991
+ for future in concurrent.futures.as_completed(futures):
1992
+ url = futures[future]
1993
+ try:
1994
+ future.result()
1995
+ except Exception as e:
1996
+ print_error(f"Failed to install {url}: {e}")
1997
+ else:
1998
+ for url in urls:
1999
+ print(f"\nInstalling: {url}")
2000
+ download_and_install(
2001
+ url,
2002
+ install_dir=install_dir,
2003
+ dry_run=dry_run,
2004
+ shallow=shallow,
2005
+ ref=ref,
2006
+ method_override=method,
2007
+ verbose=verbose,
2008
+ timeout=timeout,
2009
+ retries=retries,
2010
+ )
1564
2011
 
1565
2012
  elif command == "update":
1566
- install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, rest = (
1567
- _parse_args(sys.argv[2:])
1568
- )
2013
+ (install_dir, dry_run, force, shallow, ref, method, json_output,
2014
+ verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
1569
2015
  if not rest:
1570
2016
  print_error("Please provide an app name")
1571
2017
  sys.exit(1)
@@ -1597,9 +2043,8 @@ def main():
1597
2043
  list_installed(json_output=json_output)
1598
2044
 
1599
2045
  elif command in ("uninstall", "remove"):
1600
- install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, rest = (
1601
- _parse_args(sys.argv[2:])
1602
- )
2046
+ (install_dir, dry_run, force, shallow, ref, method, json_output,
2047
+ verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
1603
2048
  if not rest:
1604
2049
  print_error("Please provide an app name")
1605
2050
  sys.exit(1)
@@ -1612,9 +2057,8 @@ def main():
1612
2057
  verify_apps(json_output=json_output)
1613
2058
 
1614
2059
  elif command == "clean":
1615
- install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, rest = (
1616
- _parse_args(sys.argv[2:])
1617
- )
2060
+ (install_dir, dry_run, force, shallow, ref, method, json_output,
2061
+ verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
1618
2062
  clean_registry(dry_run=dry_run, force=force, json_output=json_output)
1619
2063
 
1620
2064
  elif command == "stats":
@@ -1636,30 +2080,48 @@ def main():
1636
2080
  print_error("Please provide a search query")
1637
2081
  sys.exit(1)
1638
2082
 
2083
+ # Extract flags
2084
+ output_file = None
2085
+ search_all = False
1639
2086
  forge = "github"
1640
- if "--forge" in args:
1641
- idx = args.index("--forge")
1642
- if idx + 1 < len(args):
1643
- forge = args[idx + 1].lower()
1644
- args = args[:idx] + args[idx + 2:]
2087
+ cleaned = []
2088
+ i = 0
2089
+ while i < len(args):
2090
+ if args[i] == "--forge" and i + 1 < len(args):
2091
+ forge = args[i + 1].lower()
2092
+ i += 2
2093
+ elif args[i] == "--all":
2094
+ search_all = True
2095
+ i += 1
2096
+ elif args[i] == "--output" and i + 1 < len(args):
2097
+ output_file = args[i + 1]
2098
+ i += 2
1645
2099
  else:
1646
- print_error("Missing forge name after --forge (try: github, gitlab, codeberg)")
1647
- sys.exit(1)
2100
+ cleaned.append(args[i])
2101
+ i += 1
1648
2102
 
1649
- query = " ".join(args)
1650
- forge_searchers = {
1651
- "github": search_github,
1652
- "gitlab": search_gitlab,
1653
- "codeberg": search_codeberg,
1654
- "bitbucket": search_bitbucket,
1655
- }
1656
- searcher = forge_searchers.get(forge)
1657
- if searcher:
1658
- searcher(query)
1659
- else:
1660
- print_error(f"Unknown forge: {forge}. Supported: {', '.join(sorted(forge_searchers))}")
2103
+ query = " ".join(cleaned)
2104
+
2105
+ if not query:
2106
+ print_error("Please provide a search query")
1661
2107
  sys.exit(1)
1662
2108
 
2109
+ if search_all:
2110
+ search_all_forges(query, output_file=output_file)
2111
+ else:
2112
+ forge_searchers = {
2113
+ "github": search_github,
2114
+ "gitlab": search_gitlab,
2115
+ "codeberg": search_codeberg,
2116
+ "bitbucket": search_bitbucket,
2117
+ }
2118
+ searcher = forge_searchers.get(forge)
2119
+ if searcher:
2120
+ searcher(query)
2121
+ else:
2122
+ print_error(f"Unknown forge: {forge}. Supported: {', '.join(sorted(forge_searchers))}")
2123
+ sys.exit(1)
2124
+
1663
2125
  elif command == "export":
1664
2126
  if len(sys.argv) < 3:
1665
2127
  print_error("Please provide an output file path")
@@ -1685,6 +2147,26 @@ def main():
1685
2147
  print("Supported shells: bash, zsh")
1686
2148
  sys.exit(1)
1687
2149
 
2150
+ elif command == "pin":
2151
+ if len(sys.argv) < 3:
2152
+ print_error("Please provide an app name")
2153
+ sys.exit(1)
2154
+ pin_app(sys.argv[2])
2155
+
2156
+ elif command == "unpin":
2157
+ if len(sys.argv) < 3:
2158
+ print_error("Please provide an app name")
2159
+ sys.exit(1)
2160
+ unpin_app(sys.argv[2])
2161
+
2162
+ elif command == "self-update":
2163
+ if not self_update():
2164
+ sys.exit(1)
2165
+
2166
+ elif command == "cache":
2167
+ action = sys.argv[2] if len(sys.argv) > 2 else "path"
2168
+ cache_command(action)
2169
+
1688
2170
  elif command == "version":
1689
2171
  print(f"pluck v{__version__}")
1690
2172
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluck-cli
3
- Version: 0.2.1
3
+ Version: 0.3.2
4
4
  Summary: Pluck any git repo from any forge — auto-detect, auto-install, done!
5
5
  Author: pluck contributors
6
6
  License: MIT
@@ -175,7 +175,11 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
175
175
  | `stats` | Show statistics | `pluck stats` |
176
176
  | `doctor` | Check tool availability | `pluck doctor` |
177
177
  | `config [key] [val]` | View/set config | `pluck config install_dir ~/Apps` |
178
- | `search <query> [--forge <name>]` | Search repos (github\|gitlab\|codeberg\|bitbucket) | `pluck search python installer --forge gitlab` |
178
+ | `search <query> [--forge <name>] [--all] [--output <file>]` | Search repos across forges | `pluck search python installer --all --output results.txt` |
179
+ | `pin <name>` | Pin an app to prevent updates | `pluck pin myapp` |
180
+ | `unpin <name>` | Unpin an app | `pluck unpin myapp` |
181
+ | `self-update` | Update pluck itself | `pluck self-update` |
182
+ | `cache prune` | Clear download cache | `pluck cache prune` |
179
183
  | `export <file>` | Export registry | `pluck export ~/backup.json` |
180
184
  | `import <file>` | Import registry | `pluck import ~/backup.json` |
181
185
  | `completion <shell>` | Generate shell completion | `pluck completion bash` |
@@ -197,6 +201,8 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
197
201
  | `--no-color` | Disable colored output |
198
202
  | `--timeout <secs>` | Timeout for git clone in seconds |
199
203
  | `--retries <n>` | Number of retries for failed git clone |
204
+ | `--jobs <n>` | Number of parallel installs (default: 1) |
205
+ | `--release` | Install from pre-built release assets instead of cloning |
200
206
  | `--verbose` | Show detailed git clone output |
201
207
 
202
208
  ## 📥 Installation
@@ -356,10 +356,10 @@ class TestSharedPaths:
356
356
 
357
357
  class TestValidMethods:
358
358
  def test_valid_methods_not_empty(self):
359
- assert len(VALID_METHODS) == 8
359
+ assert len(VALID_METHODS) == 9
360
360
 
361
361
  def test_all_expected_methods_present(self):
362
- expected = {"script", "binary", "python", "node", "go", "rust", "make", "download"}
362
+ expected = {"script", "binary", "python", "node", "go", "rust", "make", "download", "release"}
363
363
  assert VALID_METHODS == expected
364
364
 
365
365
 
@@ -433,7 +433,7 @@ class TestParseArgs:
433
433
  def test_urls_only(self):
434
434
  (
435
435
  install_dir, dry_run, force, shallow, ref,
436
- method, json_output, verbose, no_color, timeout, retries, urls,
436
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
437
437
  ) = _parse_args(["https://github.com/a/b"])
438
438
  assert install_dir is None
439
439
  assert dry_run is False
@@ -446,7 +446,7 @@ class TestParseArgs:
446
446
  def test_dir_flag(self):
447
447
  (
448
448
  install_dir, dry_run, force, shallow, ref,
449
- method, json_output, verbose, no_color, timeout, retries, urls,
449
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
450
450
  ) = _parse_args(["--dir", "/tmp/test", "https://github.com/a/b"])
451
451
  assert install_dir == Path("/tmp/test")
452
452
  assert urls == ["https://github.com/a/b"]
@@ -454,49 +454,49 @@ class TestParseArgs:
454
454
  def test_dry_run_flag(self):
455
455
  (
456
456
  install_dir, dry_run, force, shallow, ref,
457
- method, json_output, verbose, no_color, timeout, retries, urls,
457
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
458
458
  ) = _parse_args(["--dry-run", "https://github.com/a/b"])
459
459
  assert dry_run is True
460
460
 
461
461
  def test_force_flag(self):
462
462
  (
463
463
  install_dir, dry_run, force, shallow, ref,
464
- method, json_output, verbose, no_color, timeout, retries, urls,
464
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
465
465
  ) = _parse_args(["--force", "https://github.com/a/b"])
466
466
  assert force is True
467
467
 
468
468
  def test_yes_flag(self):
469
469
  (
470
470
  install_dir, dry_run, force, shallow, ref,
471
- method, json_output, verbose, no_color, timeout, retries, urls,
471
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
472
472
  ) = _parse_args(["--yes", "https://github.com/a/b"])
473
473
  assert force is True
474
474
 
475
475
  def test_shallow_flag(self):
476
476
  (
477
477
  install_dir, dry_run, force, shallow, ref,
478
- method, json_output, verbose, no_color, timeout, retries, urls,
478
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
479
479
  ) = _parse_args(["--shallow", "https://github.com/a/b"])
480
480
  assert shallow is True
481
481
 
482
482
  def test_ref_flag(self):
483
483
  (
484
484
  install_dir, dry_run, force, shallow, ref,
485
- method, json_output, verbose, no_color, timeout, retries, urls,
485
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
486
486
  ) = _parse_args(["--ref", "v2.0", "https://github.com/a/b"])
487
487
  assert ref == "v2.0"
488
488
 
489
489
  def test_method_flag(self):
490
490
  (
491
491
  install_dir, dry_run, force, shallow, ref,
492
- method, json_output, verbose, no_color, timeout, retries, urls,
492
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
493
493
  ) = _parse_args(["--method", "python", "https://github.com/a/b"])
494
494
  assert method == "python"
495
495
 
496
496
  def test_combined_flags(self):
497
497
  (
498
498
  install_dir, dry_run, force, shallow, ref,
499
- method, json_output, verbose, no_color, timeout, retries, urls,
499
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
500
500
  ) = _parse_args([
501
501
  "--dir", "/custom", "--dry-run", "--shallow",
502
502
  "--ref", "main", "--method", "python",
@@ -511,7 +511,7 @@ class TestParseArgs:
511
511
  def test_flags_between_urls(self):
512
512
  (
513
513
  install_dir, dry_run, force, shallow, ref,
514
- method, json_output, verbose, no_color, timeout, retries, urls,
514
+ method, json_output, verbose, no_color, timeout, retries, jobs, urls,
515
515
  ) = _parse_args([
516
516
  "https://github.com/a/b", "--dir", "/opt", "https://github.com/c/d",
517
517
  ])
File without changes
File without changes