pluck-cli 0.2.0__tar.gz → 0.3.0__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.0
3
+ Version: 0.3.0
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) | `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,9 @@ 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 |
206
+ | `--verbose` | Show detailed git clone output |
200
207
 
201
208
  ## 📥 Installation
202
209
 
@@ -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) | `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,9 @@ 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 |
181
+ | `--verbose` | Show detailed git clone output |
175
182
 
176
183
  ## 📥 Installation
177
184
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pluck-cli"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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.0"
22
+ __version__ = "0.3.0"
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)"),
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,8 @@ 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)"),
118
+ ("--verbose", "Show detailed git clone output"),
110
119
  ]
111
120
 
112
121
  print("Usage:")
@@ -646,6 +655,14 @@ def download_and_install(
646
655
  else:
647
656
  install_dir = DEFAULT_INSTALL_DIR
648
657
 
658
+ # Check if this is a local path instead of a URL
659
+ local_candidate = Path(repo_url).expanduser()
660
+ if local_candidate.exists():
661
+ return _install_local_path(
662
+ repo_url, install_dir,
663
+ dry_run=dry_run, method_override=method_override,
664
+ )
665
+
649
666
  # Parse repository URL
650
667
  repo_info = parse_repo_url(repo_url)
651
668
  if not repo_info:
@@ -662,6 +679,25 @@ def download_and_install(
662
679
  print_error(f"Invalid repository name: {repo_info['repo']}")
663
680
  return None
664
681
 
682
+ # If release method requested, skip cloning and try release assets
683
+ if method_override == "release":
684
+ if dry_run:
685
+ print(f" [DRY RUN] Would install release assets for: {repo_info['owner']}/{repo_info['repo']}")
686
+ return install_dir / safe_name
687
+ install_dir.mkdir(parents=True, exist_ok=True)
688
+ print(" Attempting release asset install...")
689
+ installed_path = install_release_asset(repo_info, install_dir)
690
+ if installed_path:
691
+ register_app(repo_info["repo"], repo_url, installed_path, "release")
692
+ print()
693
+ print(f" {Colors.CYAN}Summary:{Colors.END}")
694
+ print(f" Name: {repo_info['repo']}")
695
+ print(" Method: release")
696
+ print(f" Location: {installed_path}")
697
+ print(f" Size: {_get_disk_size(installed_path)}")
698
+ return installed_path
699
+ print_warning("Release asset install failed, falling back to clone...")
700
+
665
701
  # Dry-run check before doing any I/O
666
702
  if dry_run:
667
703
  print(f" [DRY RUN] Would install to: {install_dir / safe_name}")
@@ -772,6 +808,12 @@ def update_app(
772
808
  url = app_info["url"]
773
809
  old_path = Path(app_info["path"])
774
810
 
811
+ # Check if app is pinned
812
+ if app_info.get("pinned", False):
813
+ print_warning(f"{repo_name} is pinned — skipping update")
814
+ print(" Use 'pluck unpin' first, or --force to override")
815
+ return True
816
+
775
817
  print_header(f"Updating {repo_name}")
776
818
  print(f" Current: {app_info['installed_at']}")
777
819
  print(f" URL: {url}")
@@ -828,11 +870,13 @@ def info_app(repo_name, json_output=False):
828
870
  app_info = registry["apps"][repo_name]
829
871
  install_path = Path(app_info["path"])
830
872
 
873
+ pinned = app_info.get("pinned", False)
831
874
  if json_output:
832
875
  data = {
833
876
  "name": repo_name,
834
877
  "url": app_info["url"],
835
878
  "method": app_info["method"],
879
+ "pinned": pinned,
836
880
  "path": app_info["path"],
837
881
  "installed_at": app_info["installed_at"],
838
882
  "size": _get_disk_size(install_path),
@@ -842,11 +886,12 @@ def info_app(repo_name, json_output=False):
842
886
  return True
843
887
 
844
888
  print_header(f"App Info: {repo_name}")
845
- labels = ["URL", "Method", "Path", "Installed", "Size", "Exists"]
889
+ labels = ["URL", "Method", "Path", "Pinned", "Installed", "Size", "Exists"]
846
890
  values = [
847
891
  app_info["url"],
848
892
  app_info["method"],
849
893
  app_info["path"],
894
+ "Yes" if pinned else "No",
850
895
  app_info["installed_at"],
851
896
  _get_disk_size(install_path),
852
897
  "Yes" if install_path.exists() else "No (files may have been moved)",
@@ -942,8 +987,10 @@ def _search_print_result(index, name, desc, stars, lang, url, star_char="★"):
942
987
  print()
943
988
 
944
989
 
945
- def search_github(query, limit=10):
946
- """Search repositories using the GitHub API."""
990
+ def search_github(query, limit=10, results=None):
991
+ """Search repositories using the GitHub API.
992
+ If results (list) is provided, appends result dicts instead of printing.
993
+ """
947
994
  print(f" Searching GitHub for '{query}'...")
948
995
  url = f"https://api.github.com/search/repositories?q={urllib.parse.quote(query)}&sort=stars&order=desc&per_page={limit}"
949
996
  try:
@@ -959,19 +1006,29 @@ def search_github(query, limit=10):
959
1006
  print_warning("No results found")
960
1007
  return
961
1008
 
962
- print_header(f"GitHub Results '{query}' ({len(items)} found)")
963
- for i, repo in enumerate(items, 1):
964
- _search_print_result(
965
- i,
966
- repo["full_name"],
967
- repo.get("description") or "No description",
968
- repo["stargazers_count"],
969
- repo.get("language") or "Unknown",
970
- repo["html_url"],
971
- )
1009
+ if results is not None:
1010
+ for i, repo in enumerate(items, 1):
1011
+ results.append({
1012
+ "index": i, "name": repo["full_name"],
1013
+ "description": repo.get("description") or "No description",
1014
+ "stars": repo["stargazers_count"],
1015
+ "language": repo.get("language") or "Unknown",
1016
+ "url": repo["html_url"],
1017
+ })
1018
+ else:
1019
+ print_header(f"GitHub Results — '{query}' ({len(items)} found)")
1020
+ for i, repo in enumerate(items, 1):
1021
+ _search_print_result(
1022
+ i,
1023
+ repo["full_name"],
1024
+ repo.get("description") or "No description",
1025
+ repo["stargazers_count"],
1026
+ repo.get("language") or "Unknown",
1027
+ repo["html_url"],
1028
+ )
972
1029
 
973
1030
 
974
- def search_gitlab(query, limit=10):
1031
+ def search_gitlab(query, limit=10, collector=None):
975
1032
  """Search repositories using the GitLab API."""
976
1033
  print(f" Searching GitLab for '{query}'...")
977
1034
  url = f"https://gitlab.com/api/v4/projects?search={urllib.parse.quote(query)}&per_page={limit}&order_by=stars&sort=desc"
@@ -1002,12 +1059,23 @@ def search_gitlab(query, limit=10):
1002
1059
 
1003
1060
  results.sort(key=lambda r: r["stars"], reverse=True)
1004
1061
 
1005
- print_header(f"GitLab Results '{query}' ({len(results)} found)")
1006
- for i, r in enumerate(results[:limit], 1):
1007
- _search_print_result(i, r["name"], r["description"], r["stars"], r["language"], r["url"], star_char="\u2605")
1062
+ if collector is not None:
1063
+ for i, r in enumerate(results[:limit], 1):
1064
+ collector.append({
1065
+ "index": i, "name": r["name"],
1066
+ "description": r["description"],
1067
+ "stars": r["stars"],
1068
+ "language": r["language"],
1069
+ "url": r["url"],
1070
+ })
1071
+ else:
1072
+ print_header(f"GitLab Results — '{query}' ({len(results)} found)")
1073
+ for i, r in enumerate(results[:limit], 1):
1074
+ _search_print_result(i, r["name"], r["description"],
1075
+ r["stars"], r["language"], r["url"], star_char="\u2605")
1008
1076
 
1009
1077
 
1010
- def search_codeberg(query, limit=10):
1078
+ def search_codeberg(query, limit=10, results=None):
1011
1079
  """Search repositories using the Codeberg (Gitea/Forgejo) API."""
1012
1080
  print(f" Searching Codeberg for '{query}'...")
1013
1081
  url = f"https://codeberg.org/api/v1/repos/search?q={urllib.parse.quote(query)}&limit={limit}&sort=stars"
@@ -1029,19 +1097,29 @@ def search_codeberg(query, limit=10):
1029
1097
  print_warning("No results found")
1030
1098
  return
1031
1099
 
1032
- print_header(f"Codeberg Results '{query}' ({len(items)} found)")
1033
- for i, repo in enumerate(items, 1):
1034
- _search_print_result(
1035
- i,
1036
- repo.get("full_name", "unknown"),
1037
- repo.get("description") or "No description",
1038
- repo.get("stars_count", 0),
1039
- repo.get("language") or "Unknown",
1040
- repo.get("html_url", ""),
1041
- )
1100
+ if results is not None:
1101
+ for i, repo in enumerate(items, 1):
1102
+ results.append({
1103
+ "index": i, "name": repo.get("full_name", "unknown"),
1104
+ "description": repo.get("description") or "No description",
1105
+ "stars": repo.get("stars_count", 0),
1106
+ "language": repo.get("language") or "Unknown",
1107
+ "url": repo.get("html_url", ""),
1108
+ })
1109
+ else:
1110
+ print_header(f"Codeberg Results — '{query}' ({len(items)} found)")
1111
+ for i, repo in enumerate(items, 1):
1112
+ _search_print_result(
1113
+ i,
1114
+ repo.get("full_name", "unknown"),
1115
+ repo.get("description") or "No description",
1116
+ repo.get("stars_count", 0),
1117
+ repo.get("language") or "Unknown",
1118
+ repo.get("html_url", ""),
1119
+ )
1042
1120
 
1043
1121
 
1044
- def search_bitbucket(query, limit=10):
1122
+ def search_bitbucket(query, limit=10, results=None):
1045
1123
  """Search repositories using the Bitbucket Cloud API."""
1046
1124
  print(f" Searching Bitbucket for '{query}'...")
1047
1125
  url = f"https://api.bitbucket.org/2.0/repositories?q=name~\"{urllib.parse.quote(query)}\"&sort=-updated_on"
@@ -1058,13 +1136,23 @@ def search_bitbucket(query, limit=10):
1058
1136
  print_warning("No results found")
1059
1137
  return
1060
1138
 
1061
- print_header(f"Bitbucket Results '{query}' ({len(items)} found)")
1062
- for i, repo in enumerate(items, 1):
1063
- full_name = repo.get("full_name", "unknown")
1064
- desc = repo.get("description") or "No description"
1065
- lang = repo.get("language") or "Unknown"
1066
- url = repo.get("links", {}).get("html", {}).get("href", "")
1067
- _search_print_result(i, full_name, desc, 0, lang, url, star_char="\u2022")
1139
+ if results is not None:
1140
+ for i, repo in enumerate(items, 1):
1141
+ results.append({
1142
+ "index": i, "name": repo.get("full_name", "unknown"),
1143
+ "description": repo.get("description") or "No description",
1144
+ "stars": 0,
1145
+ "language": repo.get("language") or "Unknown",
1146
+ "url": repo.get("links", {}).get("html", {}).get("href", ""),
1147
+ })
1148
+ else:
1149
+ print_header(f"Bitbucket Results — '{query}' ({len(items)} found)")
1150
+ for i, repo in enumerate(items, 1):
1151
+ full_name = repo.get("full_name", "unknown")
1152
+ desc = repo.get("description") or "No description"
1153
+ lang = repo.get("language") or "Unknown"
1154
+ url = repo.get("links", {}).get("html", {}).get("href", "")
1155
+ _search_print_result(i, full_name, desc, 0, lang, url, star_char="\u2022")
1068
1156
 
1069
1157
 
1070
1158
  def export_registry(filepath):
@@ -1113,6 +1201,7 @@ def register_app(repo_name, repo_url, install_path, install_method, skip_hook=Fa
1113
1201
  "url": repo_url,
1114
1202
  "path": str(install_path),
1115
1203
  "method": install_method,
1204
+ "pinned": False,
1116
1205
  "installed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
1117
1206
  }
1118
1207
 
@@ -1242,7 +1331,9 @@ def list_installed(json_output=False):
1242
1331
  install_path = Path(info["path"])
1243
1332
  size = _get_disk_size(install_path)
1244
1333
  exists = "✓" if install_path.exists() else "✗"
1245
- print(f"\n{Colors.GREEN}{name}{Colors.END} [{exists}]")
1334
+ pinned = info.get("pinned", False)
1335
+ pin_tag = f" {Colors.YELLOW}[PINNED]{Colors.END}" if pinned else ""
1336
+ print(f"\n{Colors.GREEN}{name}{Colors.END}{pin_tag} [{exists}]")
1246
1337
  print(f" URL: {info['url']}")
1247
1338
  print(f" Method: {info['method']}")
1248
1339
  print(f" Path: {info['path']}")
@@ -1302,6 +1393,7 @@ def _parse_args(args):
1302
1393
  verbose = False
1303
1394
  timeout = None
1304
1395
  retries = 0
1396
+ jobs = 1
1305
1397
  urls = []
1306
1398
 
1307
1399
  i = 0
@@ -1348,6 +1440,12 @@ def _parse_args(args):
1348
1440
  except ValueError:
1349
1441
  pass
1350
1442
  i += 2
1443
+ elif args[i] == "--jobs" and i + 1 < len(args):
1444
+ try:
1445
+ jobs = max(1, int(args[i + 1]))
1446
+ except ValueError:
1447
+ pass
1448
+ i += 2
1351
1449
  else:
1352
1450
  urls.append(args[i])
1353
1451
  i += 1
@@ -1357,7 +1455,8 @@ def _parse_args(args):
1357
1455
  if no_color:
1358
1456
  _enable_colors(False)
1359
1457
 
1360
- return install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, urls
1458
+ return (install_dir, dry_run, force, shallow, ref, method, json_output,
1459
+ verbose, no_color, timeout, retries, jobs, urls)
1361
1460
 
1362
1461
 
1363
1462
  def verify_apps(json_output=False):
@@ -1510,6 +1609,328 @@ def _migrate_old_registry():
1510
1609
  pass
1511
1610
 
1512
1611
 
1612
+ # ── Pin / Unpin ──
1613
+
1614
+
1615
+ def pin_app(repo_name):
1616
+ """Pin an app to prevent updates."""
1617
+ registry = load_registry()
1618
+ if repo_name not in registry["apps"]:
1619
+ print_error(f"{repo_name} is not installed")
1620
+ return False
1621
+ registry["apps"][repo_name]["pinned"] = True
1622
+ save_registry(registry)
1623
+ print_success(f"Pinned {repo_name}")
1624
+ return True
1625
+
1626
+
1627
+ def unpin_app(repo_name):
1628
+ """Unpin an app."""
1629
+ registry = load_registry()
1630
+ if repo_name not in registry["apps"]:
1631
+ print_error(f"{repo_name} is not installed")
1632
+ return False
1633
+ registry["apps"][repo_name]["pinned"] = False
1634
+ save_registry(registry)
1635
+ print_success(f"Unpinned {repo_name}")
1636
+ return True
1637
+
1638
+
1639
+ # ── Cache Management ──
1640
+
1641
+
1642
+ def cache_command(action):
1643
+ """Manage the download cache."""
1644
+ if action == "prune":
1645
+ if not CACHE_DIR.exists():
1646
+ print_success("Cache is already empty")
1647
+ return True
1648
+ total = 0
1649
+ for entry in CACHE_DIR.iterdir():
1650
+ if entry.is_file():
1651
+ total += entry.stat().st_size
1652
+ entry.unlink()
1653
+ elif entry.is_dir():
1654
+ total += sum(f.stat().st_size for f in entry.rglob("*") if f.is_file())
1655
+ shutil.rmtree(entry, ignore_errors=True)
1656
+ CACHE_DIR.rmdir() if CACHE_DIR.exists() else None
1657
+ print_success(f"Cleared cache ({_format_bytes(total)})")
1658
+ return True
1659
+ elif action == "path":
1660
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
1661
+ print(CACHE_DIR)
1662
+ return True
1663
+ else:
1664
+ print_error(f"Unknown cache action: {action} (try: prune, path)")
1665
+ return False
1666
+
1667
+
1668
+ # ── Install from Local Path ──
1669
+
1670
+
1671
+ def _install_local_path(repo_url, install_dir, dry_run=False, method_override=None):
1672
+ """Install a project from a local directory path."""
1673
+ local_path = Path(repo_url).expanduser().resolve()
1674
+ safe_name = _sanitize_repo_name(local_path.name)
1675
+ if not safe_name:
1676
+ print_error(f"Invalid local path name: {local_path.name}")
1677
+ return None
1678
+
1679
+ if dry_run:
1680
+ print(f" [DRY RUN] Would install from local path: {local_path}")
1681
+ print(f" [DRY RUN] Would use method: {method_override or '(auto-detected)'}")
1682
+ return install_dir / safe_name
1683
+
1684
+ install_dir.mkdir(parents=True, exist_ok=True)
1685
+ print(f" Installing from local path: {local_path}")
1686
+
1687
+ install_method = method_override or detect_install_method(local_path)
1688
+ print(f" Detected install method: {install_method}")
1689
+
1690
+ install_funcs = {
1691
+ "python": install_python, "node": install_node, "go": install_go,
1692
+ "rust": install_rust, "binary": install_binary, "make": install_make,
1693
+ "script": install_script, "download": install_binary,
1694
+ }
1695
+ install_func = install_funcs.get(install_method, install_binary)
1696
+ installed_path = install_func(local_path, install_dir)
1697
+
1698
+ if installed_path:
1699
+ register_app(safe_name, str(local_path), installed_path, install_method)
1700
+ print()
1701
+ print(f" {Colors.CYAN}Summary:{Colors.END}")
1702
+ print(f" Name: {safe_name}")
1703
+ print(f" Method: {install_method}")
1704
+ print(f" Location: {installed_path}")
1705
+ print(f" Size: {_get_disk_size(installed_path)}")
1706
+ return installed_path
1707
+ return None
1708
+
1709
+
1710
+ # ── Self-Update ──
1711
+
1712
+
1713
+ def self_update():
1714
+ """Update pluck itself via PyPI."""
1715
+ print_header("Updating pluck")
1716
+ try:
1717
+ subprocess.run(
1718
+ [sys.executable, "-m", "pip", "install", "--upgrade", "pluck-cli"],
1719
+ check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
1720
+ )
1721
+ print_success("pluck updated to latest version")
1722
+ return True
1723
+ except subprocess.CalledProcessError as e:
1724
+ print_error(f"Update failed: {e}")
1725
+ return False
1726
+
1727
+
1728
+ # ── Search All Forges + Export ──
1729
+
1730
+
1731
+ def _search_with_results(query, limit, searcher_func):
1732
+ """Run a searcher and return results as a list of dicts."""
1733
+ results = []
1734
+ searcher_func(query, limit=limit, results=results)
1735
+ return results
1736
+
1737
+
1738
+ def search_all_forges(query, limit=5, output_file=None):
1739
+ """Search all supported forges and optionally export results."""
1740
+ all_results = []
1741
+ forges = [
1742
+ ("GitHub", search_github),
1743
+ ("GitLab", search_gitlab),
1744
+ ("Codeberg", search_codeberg),
1745
+ ("Bitbucket", search_bitbucket),
1746
+ ]
1747
+
1748
+ for name, searcher in forges:
1749
+ print(f" Searching {name} for '{query}'...")
1750
+ results = _search_with_results(query, limit, searcher)
1751
+ all_results.append((name, results))
1752
+
1753
+ # Print aggregated results
1754
+ print_header(f"Aggregated Search Results — '{query}'")
1755
+ for forge_name, results in all_results:
1756
+ if results:
1757
+ print(f"\n{Colors.CYAN}── {forge_name} ({len(results)} results) ──{Colors.END}")
1758
+ for r in results:
1759
+ print(f" {r['index']}. {Colors.GREEN}{r['name']}{Colors.END}")
1760
+ print(f" {r['description']}")
1761
+ print(f" \u2605 {r['stars']:,} | Language: {r['language']}")
1762
+ print(f" URL: {r['url']}")
1763
+ print()
1764
+
1765
+ # Export to file if requested
1766
+ if output_file:
1767
+ out_path = Path(output_file).expanduser()
1768
+ lines = [f"Search Results: '{query}' ({datetime.now():%Y-%m-%d %H:%M})", "=" * 60, ""]
1769
+ for forge_name, results in all_results:
1770
+ if results:
1771
+ lines.append(f"── {forge_name} ──")
1772
+ for r in results:
1773
+ lines.append(f"{r['name']} | \u2605 {r['stars']:,} | {r['language']}")
1774
+ lines.append(f" {r['url']}")
1775
+ lines.append(f" {r['description']}")
1776
+ lines.append("")
1777
+ else:
1778
+ lines.append(f"── {forge_name} ── (no results)")
1779
+ lines.append("")
1780
+
1781
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1782
+ out_path.write_text("\n".join(lines) + "\n")
1783
+ print_success(f"Exported results to {out_path}")
1784
+
1785
+
1786
+ # ── Release Asset Install ──
1787
+
1788
+
1789
+ def _github_release_url(repo_info, install_dir):
1790
+ """Try to download a pre-built release asset from GitHub."""
1791
+ owner = repo_info["owner"]
1792
+ repo = repo_info["repo"]
1793
+ api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
1794
+ try:
1795
+ headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "pluck"}
1796
+ req = urllib.request.Request(api_url, headers=headers)
1797
+ with urllib.request.urlopen(req, timeout=15) as resp:
1798
+ data = json.loads(resp.read().decode())
1799
+ except Exception as e:
1800
+ print_warning(f"Could not fetch GitHub release: {e}")
1801
+ return None
1802
+
1803
+ assets = data.get("assets", [])
1804
+ if not assets:
1805
+ print_warning("No release assets found")
1806
+ return None
1807
+
1808
+ # Pick a matching asset: prefer the one matching the current platform
1809
+ arch_hints = []
1810
+ if sys.platform == "linux":
1811
+ arch_hints = ["linux", "Linux", "x86_64", "amd64"]
1812
+ elif sys.platform == "darwin":
1813
+ arch_hints = ["macos", "darwin", "Darwin", "macOS", "x86_64", "amd64", "arm64"]
1814
+
1815
+ best = None
1816
+ for asset in assets:
1817
+ name = asset["name"]
1818
+ if all(hint in name for hint in arch_hints):
1819
+ best = asset
1820
+ break
1821
+
1822
+ if not best and assets:
1823
+ # Fallback to first asset
1824
+ best = assets[0]
1825
+
1826
+ if not best:
1827
+ print_warning("No suitable asset found")
1828
+ return None
1829
+
1830
+ # Download the asset
1831
+ print(f" Downloading release asset: {best['name']}")
1832
+ dl_url = best["browser_download_url"]
1833
+ dest = CACHE_DIR / best["name"]
1834
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
1835
+
1836
+ try:
1837
+ req = urllib.request.Request(dl_url, headers={"User-Agent": "pluck"})
1838
+ with urllib.request.urlopen(req, timeout=60) as resp:
1839
+ dest.write_bytes(resp.read())
1840
+ except Exception as e:
1841
+ print_error(f"Download failed: {e}")
1842
+ return None
1843
+
1844
+ print_success(f"Downloaded to {dest}")
1845
+
1846
+ # If it's an archive, extract it
1847
+ if best["name"].endswith(".tar.gz") or best["name"].endswith(".tgz"):
1848
+ import tarfile
1849
+ extract_dir = install_dir / repo
1850
+ extract_dir.mkdir(parents=True, exist_ok=True)
1851
+ with tarfile.open(dest) as tar:
1852
+ tar.extractall(extract_dir)
1853
+ print_success(f"Extracted to {extract_dir}")
1854
+ return extract_dir
1855
+ elif best["name"].endswith(".zip"):
1856
+ import zipfile
1857
+ extract_dir = install_dir / repo
1858
+ extract_dir.mkdir(parents=True, exist_ok=True)
1859
+ with zipfile.ZipFile(dest) as zf:
1860
+ zf.extractall(extract_dir)
1861
+ print_success(f"Extracted to {extract_dir}")
1862
+ return extract_dir
1863
+ else:
1864
+ # Single binary
1865
+ dest.chmod(0o755)
1866
+ bin_dir = install_dir / repo
1867
+ bin_dir.mkdir(parents=True, exist_ok=True)
1868
+ final = bin_dir / best["name"]
1869
+ shutil.move(str(dest), str(final))
1870
+ print_success(f"Installed binary to {final}")
1871
+ return bin_dir
1872
+
1873
+
1874
+ def _gitlab_release_url(repo_info, install_dir):
1875
+ """Try to download a pre-built release asset from GitLab."""
1876
+ owner = repo_info["owner"]
1877
+ repo = repo_info["repo"]
1878
+ # GitLab generic packages API
1879
+ encoded = urllib.parse.quote(owner + "/" + repo, safe="")
1880
+ api_url = f"https://gitlab.com/api/v4/projects/{encoded}/releases/permalink/latest"
1881
+ try:
1882
+ req = urllib.request.Request(api_url, headers={"Accept": "application/json", "User-Agent": "pluck"})
1883
+ with urllib.request.urlopen(req, timeout=15) as resp:
1884
+ data = json.loads(resp.read().decode())
1885
+ except Exception as e:
1886
+ print_warning(f"Could not fetch GitLab release: {e}")
1887
+ return None
1888
+
1889
+ links = data.get("assets", {}).get("links", [])
1890
+ if not links:
1891
+ print_warning("No release assets found")
1892
+ return None
1893
+
1894
+ # Pick first binary link
1895
+ for link in links:
1896
+ url = link.get("direct_asset_url") or link.get("url", "")
1897
+ if url:
1898
+ name = link.get("name", "asset")
1899
+ dest = CACHE_DIR / name
1900
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
1901
+ print(f" Downloading release asset: {name}")
1902
+ try:
1903
+ req = urllib.request.Request(url, headers={"User-Agent": "pluck"})
1904
+ with urllib.request.urlopen(req, timeout=60) as resp:
1905
+ dest.write_bytes(resp.read())
1906
+ except Exception as e:
1907
+ print_warning(f"Download failed: {e}")
1908
+ continue
1909
+
1910
+ bin_dir = install_dir / repo
1911
+ bin_dir.mkdir(parents=True, exist_ok=True)
1912
+ final = bin_dir / name
1913
+ dest.chmod(0o755)
1914
+ shutil.move(str(dest), str(final))
1915
+ print_success(f"Installed release asset to {final}")
1916
+ return bin_dir
1917
+
1918
+ print_warning("Could not download any release asset")
1919
+ return None
1920
+
1921
+
1922
+ def install_release_asset(repo_info, install_dir):
1923
+ """Install from pre-built release assets instead of cloning."""
1924
+ host_type = repo_info.get("host_type", "")
1925
+ if host_type == "github":
1926
+ return _github_release_url(repo_info, install_dir)
1927
+ elif host_type == "gitlab":
1928
+ return _gitlab_release_url(repo_info, install_dir)
1929
+ else:
1930
+ print_warning(f"Release asset install not yet supported for {host_type}")
1931
+ return None
1932
+
1933
+
1513
1934
  def main():
1514
1935
  """Main entry point"""
1515
1936
  # Auto-migrate from old gh-install paths
@@ -1532,9 +1953,8 @@ def main():
1532
1953
  command = sys.argv[1]
1533
1954
 
1534
1955
  if command == "install":
1535
- install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, urls = (
1536
- _parse_args(sys.argv[2:])
1537
- )
1956
+ (install_dir, dry_run, force, shallow, ref, method, json_output,
1957
+ verbose, no_color, timeout, retries, jobs, urls) = _parse_args(sys.argv[2:])
1538
1958
 
1539
1959
  if not urls:
1540
1960
  print_error("Please provide a repository URL")
@@ -1547,24 +1967,42 @@ def main():
1547
1967
  if dry_run:
1548
1968
  print_header("Dry Run — No changes will be made")
1549
1969
 
1550
- for url in urls:
1551
- print(f"\nInstalling: {url}")
1552
- download_and_install(
1553
- url,
1554
- install_dir=install_dir,
1555
- dry_run=dry_run,
1556
- shallow=shallow,
1557
- ref=ref,
1558
- method_override=method,
1559
- verbose=verbose,
1560
- timeout=timeout,
1561
- retries=retries,
1562
- )
1970
+ if jobs > 1 and len(urls) > 1:
1971
+ # Parallel install
1972
+ print_header(f"Installing {len(urls)} repos with {jobs} workers")
1973
+ with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
1974
+ futures = {
1975
+ pool.submit(
1976
+ download_and_install, url,
1977
+ install_dir=install_dir, dry_run=dry_run, shallow=shallow,
1978
+ ref=ref, method_override=method, verbose=verbose,
1979
+ timeout=timeout, retries=retries,
1980
+ ): url for url in urls
1981
+ }
1982
+ for future in concurrent.futures.as_completed(futures):
1983
+ url = futures[future]
1984
+ try:
1985
+ future.result()
1986
+ except Exception as e:
1987
+ print_error(f"Failed to install {url}: {e}")
1988
+ else:
1989
+ for url in urls:
1990
+ print(f"\nInstalling: {url}")
1991
+ download_and_install(
1992
+ url,
1993
+ install_dir=install_dir,
1994
+ dry_run=dry_run,
1995
+ shallow=shallow,
1996
+ ref=ref,
1997
+ method_override=method,
1998
+ verbose=verbose,
1999
+ timeout=timeout,
2000
+ retries=retries,
2001
+ )
1563
2002
 
1564
2003
  elif command == "update":
1565
- install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, rest = (
1566
- _parse_args(sys.argv[2:])
1567
- )
2004
+ (install_dir, dry_run, force, shallow, ref, method, json_output,
2005
+ verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
1568
2006
  if not rest:
1569
2007
  print_error("Please provide an app name")
1570
2008
  sys.exit(1)
@@ -1596,9 +2034,8 @@ def main():
1596
2034
  list_installed(json_output=json_output)
1597
2035
 
1598
2036
  elif command in ("uninstall", "remove"):
1599
- install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, rest = (
1600
- _parse_args(sys.argv[2:])
1601
- )
2037
+ (install_dir, dry_run, force, shallow, ref, method, json_output,
2038
+ verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
1602
2039
  if not rest:
1603
2040
  print_error("Please provide an app name")
1604
2041
  sys.exit(1)
@@ -1611,9 +2048,8 @@ def main():
1611
2048
  verify_apps(json_output=json_output)
1612
2049
 
1613
2050
  elif command == "clean":
1614
- install_dir, dry_run, force, shallow, ref, method, json_output, verbose, no_color, timeout, retries, rest = (
1615
- _parse_args(sys.argv[2:])
1616
- )
2051
+ (install_dir, dry_run, force, shallow, ref, method, json_output,
2052
+ verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
1617
2053
  clean_registry(dry_run=dry_run, force=force, json_output=json_output)
1618
2054
 
1619
2055
  elif command == "stats":
@@ -1635,30 +2071,48 @@ def main():
1635
2071
  print_error("Please provide a search query")
1636
2072
  sys.exit(1)
1637
2073
 
2074
+ # Extract flags
2075
+ output_file = None
2076
+ search_all = False
1638
2077
  forge = "github"
1639
- if "--forge" in args:
1640
- idx = args.index("--forge")
1641
- if idx + 1 < len(args):
1642
- forge = args[idx + 1].lower()
1643
- args = args[:idx] + args[idx + 2:]
2078
+ cleaned = []
2079
+ i = 0
2080
+ while i < len(args):
2081
+ if args[i] == "--forge" and i + 1 < len(args):
2082
+ forge = args[i + 1].lower()
2083
+ i += 2
2084
+ elif args[i] == "--all":
2085
+ search_all = True
2086
+ i += 1
2087
+ elif args[i] == "--output" and i + 1 < len(args):
2088
+ output_file = args[i + 1]
2089
+ i += 2
1644
2090
  else:
1645
- print_error("Missing forge name after --forge (try: github, gitlab, codeberg)")
1646
- sys.exit(1)
2091
+ cleaned.append(args[i])
2092
+ i += 1
1647
2093
 
1648
- query = " ".join(args)
1649
- forge_searchers = {
1650
- "github": search_github,
1651
- "gitlab": search_gitlab,
1652
- "codeberg": search_codeberg,
1653
- "bitbucket": search_bitbucket,
1654
- }
1655
- searcher = forge_searchers.get(forge)
1656
- if searcher:
1657
- searcher(query)
1658
- else:
1659
- print_error(f"Unknown forge: {forge}. Supported: {', '.join(sorted(forge_searchers))}")
2094
+ query = " ".join(cleaned)
2095
+
2096
+ if not query:
2097
+ print_error("Please provide a search query")
1660
2098
  sys.exit(1)
1661
2099
 
2100
+ if search_all:
2101
+ search_all_forges(query, output_file=output_file)
2102
+ else:
2103
+ forge_searchers = {
2104
+ "github": search_github,
2105
+ "gitlab": search_gitlab,
2106
+ "codeberg": search_codeberg,
2107
+ "bitbucket": search_bitbucket,
2108
+ }
2109
+ searcher = forge_searchers.get(forge)
2110
+ if searcher:
2111
+ searcher(query)
2112
+ else:
2113
+ print_error(f"Unknown forge: {forge}. Supported: {', '.join(sorted(forge_searchers))}")
2114
+ sys.exit(1)
2115
+
1662
2116
  elif command == "export":
1663
2117
  if len(sys.argv) < 3:
1664
2118
  print_error("Please provide an output file path")
@@ -1684,6 +2138,26 @@ def main():
1684
2138
  print("Supported shells: bash, zsh")
1685
2139
  sys.exit(1)
1686
2140
 
2141
+ elif command == "pin":
2142
+ if len(sys.argv) < 3:
2143
+ print_error("Please provide an app name")
2144
+ sys.exit(1)
2145
+ pin_app(sys.argv[2])
2146
+
2147
+ elif command == "unpin":
2148
+ if len(sys.argv) < 3:
2149
+ print_error("Please provide an app name")
2150
+ sys.exit(1)
2151
+ unpin_app(sys.argv[2])
2152
+
2153
+ elif command == "self-update":
2154
+ if not self_update():
2155
+ sys.exit(1)
2156
+
2157
+ elif command == "cache":
2158
+ action = sys.argv[2] if len(sys.argv) > 2 else "path"
2159
+ cache_command(action)
2160
+
1687
2161
  elif command == "version":
1688
2162
  print(f"pluck v{__version__}")
1689
2163
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluck-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
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) | `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,9 @@ 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 |
206
+ | `--verbose` | Show detailed git clone output |
200
207
 
201
208
  ## 📥 Installation
202
209
 
@@ -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