pluck-cli 0.1.0__py3-none-any.whl

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.
gh_install.py ADDED
@@ -0,0 +1,1692 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ GitHub App Installer - Paste URL, Auto-Install, Done!
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ import urllib.parse
17
+ import urllib.request
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ # Configuration
24
+ DEFAULT_INSTALL_DIR_MACOS = Path.home() / "Applications"
25
+ DEFAULT_INSTALL_DIR_LINUX = Path.home() / ".local" / "opt"
26
+ if sys.platform == "darwin":
27
+ DEFAULT_INSTALL_DIR = DEFAULT_INSTALL_DIR_MACOS
28
+ else:
29
+ DEFAULT_INSTALL_DIR = DEFAULT_INSTALL_DIR_LINUX
30
+ APP_REGISTRY_FILE = Path.home() / ".pluck-registry.json"
31
+ _CONFIG_OLD_REGISTRY = Path.home() / ".gh-install-registry.json"
32
+ CONFIG_FILE = (
33
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "pluck" / "config.json"
34
+ )
35
+ _CONFIG_OLD_DIR = Path.home() / ".config" / "gh-install"
36
+ SHARED_PATHS = {
37
+ Path.home() / "go" / "bin",
38
+ Path.home() / "Applications",
39
+ Path.home() / ".local" / "opt",
40
+ Path.home() / "bin",
41
+ }
42
+ VALID_METHODS = {"script", "binary", "python", "node", "go", "rust", "make", "download"}
43
+ GIST_PATTERN = r"gist\.github\.com[:/]([^/]+)/([a-f0-9]+)"
44
+ # GitLab personal snippet: gitlab.com/-/snippets/12345
45
+ # GitLab project snippet: gitlab.com/owner/repo/-/snippets/12345
46
+ SNIPPET_PATTERNS = [
47
+ r"gitlab\.com/-/snippets/(\d+)",
48
+ r"gitlab\.com/([^/]+)/([^/]+)/-/snippets/(\d+)",
49
+ ]
50
+
51
+ # Color auto-detection
52
+ _COLORS_ENABLED = sys.stdout.isatty()
53
+
54
+
55
+ def _enable_colors(enabled: bool) -> None:
56
+ global _COLORS_ENABLED
57
+ _COLORS_ENABLED = enabled
58
+
59
+
60
+ def _load_user_config():
61
+ """Load user config file if it exists."""
62
+ if CONFIG_FILE.exists():
63
+ try:
64
+ with open(CONFIG_FILE) as f:
65
+ return json.load(f)
66
+ except (json.JSONDecodeError, OSError):
67
+ pass
68
+ return {}
69
+
70
+
71
+ def _save_user_config(config):
72
+ """Save user config file."""
73
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
74
+ with open(CONFIG_FILE, "w") as f:
75
+ json.dump(config, f, indent=2)
76
+
77
+
78
+ def print_usage():
79
+ commands = [
80
+ ("install <url> [opts]", "Install from any git repo URL"),
81
+ ("update <name> [--force]", "Update an installed app"),
82
+ ("info <name>", "Show app details"),
83
+ ("list", "List installed apps"),
84
+ ("uninstall <name> [--force]", "Uninstall an app"),
85
+ ("remove <name> [--force]", "Alias for uninstall"),
86
+ ("verify", "Check installed apps validity"),
87
+ ("clean [--force]", "Remove orphaned registry entries"),
88
+ ("stats", "Show installation statistics"),
89
+ ("doctor", "Check tool availability"),
90
+ ("config [key] [value]", "View/set config"),
91
+ ("search <query> [--forge <name>]", "Search repos (github|gitlab|codeberg)"),
92
+ ("export <file>", "Export registry"),
93
+ ("import <file>", "Import registry"),
94
+ ("completion <shell>", "Generate shell completion"),
95
+ ("version", "Show version"),
96
+ ("help", "Show this help"),
97
+ ]
98
+ opts = [
99
+ ("--dir <path>", "Install to a custom directory"),
100
+ ("--dry-run", "Show what would be done without making changes"),
101
+ ("--force", "Skip confirmation prompts"),
102
+ ("--shallow", "Use shallow clone (--depth 1)"),
103
+ ("--ref <ref>", "Clone a specific branch or tag"),
104
+ ("--method <method>", "Force install method"),
105
+ ("--yes", "Non-interactive mode (alias for --force)"),
106
+ ("--json", "Output in JSON format (for scripting)"),
107
+ ("--no-color", "Disable colored output"),
108
+ ("--timeout <secs>", "Timeout for git clone in seconds"),
109
+ ("--retries <n>", "Number of retries for failed git clone"),
110
+ ]
111
+
112
+ print("Usage:")
113
+ print(" pluck <command> [args] [options]")
114
+ print()
115
+ print("Commands:")
116
+ max_cmd = max(len(c[0]) for c in commands)
117
+ for cmd, desc in commands:
118
+ print(f" {cmd:<{max_cmd}} {desc}")
119
+ print()
120
+ print("Options:")
121
+ max_opt = max(len(o[0]) for o in opts)
122
+ for opt, desc in opts:
123
+ print(f" {opt:<{max_opt}} {desc}")
124
+
125
+
126
+ def _parse_snippet_url(url):
127
+ """Extract snippet/gist info from gist or code snippet URLs.
128
+
129
+ Supports:
130
+ - GitHub Gists: gist.github.com/user/id
131
+ - GitLab personal snippets: gitlab.com/-/snippets/id
132
+ - GitLab project snippets: gitlab.com/owner/repo/-/snippets/id
133
+ """
134
+ # GitHub Gist
135
+ match = re.search(GIST_PATTERN, url)
136
+ if match:
137
+ return {
138
+ "host": "gist.github.com",
139
+ "host_type": "github",
140
+ "owner": match.group(1),
141
+ "repo": f"gist-{match.group(2)}",
142
+ "url": f"https://gist.github.com/{match.group(1)}/{match.group(2)}.git",
143
+ "is_gist": True,
144
+ }
145
+
146
+ # GitLab personal snippet
147
+ match = re.search(SNIPPET_PATTERNS[0], url)
148
+ if match:
149
+ snippet_id = match.group(1)
150
+ return {
151
+ "host": "gitlab.com",
152
+ "host_type": "gitlab",
153
+ "owner": "-",
154
+ "repo": f"snippet-{snippet_id}",
155
+ "url": f"https://gitlab.com/-/snippets/{snippet_id}.git",
156
+ "is_gist": True,
157
+ }
158
+
159
+ # GitLab project snippet
160
+ match = re.search(SNIPPET_PATTERNS[1], url)
161
+ if match:
162
+ owner = match.group(1)
163
+ project = match.group(2)
164
+ snippet_id = match.group(3)
165
+ return {
166
+ "host": "gitlab.com",
167
+ "host_type": "gitlab",
168
+ "owner": f"{owner}/{project}",
169
+ "repo": f"snippet-{snippet_id}",
170
+ "url": f"https://gitlab.com/{owner}/{project}/-/snippets/{snippet_id}.git",
171
+ "is_gist": True,
172
+ }
173
+
174
+ return None
175
+
176
+
177
+ # Backward compat alias
178
+ _parse_gist_url = _parse_snippet_url
179
+
180
+
181
+ def _completion_script(shell):
182
+ """Return shell completion script for bash or zsh."""
183
+ if shell == "bash":
184
+ return """_pluck_completion() {
185
+ local cur="${COMP_WORDS[COMP_CWORD]}"
186
+ local prev="${COMP_WORDS[COMP_CWORD-1]}"
187
+ local commands="install update info list uninstall doctor config search export import completion version help"
188
+
189
+ case "${COMP_WORDS[1]}" in
190
+ install)
191
+ if [[ "$prev" == "--dir" ]]; then
192
+ _filedir -d
193
+ else
194
+ opts="--dir --dry-run --force --shallow --ref --method --yes"
195
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
196
+ fi
197
+ return
198
+ ;;
199
+ update|uninstall|info)
200
+ local apps
201
+ apps=$(python3 -c "
202
+ import json, os
203
+ p = os.path.expanduser('~/.pluck-registry.json')
204
+ if os.path.exists(p):
205
+ data = json.load(open(p))
206
+ print(' '.join(data.get('apps', {}).keys()))
207
+ " 2>/dev/null)
208
+ COMPREPLY=( $(compgen -W "$apps --force --dry-run" -- "$cur") )
209
+ return
210
+ ;;
211
+ list|version|help|doctor)
212
+ return
213
+ ;;
214
+ completion)
215
+ COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") )
216
+ return
217
+ ;;
218
+ config)
219
+ COMPREPLY=( $(compgen -W "install_dir method_priority" -- "$cur") )
220
+ return
221
+ ;;
222
+ *)
223
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
224
+ return
225
+ ;;
226
+ esac
227
+ }
228
+ complete -F _pluck_completion pluck
229
+ """
230
+ elif shell == "zsh":
231
+ return """#compdef pluck
232
+ _pluck() {
233
+ local -a commands
234
+ commands=(
235
+ 'install:Install from any git repo URL'
236
+ 'update:Update an installed app'
237
+ 'info:Show app details'
238
+ 'list:List installed apps'
239
+ 'uninstall:Uninstall an app'
240
+ 'doctor:Check tool availability'
241
+ 'config:View/set config'
242
+ 'search:Search GitHub repos'
243
+ 'export:Export registry'
244
+ 'import:Import registry'
245
+ 'completion:Generate shell completion'
246
+ 'version:Show version'
247
+ 'help:Show help'
248
+ )
249
+
250
+ _arguments -C \\
251
+ '1: :->command' \\
252
+ '*: :->args'
253
+
254
+ case $state in
255
+ command)
256
+ _describe 'command' commands
257
+ ;;
258
+ args)
259
+ case $words[1] in
260
+ install)
261
+ _arguments \\
262
+ '--dir[Install to custom directory]:directory:_directories' \\
263
+ '--dry-run[Preview without changes]' \\
264
+ '--force[Skip confirmation]' \\
265
+ '--shallow[Use shallow clone]' \\
266
+ '--ref[Clone specific branch/tag]:ref:' \\
267
+ '--method[Force install method]:(script python node go rust' \
268
+ 'make binary download)' \
269
+ '--yes[Non-interactive mode]'
270
+ ;;
271
+ update|uninstall|info)
272
+ local apps
273
+ apps=($(python3 -c "
274
+ import json, os
275
+ p = os.path.expanduser('~/.pluck-registry.json')
276
+ if os.path.exists(p):
277
+ data = json.load(open(p))
278
+ print(' '.join(data.get('apps', {}).keys()))
279
+ " 2>/dev/null))
280
+ _arguments \\
281
+ '--force[Skip confirmation]' \\
282
+ '--dry-run[Preview without changes]' \\
283
+ "1:app:($apps)"
284
+ ;;
285
+ completion)
286
+ _arguments '1:shell:(bash zsh)'
287
+ ;;
288
+ esac
289
+ ;;
290
+ esac
291
+ }
292
+ _pluck
293
+ """
294
+ else:
295
+ return None
296
+
297
+
298
+ def _get_app_names():
299
+ """Return list of installed app names for completion."""
300
+ try:
301
+ if APP_REGISTRY_FILE.exists():
302
+ with open(APP_REGISTRY_FILE) as f:
303
+ data = json.load(f)
304
+ return list(data.get("apps", {}).keys())
305
+ except (json.JSONDecodeError, OSError):
306
+ pass
307
+ return []
308
+
309
+
310
+ def _sanitize_repo_name(name):
311
+ """Reject repo names that could cause path traversal."""
312
+ if ".." in name or name.startswith("/") or name.startswith("\\"):
313
+ return None
314
+ return name
315
+
316
+
317
+ class Colors:
318
+ GREEN = "\033[92m" if _COLORS_ENABLED else ""
319
+ YELLOW = "\033[93m" if _COLORS_ENABLED else ""
320
+ RED = "\033[91m" if _COLORS_ENABLED else ""
321
+ BLUE = "\033[94m" if _COLORS_ENABLED else ""
322
+ CYAN = "\033[96m" if _COLORS_ENABLED else ""
323
+ END = "\033[0m" if _COLORS_ENABLED else ""
324
+
325
+
326
+ def print_header(text):
327
+ print(f"\n{Colors.BLUE}{'=' * 60}{Colors.END}")
328
+ print(f"{Colors.GREEN} {text}{Colors.END}")
329
+ print(f"{Colors.BLUE}{'=' * 60}{Colors.END}\n")
330
+
331
+
332
+ def print_success(text):
333
+ print(f"{Colors.GREEN}✓ {text}{Colors.END}")
334
+
335
+
336
+ def print_warning(text):
337
+ print(f"{Colors.YELLOW}⚠ {text}{Colors.END}")
338
+
339
+
340
+ def print_error(text):
341
+ print(f"{Colors.RED}✗ {text}{Colors.END}")
342
+
343
+
344
+ def _detect_host_type(host):
345
+ """Identify the forge type from a git hosting domain."""
346
+ host_lower = host.lower()
347
+ if host_lower.startswith("www."):
348
+ host_lower = host_lower[4:]
349
+ forge_map = {
350
+ "github.com": "github",
351
+ "gitlab.com": "gitlab",
352
+ "codeberg.org": "codeberg",
353
+ "bitbucket.org": "bitbucket",
354
+ "git.sr.ht": "sourcehut",
355
+ "gitea.com": "gitea",
356
+ "gogs.io": "gogs",
357
+ "pagure.io": "pagure",
358
+ "forgejo.org": "forgejo",
359
+ }
360
+ return forge_map.get(host_lower, "generic")
361
+
362
+
363
+ def parse_repo_url(url):
364
+ """Extract owner/repo from any git hosting URL.
365
+
366
+ Supports GitHub, GitLab, Codeberg, Bitbucket, SourceHut, Gitea,
367
+ self-hosted instances, and any other standard git hosting.
368
+ """
369
+ # Try gist detection first
370
+ gist_info = _parse_snippet_url(url)
371
+ if gist_info:
372
+ return gist_info
373
+
374
+ # Normalize: strip trailing slash
375
+ url = url.rstrip("/")
376
+
377
+ patterns = [
378
+ # HTTPS: https://host/owner/repo[.git][/extra/path]
379
+ r"https?://([^/]+)/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$",
380
+ # SSH git@host:owner/repo[.git]
381
+ r"git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?$",
382
+ # SSH ssh://git@host/owner/repo[.git]
383
+ r"ssh://git@([^/]+)/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$",
384
+ # git protocol: git://host/owner/repo[.git]
385
+ r"git://([^/]+)/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$",
386
+ ]
387
+
388
+ for pattern in patterns:
389
+ match = re.search(pattern, url)
390
+ if match:
391
+ host = match.group(1)
392
+ owner = match.group(2)
393
+ repo = match.group(3)
394
+ host_type = _detect_host_type(host)
395
+ normalized_url = f"https://{host}/{owner}/{repo}"
396
+ return {
397
+ "host": host,
398
+ "host_type": host_type,
399
+ "owner": owner,
400
+ "repo": repo,
401
+ "url": normalized_url,
402
+ "is_gist": False,
403
+ }
404
+
405
+ return None
406
+
407
+
408
+ # Backward-compat alias — remove in a future release
409
+ parse_github_url = parse_repo_url
410
+
411
+
412
+ def detect_install_method(repo_path, method_priority=None):
413
+ """Detect the best installation method for a repository"""
414
+ if method_priority:
415
+ methods = [m for m in method_priority if m in VALID_METHODS]
416
+ else:
417
+ methods = ["script", "binary", "python", "node", "go", "rust", "make", "download"]
418
+
419
+ for method in methods:
420
+ if method == "script" and (repo_path / "install.sh").exists():
421
+ return "script"
422
+ if method == "binary" and (
423
+ (repo_path / "release" / "linux").exists()
424
+ or (repo_path / "bin" / "linux").exists()
425
+ or list(repo_path.glob("*.AppImage"))
426
+ or list(repo_path.glob("*.deb"))
427
+ ):
428
+ return "binary"
429
+ if method == "python" and (
430
+ (repo_path / "pyproject.toml").exists() or (repo_path / "setup.py").exists()
431
+ ):
432
+ return "python"
433
+ if method == "node" and (repo_path / "package.json").exists():
434
+ return "node"
435
+ if method == "go" and ((repo_path / "go.mod").exists() or list(repo_path.glob("*.go"))):
436
+ return "go"
437
+ if method == "rust" and (repo_path / "Cargo.toml").exists():
438
+ return "rust"
439
+ if method == "make" and (repo_path / "Makefile").exists():
440
+ return "make"
441
+
442
+ return "download"
443
+
444
+
445
+ def install_script(repo_path, install_dir):
446
+ """Install using install.sh script"""
447
+ print(" Running install.sh script...")
448
+
449
+ try:
450
+ subprocess.run(["bash", "install.sh", "--yes"], cwd=repo_path, check=True)
451
+ print_success("Installation script completed")
452
+ return install_dir
453
+ except subprocess.CalledProcessError:
454
+ print_warning("Install script failed, copying directory instead")
455
+ return install_binary(repo_path, install_dir)
456
+
457
+
458
+ def install_python(repo_path, install_dir):
459
+ """Install Python project"""
460
+ print(" Installing Python project...")
461
+
462
+ try:
463
+ app_dir = install_dir / repo_path.name
464
+ venv_path = app_dir / ".venv"
465
+ venv_path.parent.mkdir(parents=True, exist_ok=True)
466
+ subprocess.run([sys.executable, "-m", "venv", str(venv_path)], check=True)
467
+
468
+ pip_path = venv_path / "bin" / "pip"
469
+ subprocess.run([str(pip_path), "install", "-e", str(repo_path)], check=True)
470
+
471
+ print_success(f"Installed to {app_dir}")
472
+
473
+ if (venv_path / "bin" / repo_path.name).exists():
474
+ bin_file = venv_path / "bin" / repo_path.name
475
+ link_path = install_dir / repo_path.name
476
+ if link_path.exists() or link_path.is_symlink():
477
+ link_path.unlink()
478
+ link_path.symlink_to(bin_file)
479
+ print_success(f"Created symlink: {link_path}")
480
+ return app_dir
481
+
482
+ return app_dir
483
+ except subprocess.CalledProcessError as e:
484
+ print_error(f"Python installation failed: {e}")
485
+ return None
486
+
487
+
488
+ def install_node(repo_path, install_dir):
489
+ """Install Node.js project"""
490
+ print(" Installing Node.js project...")
491
+
492
+ try:
493
+ dest = install_dir / repo_path.name
494
+ ignore = shutil.ignore_patterns("node_modules", ".git")
495
+ shutil.copytree(repo_path, dest, dirs_exist_ok=True, ignore=ignore)
496
+
497
+ subprocess.run(["npm", "install"], cwd=dest, check=True)
498
+
499
+ print_success(f"Installed to {dest}")
500
+ return dest
501
+ except subprocess.CalledProcessError as e:
502
+ print_error(f"Node.js installation failed: {e}")
503
+ return None
504
+
505
+
506
+ def install_go(repo_path, install_dir):
507
+ """Install Go project"""
508
+ print(" Installing Go project...")
509
+
510
+ try:
511
+ subprocess.run(
512
+ ["go", "build", "-o", str(install_dir / repo_path.name), "."],
513
+ cwd=repo_path,
514
+ check=True,
515
+ )
516
+
517
+ binary_path = install_dir / repo_path.name
518
+ if binary_path.exists():
519
+ print_success(f"Installed to {binary_path}")
520
+ return binary_path
521
+
522
+ return install_dir
523
+ except subprocess.CalledProcessError as e:
524
+ print_error(f"Go installation failed: {e}")
525
+ return None
526
+
527
+
528
+ def install_rust(repo_path, install_dir):
529
+ """Install Rust project"""
530
+ print(" Installing Rust project...")
531
+
532
+ try:
533
+ subprocess.run(["cargo", "build", "--release"], cwd=repo_path, check=True)
534
+
535
+ target_dir = repo_path / "target" / "release"
536
+ binaries = list(target_dir.glob("*"))
537
+ binaries = [b for b in binaries if b.is_file() and not b.suffix]
538
+
539
+ if binaries:
540
+ for binary in binaries:
541
+ dest = install_dir / binary.name
542
+ shutil.copy2(binary, dest)
543
+ print_success(f"Installed {binary.name} to {dest}")
544
+
545
+ return install_dir
546
+
547
+ return None
548
+ except subprocess.CalledProcessError as e:
549
+ print_error(f"Rust installation failed: {e}")
550
+ return None
551
+
552
+
553
+ def _is_executable(item):
554
+ """Check if a file is likely an executable binary or script."""
555
+ if not item.is_file():
556
+ return False
557
+ if os.access(item, os.X_OK):
558
+ return True
559
+ executable_extensions = {".exe", ".bin", ".sh", ".py", ".pl", ".rb", ".app"}
560
+ if item.suffix.lower() in executable_extensions:
561
+ return True
562
+ if "." not in item.name:
563
+ return True
564
+ return False
565
+
566
+
567
+ def install_binary(repo_path, install_dir):
568
+ """Install pre-built binary"""
569
+ print(" Installing pre-built binary...")
570
+
571
+ binary_dirs = ["release", "bin", "dist"]
572
+
573
+ for dir_name in binary_dirs:
574
+ binary_dir = repo_path / dir_name
575
+ if binary_dir.exists():
576
+ for item in binary_dir.iterdir():
577
+ if _is_executable(item):
578
+ dest = install_dir / item.name
579
+ shutil.copy2(item, dest)
580
+ print_success(f"Installed {item.name} to {dest}")
581
+
582
+ return install_dir
583
+
584
+ # Fallback: copy entire directory
585
+ dest = install_dir / repo_path.name
586
+ shutil.copytree(repo_path, dest, dirs_exist_ok=True)
587
+ print_success(f"Installed to {dest}")
588
+ return dest
589
+
590
+
591
+ def install_make(repo_path, install_dir):
592
+ """Install using Makefile"""
593
+ print(" Installing using Makefile...")
594
+
595
+ try:
596
+ subprocess.run(["make", "install", f"PREFIX={install_dir}"], cwd=repo_path, check=True)
597
+ print_success(f"Installed to {install_dir}")
598
+ return install_dir
599
+ except subprocess.CalledProcessError:
600
+ subprocess.run(["make"], cwd=repo_path, check=True)
601
+ return install_binary(repo_path, install_dir)
602
+
603
+
604
+ def _get_disk_size(path):
605
+ """Get disk size of a path in human-readable format."""
606
+ try:
607
+ total = 0
608
+ p = Path(path)
609
+ if p.is_file():
610
+ total = p.stat().st_size
611
+ elif p.is_dir():
612
+ for dirpath, _, filenames in os.walk(p):
613
+ for f in filenames:
614
+ fp = os.path.join(dirpath, f)
615
+ if not os.path.islink(fp):
616
+ total += os.path.getsize(fp)
617
+ if total >= 1024 * 1024 * 1024:
618
+ return f"{total / (1024 * 1024 * 1024):.1f} GB"
619
+ elif total >= 1024 * 1024:
620
+ return f"{total / (1024 * 1024):.1f} MB"
621
+ elif total >= 1024:
622
+ return f"{total / 1024:.1f} KB"
623
+ return f"{total} B"
624
+ except OSError:
625
+ return "unknown"
626
+
627
+
628
+ def download_and_install(
629
+ repo_url,
630
+ install_dir=None,
631
+ dry_run=False,
632
+ shallow=False,
633
+ ref=None,
634
+ method_override=None,
635
+ timeout=None,
636
+ retries=0,
637
+ ):
638
+ """Download and install a repository from any git hosting URL"""
639
+
640
+ if install_dir is None:
641
+ user_config = _load_user_config()
642
+ config_dir = user_config.get("install_dir")
643
+ if config_dir:
644
+ install_dir = Path(config_dir).expanduser()
645
+ else:
646
+ install_dir = DEFAULT_INSTALL_DIR
647
+
648
+ # Parse repository URL
649
+ repo_info = parse_repo_url(repo_url)
650
+ if not repo_info:
651
+ print_error(f"Invalid repository URL: {repo_url}")
652
+ return None
653
+
654
+ repo_type = "Gist" if repo_info.get("is_gist") else "Repository"
655
+ host_label = repo_info.get("host", "unknown")
656
+ print(f" {repo_type}: {repo_info['owner']}/{repo_info['repo']} ({host_label})")
657
+
658
+ # Validate repo name to prevent path traversal
659
+ safe_name = _sanitize_repo_name(repo_info["repo"])
660
+ if not safe_name:
661
+ print_error(f"Invalid repository name: {repo_info['repo']}")
662
+ return None
663
+
664
+ # Dry-run check before doing any I/O
665
+ if dry_run:
666
+ print(f" [DRY RUN] Would install to: {install_dir / safe_name}")
667
+ print(f" [DRY RUN] Would use method: {method_override or '(auto-detected after clone)'}")
668
+ return install_dir / safe_name
669
+
670
+ # Create install directory if it doesn't exist
671
+ install_dir.mkdir(parents=True, exist_ok=True)
672
+
673
+ # Clone to temp directory
674
+ temp_dir = Path(tempfile.mkdtemp())
675
+ repo_path = temp_dir / safe_name
676
+
677
+ clone_cmd = ["git", "clone"]
678
+ if shallow:
679
+ clone_cmd.extend(["--depth", "1"])
680
+ if ref:
681
+ clone_cmd.extend(["--branch", ref])
682
+ clone_cmd.extend([repo_info["url"], str(repo_path)])
683
+
684
+ attempts = retries + 1
685
+ for attempt in range(attempts):
686
+ if attempts > 1:
687
+ print(f" Downloading... (attempt {attempt + 1}/{attempts})")
688
+ else:
689
+ print(" Downloading...")
690
+
691
+ try:
692
+ subprocess.run(clone_cmd, check=True, timeout=timeout)
693
+ break
694
+ except subprocess.TimeoutExpired:
695
+ print_error(f"Clone timed out after {timeout}s")
696
+ if attempt < attempts - 1:
697
+ time.sleep(2)
698
+ continue
699
+ shutil.rmtree(temp_dir, ignore_errors=True)
700
+ return None
701
+ except subprocess.CalledProcessError as e:
702
+ if attempt < attempts - 1:
703
+ print_warning("Clone failed, retrying...")
704
+ time.sleep(2)
705
+ continue
706
+ print_error(f"Failed to clone repository: {repo_info['url']}")
707
+ if e.stderr:
708
+ print_error(e.stderr.strip())
709
+ shutil.rmtree(temp_dir, ignore_errors=True)
710
+ return None
711
+
712
+ # Detect install method
713
+ user_config = _load_user_config()
714
+ method_priority = user_config.get("method_priority")
715
+ install_method = method_override or detect_install_method(repo_path, method_priority)
716
+ print(f" Detected install method: {install_method}")
717
+
718
+ # Install based on method
719
+ install_funcs = {
720
+ "python": install_python,
721
+ "node": install_node,
722
+ "go": install_go,
723
+ "rust": install_rust,
724
+ "binary": install_binary,
725
+ "make": install_make,
726
+ "script": install_script,
727
+ "download": install_binary,
728
+ }
729
+
730
+ install_func = install_funcs.get(install_method, install_binary)
731
+ installed_path = install_func(repo_path, install_dir)
732
+
733
+ # Clean up temp directory
734
+ shutil.rmtree(temp_dir)
735
+
736
+ # Register the installation
737
+ if installed_path:
738
+ register_app(repo_info["repo"], repo_url, installed_path, install_method)
739
+ # Post-install summary
740
+ print()
741
+ print(f" {Colors.CYAN}Summary:{Colors.END}")
742
+ print(f" Name: {repo_info['repo']}")
743
+ print(f" Method: {install_method}")
744
+ print(f" Location: {installed_path}")
745
+ print(f" Size: {_get_disk_size(installed_path)}")
746
+ return installed_path
747
+
748
+ return None
749
+
750
+
751
+ def update_app(
752
+ repo_name,
753
+ install_dir=None,
754
+ dry_run=False,
755
+ force=False,
756
+ shallow=False,
757
+ ref=None,
758
+ timeout=None,
759
+ retries=0,
760
+ ):
761
+ """Update an installed application"""
762
+ registry = load_registry()
763
+
764
+ if repo_name not in registry["apps"]:
765
+ print_error(f"{repo_name} is not installed")
766
+ return False
767
+
768
+ app_info = registry["apps"][repo_name]
769
+ url = app_info["url"]
770
+ old_path = Path(app_info["path"])
771
+
772
+ print_header(f"Updating {repo_name}")
773
+ print(f" Current: {app_info['installed_at']}")
774
+ print(f" URL: {url}")
775
+
776
+ if dry_run:
777
+ print(f" [DRY RUN] Would re-install from: {url}")
778
+ print(f" [DRY RUN] Would update: {old_path}")
779
+ return True
780
+
781
+ # Remove old installation
782
+ if old_path.exists() and old_path.resolve() not in SHARED_PATHS:
783
+ if old_path.is_file():
784
+ old_path.unlink()
785
+ else:
786
+ shutil.rmtree(old_path, ignore_errors=True)
787
+
788
+ # Remove from registry before re-installing
789
+ del registry["apps"][repo_name]
790
+ save_registry(registry)
791
+
792
+ # Re-install
793
+ target_dir = old_path.parent if old_path.parent.exists() else install_dir
794
+ result = download_and_install(
795
+ url,
796
+ install_dir=target_dir,
797
+ shallow=shallow,
798
+ ref=ref,
799
+ timeout=timeout,
800
+ retries=retries,
801
+ )
802
+
803
+ if result:
804
+ print_success(f"Updated {repo_name}")
805
+ return True
806
+ else:
807
+ print_error(f"Failed to update {repo_name}")
808
+ # Restore old registry entry
809
+ registry["apps"][repo_name] = app_info
810
+ save_registry(registry)
811
+ return False
812
+
813
+
814
+ def info_app(repo_name, json_output=False):
815
+ """Show detailed info about an installed app"""
816
+ registry = load_registry()
817
+
818
+ if repo_name not in registry["apps"]:
819
+ if json_output:
820
+ print(json.dumps({"error": f"{repo_name} is not installed"}))
821
+ else:
822
+ print_error(f"{repo_name} is not installed")
823
+ return False
824
+
825
+ app_info = registry["apps"][repo_name]
826
+ install_path = Path(app_info["path"])
827
+
828
+ if json_output:
829
+ data = {
830
+ "name": repo_name,
831
+ "url": app_info["url"],
832
+ "method": app_info["method"],
833
+ "path": app_info["path"],
834
+ "installed_at": app_info["installed_at"],
835
+ "size": _get_disk_size(install_path),
836
+ "exists": install_path.exists(),
837
+ }
838
+ print(json.dumps(data, indent=2))
839
+ return True
840
+
841
+ print_header(f"App Info: {repo_name}")
842
+ labels = ["URL", "Method", "Path", "Installed", "Size", "Exists"]
843
+ values = [
844
+ app_info["url"],
845
+ app_info["method"],
846
+ app_info["path"],
847
+ app_info["installed_at"],
848
+ _get_disk_size(install_path),
849
+ "Yes" if install_path.exists() else "No (files may have been moved)",
850
+ ]
851
+ max_label = max(len(label) for label in labels)
852
+ for label, value in zip(labels, values):
853
+ print(f" {Colors.CYAN}{label}:{Colors.END}{' ' * (max_label - len(label) + 1)}{value}")
854
+
855
+ return True
856
+
857
+
858
+ def doctor(json_output=False):
859
+ """Check if all required and optional tools are available"""
860
+ tools = {
861
+ "git": ("Required", "Cloning repositories"),
862
+ "python3": ("Required", "Running this tool"),
863
+ "npm": ("Optional", "Node.js project installs"),
864
+ "go": ("Optional", "Go project installs"),
865
+ "cargo": ("Optional", "Rust project installs"),
866
+ "make": ("Optional", "Makefile-based installs"),
867
+ }
868
+
869
+ all_ok = True
870
+ results = []
871
+ for tool, (req, purpose) in tools.items():
872
+ # Check the canonical name, but fall back for python3 → python
873
+ exe = tool
874
+ found = bool(shutil.which(exe))
875
+ if not found and exe == "python3":
876
+ found = bool(shutil.which("python"))
877
+ results.append({"tool": tool, "required": req, "found": found, "purpose": purpose})
878
+ results.append({"tool": tool, "required": req, "found": found, "purpose": purpose})
879
+ if not found and req == "Required":
880
+ all_ok = False
881
+
882
+ if json_output:
883
+ print(json.dumps({"tools": results, "all_ok": all_ok}, indent=2))
884
+ return all_ok
885
+
886
+ print_header("Doctor — Tool Availability Check")
887
+
888
+ for r in results:
889
+ status = f"{Colors.GREEN}✓{Colors.END}" if r["found"] else f"{Colors.RED}✗{Colors.END}"
890
+ label = f"{Colors.YELLOW}[{r['required']}]{Colors.END}"
891
+ print(f" {status} {r['tool']:<10} {label:<12} {r['purpose']}")
892
+
893
+ print()
894
+ if all_ok:
895
+ print_success("All required tools are available")
896
+ else:
897
+ print_error("Some required tools are missing")
898
+
899
+ return all_ok
900
+
901
+
902
+ def config_command(key=None, value=None):
903
+ """View or set configuration values"""
904
+ config = _load_user_config()
905
+
906
+ if key is None:
907
+ # Show all config
908
+ print_header("Configuration")
909
+ if not config:
910
+ print_warning("No configuration set")
911
+ print(f"\n Config file: {CONFIG_FILE}")
912
+ print(f" Install dir (default): {DEFAULT_INSTALL_DIR}")
913
+ else:
914
+ for k, v in config.items():
915
+ print(f" {k}: {v}")
916
+ print(f"\n Config file: {CONFIG_FILE}")
917
+ return
918
+
919
+ if value is None:
920
+ # Show specific key
921
+ if key in config:
922
+ print(f" {key}: {config[key]}")
923
+ else:
924
+ print_warning(f"Config key '{key}' is not set")
925
+ return
926
+
927
+ # Set value
928
+ config[key] = value
929
+ _save_user_config(config)
930
+ print_success(f"Set {key} = {value}")
931
+
932
+
933
+ def _search_print_result(index, name, desc, stars, lang, url, star_char="★"):
934
+ """Print a formatted search result."""
935
+ print(f" {index}. {Colors.GREEN}{name}{Colors.END}")
936
+ print(f" {desc}")
937
+ print(f" {Colors.CYAN}{star_char}{Colors.END} {stars:,} | Language: {lang}")
938
+ print(f" URL: {url}")
939
+ print()
940
+
941
+
942
+ def search_github(query, limit=10):
943
+ """Search repositories using the GitHub API."""
944
+ print(f" Searching GitHub for '{query}'...")
945
+ url = f"https://api.github.com/search/repositories?q={urllib.parse.quote(query)}&sort=stars&order=desc&per_page={limit}"
946
+ try:
947
+ req = urllib.request.Request(url, headers={"Accept": "application/vnd.github.v3+json"})
948
+ with urllib.request.urlopen(req, timeout=10) as resp:
949
+ data = json.loads(resp.read().decode())
950
+ except Exception as e:
951
+ print_error(f"Search failed: {e}")
952
+ return
953
+
954
+ items = data.get("items", [])
955
+ if not items:
956
+ print_warning("No results found")
957
+ return
958
+
959
+ print_header(f"GitHub Results — '{query}' ({len(items)} found)")
960
+ for i, repo in enumerate(items, 1):
961
+ _search_print_result(
962
+ i,
963
+ repo["full_name"],
964
+ repo.get("description") or "No description",
965
+ repo["stargazers_count"],
966
+ repo.get("language") or "Unknown",
967
+ repo["html_url"],
968
+ )
969
+
970
+
971
+ def search_gitlab(query, limit=10):
972
+ """Search repositories using the GitLab API."""
973
+ print(f" Searching GitLab for '{query}'...")
974
+ url = f"https://gitlab.com/api/v4/projects?search={urllib.parse.quote(query)}&per_page={limit}&order_by=stars&sort=desc"
975
+ # Note: GitLab search is unauthenticated but rate-limited (600 req/h per IP)
976
+ try:
977
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
978
+ with urllib.request.urlopen(req, timeout=10) as resp:
979
+ data = json.loads(resp.read().decode())
980
+ except Exception as e:
981
+ print_error(f"Search failed: {e}")
982
+ return
983
+
984
+ if not data:
985
+ print_warning("No results found")
986
+ return
987
+
988
+ results = []
989
+ for project in data:
990
+ # GitLab returns projects ordered by last_activity by default;
991
+ # sort with our own star sort since we requested order_by=stars
992
+ results.append({
993
+ "name": project.get("path_with_namespace", project["path"]),
994
+ "description": project.get("description") or "No description",
995
+ "stars": project.get("star_count", 0),
996
+ "language": project.get("programming_language") or project.get("language") or "Unknown",
997
+ "url": project.get("web_url", project.get("http_url_to_repo", "")),
998
+ })
999
+
1000
+ results.sort(key=lambda r: r["stars"], reverse=True)
1001
+
1002
+ print_header(f"GitLab Results — '{query}' ({len(results)} found)")
1003
+ for i, r in enumerate(results[:limit], 1):
1004
+ _search_print_result(i, r["name"], r["description"], r["stars"], r["language"], r["url"], star_char="\u2605")
1005
+
1006
+
1007
+ def search_codeberg(query, limit=10):
1008
+ """Search repositories using the Codeberg (Gitea/Forgejo) API."""
1009
+ print(f" Searching Codeberg for '{query}'...")
1010
+ url = f"https://codeberg.org/api/v1/repos/search?q={urllib.parse.quote(query)}&limit={limit}&sort=stars"
1011
+ try:
1012
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
1013
+ with urllib.request.urlopen(req, timeout=10) as resp:
1014
+ data = json.loads(resp.read().decode())
1015
+ except Exception as e:
1016
+ print_error(f"Search failed: {e}")
1017
+ return
1018
+
1019
+ items = data.get("data", []) if isinstance(data, dict) else data
1020
+ if not items or (isinstance(data, dict) and data.get("ok") is False):
1021
+ print_warning("No results found")
1022
+ return
1023
+
1024
+ ok_flag = data.get("ok", True) if isinstance(data, dict) else True
1025
+ if not ok_flag or not items:
1026
+ print_warning("No results found")
1027
+ return
1028
+
1029
+ print_header(f"Codeberg Results — '{query}' ({len(items)} found)")
1030
+ for i, repo in enumerate(items, 1):
1031
+ _search_print_result(
1032
+ i,
1033
+ repo.get("full_name", "unknown"),
1034
+ repo.get("description") or "No description",
1035
+ repo.get("stars_count", 0),
1036
+ repo.get("language") or "Unknown",
1037
+ repo.get("html_url", ""),
1038
+ )
1039
+
1040
+ url = f"https://api.github.com/search/repositories?q={urllib.parse.quote(query)}&sort=stars&order=desc&per_page={limit}"
1041
+ try:
1042
+ req = urllib.request.Request(url, headers={"Accept": "application/vnd.github.v3+json"})
1043
+ with urllib.request.urlopen(req, timeout=10) as resp:
1044
+ data = json.loads(resp.read().decode())
1045
+ except Exception as e:
1046
+ print_error(f"Search failed: {e}")
1047
+ return
1048
+
1049
+ items = data.get("items", [])
1050
+ if not items:
1051
+ print_warning("No results found")
1052
+ return
1053
+
1054
+ print_header(f"Search Results for '{query}' ({len(items)} found)")
1055
+ for i, repo in enumerate(items, 1):
1056
+ name = repo["full_name"]
1057
+ stars = repo["stargazers_count"]
1058
+ desc = repo.get("description") or "No description"
1059
+ lang = repo.get("language") or "Unknown"
1060
+ print(f" {i}. {Colors.GREEN}{name}{Colors.END}")
1061
+ print(f" {desc}")
1062
+ print(f" {Colors.CYAN}★{Colors.END} {stars:,} | Language: {lang}")
1063
+ print(f" URL: {repo['html_url']}")
1064
+ print()
1065
+
1066
+
1067
+ def export_registry(filepath):
1068
+ """Export the app registry to a file"""
1069
+ registry = load_registry()
1070
+ path = Path(filepath).expanduser()
1071
+ with open(path, "w") as f:
1072
+ json.dump(registry, f, indent=2)
1073
+ print_success(f"Exported {len(registry['apps'])} apps to {path}")
1074
+
1075
+
1076
+ def import_registry(filepath):
1077
+ """Import the app registry from a file"""
1078
+ path = Path(filepath).expanduser()
1079
+ if not path.exists():
1080
+ print_error(f"File not found: {path}")
1081
+ return False
1082
+
1083
+ with open(path) as f:
1084
+ data = json.load(f)
1085
+
1086
+ if "apps" not in data:
1087
+ print_error("Invalid registry file format")
1088
+ return False
1089
+
1090
+ registry = load_registry()
1091
+ imported = 0
1092
+ for name, info in data["apps"].items():
1093
+ if name not in registry["apps"]:
1094
+ registry["apps"][name] = info
1095
+ imported += 1
1096
+ else:
1097
+ print_warning(f"Skipping {name} (already installed)")
1098
+
1099
+ save_registry(registry)
1100
+ print_success(f"Imported {imported} new apps")
1101
+ return True
1102
+
1103
+
1104
+ def register_app(repo_name, repo_url, install_path, install_method, skip_hook=False):
1105
+ """Register an installed application"""
1106
+
1107
+ registry = load_registry()
1108
+
1109
+ registry["apps"][repo_name] = {
1110
+ "url": repo_url,
1111
+ "path": str(install_path),
1112
+ "method": install_method,
1113
+ "installed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
1114
+ }
1115
+
1116
+ save_registry(registry)
1117
+ print_success(f"Registered {repo_name}")
1118
+
1119
+ if not skip_hook:
1120
+ _run_post_install_hook(repo_name, str(install_path), install_method)
1121
+
1122
+
1123
+ def _run_post_install_hook(repo_name, install_path, method):
1124
+ """Run user-defined post-install hook if configured."""
1125
+ hook_dir = CONFIG_FILE.parent / "pluck" / "hooks"
1126
+ hook_file = hook_dir / "post-install.sh"
1127
+
1128
+ if hook_file.exists():
1129
+ env = os.environ.copy()
1130
+ env["PLUCK_APP"] = repo_name
1131
+ env["PLUCK_PATH"] = install_path
1132
+ env["PLUCK_METHOD"] = method
1133
+ # Legacy aliases — remove in a future release
1134
+ env["GH_INSTALL_APP"] = repo_name
1135
+ env["GH_INSTALL_PATH"] = install_path
1136
+ env["GH_INSTALL_METHOD"] = method
1137
+
1138
+ try:
1139
+ subprocess.run(["bash", str(hook_file)], env=env, check=True)
1140
+ except subprocess.CalledProcessError as e:
1141
+ print_warning(f"Post-install hook failed with exit code {e.returncode}")
1142
+ except FileNotFoundError:
1143
+ print_warning("Post-install hook requires bash")
1144
+
1145
+
1146
+ def clean_registry(dry_run=False, force=False, json_output=False):
1147
+ """Remove orphaned registry entries (apps whose paths no longer exist)"""
1148
+ registry = load_registry()
1149
+ orphaned = []
1150
+
1151
+ for name, info in registry["apps"].items():
1152
+ install_path = Path(info["path"])
1153
+ if not install_path.exists():
1154
+ orphaned.append({"name": name, "path": info["path"]})
1155
+
1156
+ if not orphaned:
1157
+ if json_output:
1158
+ print(json.dumps({"orphaned": []}))
1159
+ else:
1160
+ print_success("No orphaned entries found")
1161
+ return 0
1162
+
1163
+ if json_output:
1164
+ data = {"orphaned": orphaned, "count": len(orphaned)}
1165
+ if dry_run:
1166
+ data["dry_run"] = True
1167
+ print(json.dumps(data, indent=2))
1168
+ return len(orphaned)
1169
+
1170
+ print_header(f"Found {len(orphaned)} orphaned entries")
1171
+ for entry in orphaned:
1172
+ print(f" {Colors.RED}{entry['name']}{Colors.END} — {entry['path']} (missing)")
1173
+
1174
+ if dry_run:
1175
+ print(f"\n {Colors.YELLOW}[DRY RUN] Would remove {len(orphaned)} entries{Colors.END}")
1176
+ return len(orphaned)
1177
+
1178
+ if not force:
1179
+ confirm = input(f"\nRemove {len(orphaned)} orphaned entries? [y/N]: ")
1180
+ if confirm.lower() != "y":
1181
+ print("Cancelled")
1182
+ return 0
1183
+
1184
+ for entry in orphaned:
1185
+ del registry["apps"][entry["name"]]
1186
+
1187
+ save_registry(registry)
1188
+ print_success(f"Removed {len(orphaned)} orphaned entries")
1189
+ return len(orphaned)
1190
+
1191
+
1192
+ def load_registry():
1193
+ """Load the app registry"""
1194
+ if APP_REGISTRY_FILE.exists():
1195
+ with open(APP_REGISTRY_FILE) as f:
1196
+ return json.load(f)
1197
+
1198
+ return {"apps": {}}
1199
+
1200
+
1201
+ def save_registry(registry):
1202
+ """Save the app registry"""
1203
+ with open(APP_REGISTRY_FILE, "w") as f:
1204
+ json.dump(registry, f, indent=2)
1205
+
1206
+
1207
+ def list_installed(json_output=False):
1208
+ """List all installed applications"""
1209
+ registry = load_registry()
1210
+
1211
+ if not registry["apps"]:
1212
+ if json_output:
1213
+ print(json.dumps({"apps": []}))
1214
+ else:
1215
+ print_warning("No applications installed yet")
1216
+ return
1217
+
1218
+ if json_output:
1219
+ apps = []
1220
+ for name, info in registry["apps"].items():
1221
+ install_path = Path(info["path"])
1222
+ apps.append(
1223
+ {
1224
+ "name": name,
1225
+ "url": info["url"],
1226
+ "method": info["method"],
1227
+ "path": info["path"],
1228
+ "size": _get_disk_size(install_path),
1229
+ "exists": install_path.exists(),
1230
+ "installed_at": info["installed_at"],
1231
+ }
1232
+ )
1233
+ print(json.dumps({"apps": apps}, indent=2))
1234
+ return
1235
+
1236
+ print_header(f"Installed Applications ({len(registry['apps'])})")
1237
+
1238
+ for name, info in registry["apps"].items():
1239
+ install_path = Path(info["path"])
1240
+ size = _get_disk_size(install_path)
1241
+ exists = "✓" if install_path.exists() else "✗"
1242
+ print(f"\n{Colors.GREEN}{name}{Colors.END} [{exists}]")
1243
+ print(f" URL: {info['url']}")
1244
+ print(f" Method: {info['method']}")
1245
+ print(f" Path: {info['path']}")
1246
+ print(f" Size: {size}")
1247
+ print(f" Installed: {info['installed_at']}")
1248
+
1249
+
1250
+ def uninstall_app(repo_name, force=False):
1251
+ """Uninstall an application"""
1252
+ registry = load_registry()
1253
+
1254
+ if repo_name not in registry["apps"]:
1255
+ print_error(f"{repo_name} is not installed")
1256
+ return False
1257
+
1258
+ app_info = registry["apps"][repo_name]
1259
+
1260
+ # Ask for confirmation
1261
+ if not force:
1262
+ confirm = input(f"Uninstall {repo_name}? [y/N]: ")
1263
+ if confirm.lower() != "y":
1264
+ print("Cancelled")
1265
+ return False
1266
+
1267
+ # Remove installed files — but never delete shared system directories
1268
+ install_path = Path(app_info["path"])
1269
+ if install_path.resolve() in SHARED_PATHS or install_path.resolve() == Path.home():
1270
+ print_error(f"Refusing to uninstall: {install_path} is a shared directory")
1271
+ print_warning("Remove files from this directory manually instead")
1272
+ return False
1273
+
1274
+ if install_path.exists():
1275
+ if install_path.is_file():
1276
+ install_path.unlink()
1277
+ else:
1278
+ shutil.rmtree(install_path, ignore_errors=True)
1279
+
1280
+ # Remove from registry
1281
+ del registry["apps"][repo_name]
1282
+ save_registry(registry)
1283
+
1284
+ print_success(f"Uninstalled {repo_name}")
1285
+ return True
1286
+
1287
+
1288
+ def _parse_args(args):
1289
+ """Parse all CLI flags from a list of arguments."""
1290
+ install_dir = None
1291
+ dry_run = False
1292
+ force = False
1293
+ yes = False
1294
+ shallow = False
1295
+ ref = None
1296
+ method = None
1297
+ json_output = False
1298
+ no_color = False
1299
+ timeout = None
1300
+ retries = 0
1301
+ urls = []
1302
+
1303
+ i = 0
1304
+ while i < len(args):
1305
+ if args[i] == "--dir" and i + 1 < len(args):
1306
+ install_dir = Path(args[i + 1]).expanduser()
1307
+ i += 2
1308
+ elif args[i] == "--dry-run":
1309
+ dry_run = True
1310
+ i += 1
1311
+ elif args[i] == "--force":
1312
+ force = True
1313
+ i += 1
1314
+ elif args[i] == "--yes":
1315
+ yes = True
1316
+ i += 1
1317
+ elif args[i] == "--shallow":
1318
+ shallow = True
1319
+ i += 1
1320
+ elif args[i] == "--ref" and i + 1 < len(args):
1321
+ ref = args[i + 1]
1322
+ i += 2
1323
+ elif args[i] == "--method" and i + 1 < len(args):
1324
+ method = args[i + 1]
1325
+ i += 2
1326
+ elif args[i] == "--json":
1327
+ json_output = True
1328
+ i += 1
1329
+ elif args[i] == "--no-color":
1330
+ no_color = True
1331
+ i += 1
1332
+ elif args[i] == "--timeout" and i + 1 < len(args):
1333
+ try:
1334
+ timeout = int(args[i + 1])
1335
+ except ValueError:
1336
+ pass
1337
+ i += 2
1338
+ elif args[i] == "--retries" and i + 1 < len(args):
1339
+ try:
1340
+ retries = int(args[i + 1])
1341
+ except ValueError:
1342
+ pass
1343
+ i += 2
1344
+ else:
1345
+ urls.append(args[i])
1346
+ i += 1
1347
+
1348
+ if yes:
1349
+ force = True
1350
+ if no_color:
1351
+ _enable_colors(False)
1352
+
1353
+ return install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, urls
1354
+
1355
+
1356
+ def verify_apps(json_output=False):
1357
+ """Check if installed apps are still valid (files exist, not corrupted)."""
1358
+ registry = load_registry()
1359
+ results = []
1360
+
1361
+ for name, info in registry["apps"].items():
1362
+ install_path = Path(info["path"])
1363
+ exists = install_path.exists()
1364
+ size = _get_disk_size(install_path) if exists else "N/A"
1365
+ results.append({
1366
+ "name": name,
1367
+ "url": info["url"],
1368
+ "path": info["path"],
1369
+ "exists": exists,
1370
+ "size": size,
1371
+ "installed_at": info["installed_at"],
1372
+ })
1373
+
1374
+ valid_count = sum(1 for r in results if r["exists"])
1375
+ invalid_count = len(results) - valid_count
1376
+
1377
+ if json_output:
1378
+ print(json.dumps({
1379
+ "total": len(results),
1380
+ "valid": valid_count,
1381
+ "invalid": invalid_count,
1382
+ "apps": results,
1383
+ }, indent=2))
1384
+ return valid_count == len(results)
1385
+
1386
+ print_header(f"Verification ({len(results)} apps)")
1387
+ for r in results:
1388
+ status = f"{Colors.GREEN}✓{Colors.END}" if r["exists"] else f"{Colors.RED}✗{Colors.END}"
1389
+ print(f" {status} {Colors.CYAN}{r['name']}{Colors.END} — {r['path']} ({r['size']})")
1390
+
1391
+ print()
1392
+ if invalid_count == 0:
1393
+ print_success(f"All {valid_count} apps are valid")
1394
+ else:
1395
+ print_warning(f"{valid_count} valid, {invalid_count} missing")
1396
+
1397
+ return valid_count == len(results)
1398
+
1399
+
1400
+ def stats_command(json_output=False):
1401
+ """Show installation statistics."""
1402
+ registry = load_registry()
1403
+ apps = registry["apps"]
1404
+
1405
+ total = len(apps)
1406
+ valid = 0
1407
+ orphaned = 0
1408
+ total_size = 0
1409
+ method_counts = {}
1410
+
1411
+ for name, info in apps.items():
1412
+ install_path = Path(info["path"])
1413
+ method = info.get("method", "unknown")
1414
+ method_counts[method] = method_counts.get(method, 0) + 1
1415
+
1416
+ if install_path.exists():
1417
+ valid += 1
1418
+ try:
1419
+ if install_path.is_file():
1420
+ total_size += install_path.stat().st_size
1421
+ elif install_path.is_dir():
1422
+ for dirpath, _, filenames in os.walk(install_path):
1423
+ for f in filenames:
1424
+ fp = os.path.join(dirpath, f)
1425
+ if not os.path.islink(fp):
1426
+ total_size += os.path.getsize(fp)
1427
+ except OSError:
1428
+ pass
1429
+ else:
1430
+ orphaned += 1
1431
+
1432
+ if json_output:
1433
+ print(json.dumps({
1434
+ "total_apps": total,
1435
+ "valid": valid,
1436
+ "orphaned": orphaned,
1437
+ "total_size_bytes": total_size,
1438
+ "total_size_human": _format_bytes(total_size),
1439
+ "by_method": method_counts,
1440
+ }, indent=2))
1441
+ return
1442
+
1443
+ print_header("Installation Statistics")
1444
+ print(f" Total apps: {total}")
1445
+ print(f" Valid: {valid}")
1446
+ print(f" Orphaned: {orphaned}")
1447
+ print(f" Total size: {_format_bytes(total_size)}")
1448
+ print()
1449
+ print(f" {Colors.CYAN}By Method:{Colors.END}")
1450
+ for method, count in sorted(method_counts.items(), key=lambda x: -x[1]):
1451
+ print(f" {method:<10} {count}")
1452
+
1453
+
1454
+ def _format_bytes(size):
1455
+ """Format byte count into human-readable string."""
1456
+ if size >= 1024 * 1024 * 1024:
1457
+ return f"{size / (1024 * 1024 * 1024):.1f} GB"
1458
+ elif size >= 1024 * 1024:
1459
+ return f"{size / (1024 * 1024):.1f} MB"
1460
+ elif size >= 1024:
1461
+ return f"{size / 1024:.1f} KB"
1462
+ return f"{size} B"
1463
+
1464
+
1465
+ def _extract_global_flags(args):
1466
+ """Extract global flags (--json, --no-color) from an arg list, returning (cleaned_args, json_output, no_color)."""
1467
+ json_output = False
1468
+ no_color = False
1469
+ cleaned = []
1470
+ i = 0
1471
+ while i < len(args):
1472
+ if args[i] == "--json":
1473
+ json_output = True
1474
+ elif args[i] == "--no-color":
1475
+ no_color = True
1476
+ else:
1477
+ cleaned.append(args[i])
1478
+ i += 1
1479
+ if no_color:
1480
+ _enable_colors(False)
1481
+ return cleaned, json_output
1482
+
1483
+
1484
+ def _migrate_old_registry():
1485
+ """Migrate from old .gh-install-registry.json to .pluck-registry.json."""
1486
+ if _CONFIG_OLD_REGISTRY.exists() and not APP_REGISTRY_FILE.exists():
1487
+ try:
1488
+ data = _CONFIG_OLD_REGISTRY.read_text()
1489
+ APP_REGISTRY_FILE.write_text(data)
1490
+ _CONFIG_OLD_REGISTRY.unlink()
1491
+ print_warning("Migrated registry from .gh-install-registry.json to .pluck-registry.json")
1492
+ except OSError:
1493
+ pass
1494
+ if _CONFIG_OLD_DIR.exists() and not CONFIG_FILE.exists():
1495
+ try:
1496
+ config_data = _CONFIG_OLD_DIR / "config.json"
1497
+ if config_data.exists():
1498
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
1499
+ config_data.rename(CONFIG_FILE)
1500
+ _CONFIG_OLD_DIR.rmdir()
1501
+ print_warning("Migrated config from ~/.config/gh-install/ to ~/.config/pluck/")
1502
+ except OSError:
1503
+ pass
1504
+
1505
+
1506
+ def main():
1507
+ """Main entry point"""
1508
+ # Auto-migrate from old gh-install paths
1509
+ _migrate_old_registry()
1510
+
1511
+ # Initialize flags shared across command branches
1512
+ json_output = False
1513
+ force = False
1514
+ dry_run = False
1515
+
1516
+ if len(sys.argv) < 2:
1517
+ print_usage()
1518
+ sys.exit(0)
1519
+
1520
+ # Handle global --version flag before command dispatch
1521
+ if sys.argv[1] in ("--version", "-v"):
1522
+ print(f"pluck v{__version__}")
1523
+ sys.exit(0)
1524
+
1525
+ command = sys.argv[1]
1526
+
1527
+ if command == "install":
1528
+ install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, urls = (
1529
+ _parse_args(sys.argv[2:])
1530
+ )
1531
+
1532
+ if not urls:
1533
+ print_error("Please provide a repository URL")
1534
+ sys.exit(1)
1535
+
1536
+ if method and method not in VALID_METHODS:
1537
+ print_error(f"Invalid method: {method}. Valid: {', '.join(sorted(VALID_METHODS))}")
1538
+ sys.exit(1)
1539
+
1540
+ if dry_run:
1541
+ print_header("Dry Run — No changes will be made")
1542
+
1543
+ for url in urls:
1544
+ print(f"\nInstalling: {url}")
1545
+ download_and_install(
1546
+ url,
1547
+ install_dir=install_dir,
1548
+ dry_run=dry_run,
1549
+ shallow=shallow,
1550
+ ref=ref,
1551
+ method_override=method,
1552
+ timeout=timeout,
1553
+ retries=retries,
1554
+ )
1555
+
1556
+ elif command == "update":
1557
+ install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, rest = (
1558
+ _parse_args(sys.argv[2:])
1559
+ )
1560
+ if not rest:
1561
+ print_error("Please provide an app name")
1562
+ sys.exit(1)
1563
+
1564
+ if dry_run:
1565
+ print_header("Dry Run — No changes will be made")
1566
+
1567
+ for name in rest:
1568
+ update_app(
1569
+ name,
1570
+ install_dir=install_dir,
1571
+ dry_run=dry_run,
1572
+ force=force,
1573
+ shallow=shallow,
1574
+ ref=ref,
1575
+ timeout=timeout,
1576
+ retries=retries,
1577
+ )
1578
+
1579
+ elif command == "info":
1580
+ rest, json_output = _extract_global_flags(sys.argv[2:])
1581
+ if not rest:
1582
+ print_error("Please provide an app name")
1583
+ sys.exit(1)
1584
+ info_app(rest[0], json_output=json_output)
1585
+
1586
+ elif command == "list":
1587
+ _, json_output = _extract_global_flags(sys.argv[2:])
1588
+ list_installed(json_output=json_output)
1589
+
1590
+ elif command in ("uninstall", "remove"):
1591
+ install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, rest = (
1592
+ _parse_args(sys.argv[2:])
1593
+ )
1594
+ if not rest:
1595
+ print_error("Please provide an app name")
1596
+ sys.exit(1)
1597
+
1598
+ for name in rest:
1599
+ uninstall_app(name, force=force)
1600
+
1601
+ elif command == "verify":
1602
+ _, json_output = _extract_global_flags(sys.argv[2:])
1603
+ verify_apps(json_output=json_output)
1604
+
1605
+ elif command == "clean":
1606
+ install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, rest = (
1607
+ _parse_args(sys.argv[2:])
1608
+ )
1609
+ clean_registry(dry_run=dry_run, force=force, json_output=json_output)
1610
+
1611
+ elif command == "stats":
1612
+ _, json_output = _extract_global_flags(sys.argv[2:])
1613
+ stats_command(json_output=json_output)
1614
+
1615
+ elif command == "doctor":
1616
+ _, json_output = _extract_global_flags(sys.argv[2:])
1617
+ doctor(json_output=json_output)
1618
+
1619
+ elif command == "config":
1620
+ key = sys.argv[2] if len(sys.argv) > 2 else None
1621
+ value = sys.argv[3] if len(sys.argv) > 3 else None
1622
+ config_command(key, value)
1623
+
1624
+ elif command == "search":
1625
+ args = sys.argv[2:]
1626
+ if not args:
1627
+ print_error("Please provide a search query")
1628
+ sys.exit(1)
1629
+
1630
+ forge = "github"
1631
+ if "--forge" in args:
1632
+ idx = args.index("--forge")
1633
+ if idx + 1 < len(args):
1634
+ forge = args[idx + 1].lower()
1635
+ args = args[:idx] + args[idx + 2:]
1636
+ else:
1637
+ print_error("Missing forge name after --forge (try: github, gitlab, codeberg)")
1638
+ sys.exit(1)
1639
+
1640
+ query = " ".join(args)
1641
+ forge_searchers = {
1642
+ "github": search_github,
1643
+ "gitlab": search_gitlab,
1644
+ "codeberg": search_codeberg,
1645
+ }
1646
+ searcher = forge_searchers.get(forge)
1647
+ if searcher:
1648
+ searcher(query)
1649
+ else:
1650
+ print_error(f"Unknown forge: {forge}. Supported: {', '.join(sorted(forge_searchers))}")
1651
+ sys.exit(1)
1652
+
1653
+ elif command == "export":
1654
+ if len(sys.argv) < 3:
1655
+ print_error("Please provide an output file path")
1656
+ sys.exit(1)
1657
+ export_registry(sys.argv[2])
1658
+
1659
+ elif command == "import":
1660
+ if len(sys.argv) < 3:
1661
+ print_error("Please provide an input file path")
1662
+ sys.exit(1)
1663
+ import_registry(sys.argv[2])
1664
+
1665
+ elif command == "completion":
1666
+ if len(sys.argv) < 3:
1667
+ print_error("Please specify a shell: bash or zsh")
1668
+ sys.exit(1)
1669
+ shell = sys.argv[2]
1670
+ script = _completion_script(shell)
1671
+ if script:
1672
+ print(script)
1673
+ else:
1674
+ print_error(f"Unsupported shell: {shell}")
1675
+ print("Supported shells: bash, zsh")
1676
+ sys.exit(1)
1677
+
1678
+ elif command == "version":
1679
+ print(f"pluck v{__version__}")
1680
+
1681
+ elif command == "help":
1682
+ print_usage()
1683
+
1684
+ else:
1685
+ print_error(f"Unknown command: {command}")
1686
+ print()
1687
+ print_usage()
1688
+ sys.exit(1)
1689
+
1690
+
1691
+ if __name__ == "__main__":
1692
+ main()