dotman-git 1.0.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.
Files changed (69) hide show
  1. dot_man/__init__.py +4 -0
  2. dot_man/backups.py +211 -0
  3. dot_man/branch_ops.py +347 -0
  4. dot_man/cli/__init__.py +113 -0
  5. dot_man/cli/add_cmd.py +167 -0
  6. dot_man/cli/audit_cmd.py +141 -0
  7. dot_man/cli/backup_cmd.py +105 -0
  8. dot_man/cli/branch_cmd.py +103 -0
  9. dot_man/cli/clean_cmd.py +97 -0
  10. dot_man/cli/common.py +548 -0
  11. dot_man/cli/completions_cmd.py +127 -0
  12. dot_man/cli/config_cmd.py +979 -0
  13. dot_man/cli/deploy_cmd.py +169 -0
  14. dot_man/cli/discover_cmd.py +105 -0
  15. dot_man/cli/doctor_cmd.py +229 -0
  16. dot_man/cli/edit_cmd.py +177 -0
  17. dot_man/cli/encrypt_cmd.py +205 -0
  18. dot_man/cli/export_cmd.py +146 -0
  19. dot_man/cli/import_cmd.py +315 -0
  20. dot_man/cli/init_cmd.py +532 -0
  21. dot_man/cli/interface.py +56 -0
  22. dot_man/cli/log_cmd.py +339 -0
  23. dot_man/cli/main.py +36 -0
  24. dot_man/cli/navigate_cmd.py +903 -0
  25. dot_man/cli/onboarding.py +546 -0
  26. dot_man/cli/profile_cmd.py +313 -0
  27. dot_man/cli/remote_cmd.py +454 -0
  28. dot_man/cli/restore_cmd.py +82 -0
  29. dot_man/cli/revert_cmd.py +86 -0
  30. dot_man/cli/show_cmd.py +29 -0
  31. dot_man/cli/status_cmd.py +185 -0
  32. dot_man/cli/switch_cmd.py +387 -0
  33. dot_man/cli/tag_cmd.py +164 -0
  34. dot_man/cli/template_cmd.py +244 -0
  35. dot_man/cli/tui_cmd.py +44 -0
  36. dot_man/cli/verify_cmd.py +156 -0
  37. dot_man/completions/_dot-man.zsh +28 -0
  38. dot_man/completions/dot-man.bash +15 -0
  39. dot_man/completions/dot-man.fish +58 -0
  40. dot_man/completions/install.sh +26 -0
  41. dot_man/config.py +23 -0
  42. dot_man/config_detector.py +426 -0
  43. dot_man/constants.py +109 -0
  44. dot_man/core.py +614 -0
  45. dot_man/dotman_config.py +516 -0
  46. dot_man/encryption.py +173 -0
  47. dot_man/exceptions.py +255 -0
  48. dot_man/files.py +443 -0
  49. dot_man/global_config.py +305 -0
  50. dot_man/hooks.py +232 -0
  51. dot_man/interactive.py +460 -0
  52. dot_man/lock.py +64 -0
  53. dot_man/merge.py +440 -0
  54. dot_man/operations.py +212 -0
  55. dot_man/py.typed +1 -0
  56. dot_man/save_deploy_ops.py +466 -0
  57. dot_man/secrets.py +473 -0
  58. dot_man/section.py +207 -0
  59. dot_man/status_ops.py +229 -0
  60. dot_man/tui_log.py +91 -0
  61. dot_man/ui.py +127 -0
  62. dot_man/utils.py +132 -0
  63. dot_man/vault.py +317 -0
  64. dotman_git-1.0.0.dist-info/METADATA +678 -0
  65. dotman_git-1.0.0.dist-info/RECORD +69 -0
  66. dotman_git-1.0.0.dist-info/WHEEL +5 -0
  67. dotman_git-1.0.0.dist-info/entry_points.txt +3 -0
  68. dotman_git-1.0.0.dist-info/licenses/LICENSE +21 -0
  69. dotman_git-1.0.0.dist-info/top_level.txt +1 -0
dot_man/cli/common.py ADDED
@@ -0,0 +1,548 @@
1
+ """Common utilities for dot-man CLI commands."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import time
8
+ from functools import wraps
9
+ from typing import Callable
10
+
11
+ import click
12
+
13
+ from .. import ui
14
+ from ..constants import DOT_MAN_DIR, REPO_DIR
15
+ from ..core import GitManager
16
+ from ..secrets import PermanentRedactGuard, SecretGuard, SecretMatch
17
+
18
+
19
+ def error(message: str, exit_code: int = 1) -> None:
20
+ """Print error message and exit."""
21
+ ui.error(message, exit_code)
22
+
23
+
24
+ def success(message: str) -> None:
25
+ """Print success message."""
26
+ ui.success(message)
27
+
28
+
29
+ def warn(message: str) -> None:
30
+ """Print warning message."""
31
+ ui.warn(message)
32
+
33
+
34
+ def handle_exception(exc: BaseException, context: str = "Operation") -> None:
35
+ """Handle exceptions with user-friendly diagnostics.
36
+
37
+ Uses ErrorDiagnostic to categorize errors and provide helpful suggestions.
38
+ This is the centralized exception handler for all CLI commands.
39
+ """
40
+ from ..exceptions import DotManError, ErrorDiagnostic
41
+
42
+ if isinstance(exc, DotManError):
43
+ error(str(exc), exc.exit_code)
44
+ return
45
+
46
+ if isinstance(exc, KeyboardInterrupt):
47
+ ui.console.print()
48
+ warn("Operation cancelled by user")
49
+ raise SystemExit(130)
50
+
51
+ diagnostic = ErrorDiagnostic.from_exception(exc) # type: ignore[arg-type]
52
+ ui.console.print()
53
+ ui.console.print(f"[red bold]{diagnostic.title}[/red bold]")
54
+ ui.console.print(f"[red]{diagnostic.details}[/red]")
55
+ ui.console.print()
56
+ ui.console.print(f"[dim]💡 {diagnostic.suggestion}[/dim]")
57
+ raise SystemExit(1)
58
+
59
+
60
+ class DotManGroup(click.Group):
61
+ """Custom Click Group to provide suggestions for typos."""
62
+
63
+ def get_command(self, ctx, cmd_name):
64
+ rv = click.Group.get_command(self, ctx, cmd_name)
65
+ if rv is not None:
66
+ return rv
67
+
68
+ matches = [cmd for cmd in self.list_commands(ctx)]
69
+ suggestion = ui.suggest_command(cmd_name, matches)
70
+
71
+ ui.error(f"Unknown command '{cmd_name}'", exit_code=0)
72
+ if suggestion:
73
+ ui.warn(f"Did you mean '{suggestion}'?")
74
+
75
+ ctx.exit(2)
76
+
77
+
78
+ def require_init(func):
79
+ """Decorator to require initialization before running command."""
80
+
81
+ @wraps(func)
82
+ def wrapper(*args, **kwargs):
83
+ if not DOT_MAN_DIR.exists():
84
+ ui.console.print()
85
+ ui.print_banner("🎯 Welcome to dot-man!")
86
+ ui.console.print()
87
+ ui.console.print("[bold]The Dotfile Manager for Professionals[/bold]")
88
+ ui.console.print()
89
+ ui.console.print("[bold cyan]Get started:[/bold cyan]")
90
+ ui.console.print(
91
+ " [cyan]dot-man init[/cyan] - Initialize your dotfiles repository"
92
+ )
93
+ ui.console.print(
94
+ " [cyan]dot-man init --help[/cyan] - See all init options"
95
+ )
96
+ ui.console.print()
97
+ ui.console.print("[bold cyan]Quick overview:[/bold cyan]")
98
+ ui.console.print(
99
+ " [cyan]dot-man add <path>[/cyan] - Add files to track"
100
+ )
101
+ ui.console.print(
102
+ " [cyan]dot-man status[/cyan] - View tracked files"
103
+ )
104
+ ui.console.print(
105
+ " [cyan]dot-man navigate <branch>[/cyan] - Switch between configurations"
106
+ )
107
+ ui.console.print(
108
+ " [cyan]dot-man --help[/cyan] - See all commands"
109
+ )
110
+ ui.console.print()
111
+ ui.console.print("[dim]💡 Run 'dot-man init' to get started![/dim]")
112
+ ui.console.print()
113
+ raise SystemExit(1)
114
+
115
+ if not REPO_DIR.exists() or not (REPO_DIR / ".git").exists():
116
+ error("Repository not initialized. Run 'dot-man init' first.", exit_code=1)
117
+
118
+ return func(*args, **kwargs)
119
+
120
+ return wrapper
121
+
122
+
123
+ _COMPLETION_CACHE_TTL = 10
124
+ _COMPLETION_CACHE_FILE = REPO_DIR / ".dotman" / "completion_cache.json"
125
+
126
+ _git_runner = None
127
+ _memory_cache: dict | None = None
128
+ _memory_cache_time: float = 0
129
+ _template_cache: list[str] | None = None
130
+ _config_keys_cache: list[str] | None = None
131
+ _profiles_cache: list[str] | None = None
132
+
133
+
134
+ def _set_git_runner(runner):
135
+ """Set custom git runner for testing."""
136
+ global _git_runner
137
+ _git_runner = runner
138
+
139
+
140
+ def _run_git(args, cwd=REPO_DIR, timeout=2):
141
+ """Run git command, using custom runner if set."""
142
+ if _git_runner is not None:
143
+ return _git_runner(args, cwd, timeout)
144
+ result = subprocess.run(
145
+ args,
146
+ cwd=cwd,
147
+ capture_output=True,
148
+ text=True,
149
+ timeout=timeout,
150
+ )
151
+ return result
152
+
153
+
154
+ def _get_completion_cache() -> dict:
155
+ """Load completion cache from memory or file."""
156
+ global _memory_cache, _memory_cache_time
157
+
158
+ if (
159
+ _memory_cache is not None
160
+ and time.time() - _memory_cache_time < _COMPLETION_CACHE_TTL
161
+ ):
162
+ return _memory_cache
163
+
164
+ try:
165
+ if not REPO_DIR.exists():
166
+ _memory_cache = {}
167
+ _memory_cache_time = time.time()
168
+ return _memory_cache
169
+ if _COMPLETION_CACHE_FILE.exists():
170
+ mtime = os.path.getmtime(_COMPLETION_CACHE_FILE)
171
+ if time.time() - mtime < _COMPLETION_CACHE_TTL:
172
+ _memory_cache = json.loads(_COMPLETION_CACHE_FILE.read_text())
173
+ _memory_cache_time = time.time()
174
+ return _memory_cache
175
+ except Exception:
176
+ pass
177
+
178
+ _memory_cache = {}
179
+ _memory_cache_time = time.time()
180
+ return _memory_cache
181
+
182
+
183
+ def _save_completion_cache(data: dict) -> None:
184
+ """Save completion cache to memory and file."""
185
+ global _memory_cache, _memory_cache_time
186
+ _memory_cache = data
187
+ _memory_cache_time = time.time()
188
+
189
+ try:
190
+ if not REPO_DIR.exists():
191
+ return
192
+ _COMPLETION_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
193
+ _COMPLETION_CACHE_FILE.write_text(json.dumps(data))
194
+ except Exception:
195
+ pass
196
+
197
+
198
+ def _clear_completion_cache() -> None:
199
+ """Clear completion cache for testing."""
200
+ global _memory_cache, _memory_cache_time
201
+ _memory_cache = None
202
+ _memory_cache_time = 0
203
+
204
+ try:
205
+ if _COMPLETION_CACHE_FILE.exists():
206
+ _COMPLETION_CACHE_FILE.unlink()
207
+ except Exception:
208
+ pass
209
+
210
+
211
+ def _clear_all_caches() -> None:
212
+ """Clear all completion caches including in-memory."""
213
+ global _memory_cache, _memory_cache_time
214
+ global _template_cache, _config_keys_cache, _profiles_cache
215
+
216
+ _memory_cache = None
217
+ _memory_cache_time = 0
218
+ _template_cache = None
219
+ _config_keys_cache = None
220
+ _profiles_cache = None
221
+
222
+ try:
223
+ if _COMPLETION_CACHE_FILE.exists():
224
+ _COMPLETION_CACHE_FILE.unlink()
225
+ except Exception:
226
+ pass
227
+
228
+
229
+ def parse_branch_arg(arg: str) -> dict:
230
+ """Parse branch argument with @tag or commit SHA support.
231
+
232
+ Args:
233
+ arg: Branch string like "work", "work@tag", or "abc1234"
234
+
235
+ Returns:
236
+ dict with keys: type (branch|tag|commit), base, target
237
+ """
238
+ match = re.match(r"^(.+)@(.+)$", arg)
239
+ if match:
240
+ base = match.group(1)
241
+ target = match.group(2)
242
+
243
+ if not base:
244
+ base = "HEAD"
245
+
246
+ if re.match(r"^[a-f0-9]{7,40}$", target):
247
+ return {"type": "commit", "base": base, "target": target}
248
+
249
+ return {"type": "tag", "base": base, "target": target}
250
+
251
+ if re.match(r"^[a-f0-9]{7,40}$", arg):
252
+ return {"type": "commit", "base": "HEAD", "target": arg}
253
+
254
+ try:
255
+ git = GitManager()
256
+ if arg in git.list_tags():
257
+ return {"type": "tag", "base": "HEAD", "target": arg}
258
+ except Exception as e:
259
+ import logging
260
+
261
+ logging.debug(f"Could not check tags: {e}")
262
+
263
+ return {"type": "branch", "base": arg, "target": arg}
264
+
265
+
266
+ def complete_switch_args(ctx, param, incomplete):
267
+ """Shell completion callback for switch (branches, tags, commits).
268
+
269
+ Returns tuples (value, description) for shell completion.
270
+ Order: branches first, then tags, then commits (git checkout style).
271
+ """
272
+ try:
273
+ return _complete_navigate_items(incomplete)
274
+ except Exception as e:
275
+ import logging
276
+
277
+ logging.debug(f"Completion error: {e}")
278
+ return []
279
+
280
+
281
+ def _complete_navigate_items(
282
+ incomplete: str,
283
+ ) -> "list[click.shell_completion.CompletionItem]":
284
+ """Get completion items for navigate command with context.
285
+
286
+ Uses cache and lightweight git commands for performance.
287
+ Order: branches -> tags -> commits (like git checkout)
288
+ """
289
+ from click.shell_completion import CompletionItem
290
+
291
+ try:
292
+ cache = _get_completion_cache()
293
+ items: list[CompletionItem] = []
294
+
295
+ if "branches" not in cache or "current_branch" not in cache:
296
+ result = _run_git(["git", "branch", "--list", "--format=%(refname:short)"])
297
+ branches = [
298
+ b.strip() for b in result.stdout.strip().split("\n") if b.strip()
299
+ ]
300
+
301
+ result = _run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"])
302
+ current_branch = result.stdout.strip() or "HEAD"
303
+
304
+ cache["branches"] = branches
305
+ cache["current_branch"] = current_branch
306
+ else:
307
+ branches = cache["branches"]
308
+ current_branch = cache["current_branch"]
309
+
310
+ branch_items: list[CompletionItem] = []
311
+ other_branches: list[CompletionItem] = []
312
+ for b in branches:
313
+ if b.startswith(incomplete):
314
+ if b == current_branch:
315
+ branch_items.append(CompletionItem(b, help="current branch"))
316
+ else:
317
+ other_branches.append(CompletionItem(b, help="branch"))
318
+
319
+ other_branches.sort(key=lambda x: x.value.lower())
320
+ items.extend(branch_items)
321
+ items.extend(other_branches)
322
+
323
+ if "tags" not in cache:
324
+ result = _run_git(["git", "tag", "-l"])
325
+ tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
326
+ cache["tags"] = tags
327
+ else:
328
+ tags = cache["tags"]
329
+
330
+ tag_items: list[CompletionItem] = []
331
+ for t in tags:
332
+ if t.startswith(incomplete):
333
+ result = _run_git(["git", "rev-parse", f"{t}^{{commit}}"])
334
+ commit_hash = (
335
+ result.stdout.strip()[:7] if result.returncode == 0 else ""
336
+ )
337
+ tag_items.append(CompletionItem(t, help=f"tag → {commit_hash}"))
338
+
339
+ tag_items.sort(key=lambda x: x.value.lower())
340
+ items.extend(tag_items)
341
+
342
+ if "commits" not in cache:
343
+ result = _run_git(
344
+ ["git", "log", "--oneline", "-n", "20", "--format=%H %s"], timeout=3
345
+ )
346
+ commits = []
347
+ for line in result.stdout.strip().split("\n"):
348
+ if line:
349
+ parts = line.split(" ", 1)
350
+ if len(parts) == 2:
351
+ commits.append({"sha": parts[0][:7], "message": parts[1][:30]})
352
+ cache["commits"] = commits
353
+ else:
354
+ commits = cache["commits"]
355
+
356
+ commit_items: list[CompletionItem] = []
357
+ for c in commits:
358
+ if c["sha"].startswith(incomplete):
359
+ commit_items.append(CompletionItem(c["sha"], help=f"{c['message']}..."))
360
+
361
+ items.extend(commit_items)
362
+
363
+ if "@" in incomplete:
364
+ parts = incomplete.split("@", 1)
365
+ if parts[0] in branches:
366
+ for t in tags:
367
+ if t.startswith(parts[1] if len(parts) > 1 else ""):
368
+ result = _run_git(["git", "rev-parse", f"{t}^{{commit}}"])
369
+ commit_hash = (
370
+ result.stdout.strip()[:7] if result.returncode == 0 else ""
371
+ )
372
+ items.append(
373
+ CompletionItem(
374
+ f"{parts[0]}@{t}", help=f"tag at {commit_hash}"
375
+ )
376
+ )
377
+
378
+ _save_completion_cache(cache)
379
+ return items
380
+ except Exception:
381
+ return []
382
+
383
+
384
+ def complete_branches(ctx, param, incomplete):
385
+ """Shell completion callback for branches."""
386
+ try:
387
+ cache = _get_completion_cache()
388
+ if "branches" not in cache:
389
+ result = _run_git(["git", "branch", "--list", "--format=%(refname:short)"])
390
+ branches = [
391
+ b.strip() for b in result.stdout.strip().split("\n") if b.strip()
392
+ ]
393
+ cache["branches"] = branches
394
+ _save_completion_cache(cache)
395
+ else:
396
+ branches = cache["branches"]
397
+ return [b for b in branches if b.startswith(incomplete)]
398
+ except Exception:
399
+ return []
400
+
401
+
402
+ def complete_tags(ctx, param, incomplete):
403
+ """Shell completion callback for tags."""
404
+ try:
405
+ cache = _get_completion_cache()
406
+ if "tags" not in cache:
407
+ result = _run_git(["git", "tag", "-l"])
408
+ tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
409
+ cache["tags"] = tags
410
+ _save_completion_cache(cache)
411
+ else:
412
+ tags = cache["tags"]
413
+ return [t for t in tags if t.startswith(incomplete)]
414
+ except Exception:
415
+ return []
416
+
417
+
418
+ def complete_commits(ctx, param, incomplete):
419
+ """Shell completion callback for commits."""
420
+ try:
421
+ cache = _get_completion_cache()
422
+ if "commits_all" not in cache:
423
+ result = _run_git(
424
+ ["git", "log", "--oneline", "-n", "50", "--format=%h"], timeout=3
425
+ )
426
+ commits = [
427
+ c.strip() for c in result.stdout.strip().split("\n") if c.strip()
428
+ ]
429
+ cache["commits_all"] = commits
430
+ _save_completion_cache(cache)
431
+ else:
432
+ commits = cache["commits_all"]
433
+ return [c for c in commits if c.startswith(incomplete)]
434
+ except Exception:
435
+ return []
436
+
437
+
438
+ def complete_template_keys(ctx, param, incomplete):
439
+ """Shell completion callback for template keys."""
440
+ global _template_cache
441
+
442
+ if _template_cache is not None:
443
+ return [k for k in _template_cache if k.startswith(incomplete)]
444
+
445
+ try:
446
+ from ..global_config import GlobalConfig
447
+
448
+ gc = GlobalConfig()
449
+ templates = gc.get_all_templates()
450
+ _template_cache = list(templates.keys())
451
+ return [k for k in _template_cache if k.startswith(incomplete)]
452
+ except Exception:
453
+ return []
454
+
455
+
456
+ def complete_config_keys(ctx, param, incomplete):
457
+ """Shell completion callback for config keys."""
458
+ global _config_keys_cache
459
+
460
+ if _config_keys_cache is not None:
461
+ return [k for k in _config_keys_cache if k.startswith(incomplete)]
462
+
463
+ try:
464
+ keys = [
465
+ "dot-man.current_branch",
466
+ "remote.url",
467
+ "security.strict_mode",
468
+ "switch.default_behavior",
469
+ "secrets_filter_enabled",
470
+ ]
471
+ _config_keys_cache = keys
472
+ return [k for k in keys if k.startswith(incomplete)]
473
+ except Exception:
474
+ return []
475
+
476
+
477
+ def complete_profiles(ctx, param, incomplete):
478
+ """Shell completion callback for profiles."""
479
+ global _profiles_cache
480
+
481
+ if _profiles_cache is not None:
482
+ return [k for k in _profiles_cache if k.startswith(incomplete)]
483
+
484
+ try:
485
+ from ..global_config import GlobalConfig
486
+
487
+ gc = GlobalConfig()
488
+ profiles = gc._data.get("profiles", {})
489
+ _profiles_cache = list(profiles.keys())
490
+ return [k for k in _profiles_cache if k.startswith(incomplete)]
491
+ except Exception:
492
+ return []
493
+
494
+
495
+ def get_secret_handler() -> Callable[[SecretMatch], str]:
496
+ """Get a secret handler that prompts the user for action."""
497
+ guard = SecretGuard()
498
+ permanent_guard = PermanentRedactGuard()
499
+
500
+ def handle_secret(match: SecretMatch) -> str:
501
+ if permanent_guard.should_redact(
502
+ match.file, match.line_content, match.pattern_name
503
+ ):
504
+ return "REDACT"
505
+
506
+ if guard.is_allowed(match.file, match.line_content, match.pattern_name):
507
+ return "IGNORE"
508
+
509
+ ui.console.print()
510
+ ui.warn("Potential secret detected!")
511
+ ui.console.print(f"File: [cyan]{match.file}[/cyan]")
512
+ ui.console.print(f"Line {match.line_number}: {match.line_content[:80]}...")
513
+ ui.console.print(
514
+ f"Pattern: {match.pattern_name} (severity: {match.severity.value})"
515
+ )
516
+ ui.console.print()
517
+
518
+ ui.console.print("Choose how to handle this secret:")
519
+ ui.console.print(" 1. [dim]Ignore (skip it this time)[/dim]")
520
+ ui.console.print(
521
+ " 2. [yellow]Protect (replace with ***REDACTED*** this time)[/yellow]"
522
+ )
523
+ ui.console.print(
524
+ " 3. [blue]Add to skip list (skip this line every time)[/blue]"
525
+ )
526
+ ui.console.print(" 4. [red]Protect forever (always replace in repo)[/red]")
527
+ ui.console.print()
528
+
529
+ choices = ["1", "2", "3", "4"]
530
+ choice = ui.ask("Enter choice", choices=choices, default="2")
531
+
532
+ if choice == "1":
533
+ return "IGNORE"
534
+ elif choice == "2":
535
+ return "REDACT"
536
+ elif choice == "3":
537
+ guard.add_allowed(match.file, match.line_content, match.pattern_name)
538
+ ui.info("Added to skip list.")
539
+ return "IGNORE"
540
+ elif choice == "4":
541
+ permanent_guard.add_permanent_redact(
542
+ match.file, match.line_content, match.pattern_name
543
+ )
544
+ ui.warn("Will always redact this secret.")
545
+ return "REDACT"
546
+ return "REDACT"
547
+
548
+ return handle_secret
@@ -0,0 +1,127 @@
1
+ """Completions command for dot-man CLI."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from .interface import cli as main
9
+
10
+
11
+ @main.command("completions")
12
+ @click.option(
13
+ "--shell",
14
+ type=click.Choice(["bash", "zsh", "fish", "all"]),
15
+ default="all",
16
+ help="Shell to install completions for",
17
+ )
18
+ @click.option(
19
+ "--source-only",
20
+ is_flag=True,
21
+ help="Only print source command, don't install",
22
+ )
23
+ def completions(shell: str, source_only: bool):
24
+ """Install or show shell completions for dot-man.
25
+
26
+ Examples:
27
+ dot-man completions # Install all completions
28
+ dot-man completions --shell bash # Install bash only
29
+ dot-man completions --source-only # Show source commands
30
+ """
31
+ home = Path.home()
32
+ completions_dir = home / ".local" / "share" / "bash-completion" / "completions"
33
+ zsh_compdir = home / ".local" / "share" / "zsh" / "site-functions"
34
+ fish_compdir = home / ".config" / "fish" / "completions"
35
+
36
+ try:
37
+ from dot_man import completions as completions_pkg
38
+ except ImportError:
39
+ click.echo("Error: Completions not found. Is dot-man installed?", err=True)
40
+ return
41
+
42
+ completions_path = Path(completions_pkg.__file__).parent
43
+
44
+ def do_bash():
45
+ if shell not in ("bash", "all"):
46
+ return
47
+ bash_src = completions_path / "dot-man.bash"
48
+ bash_dest = completions_dir / "dot-man"
49
+ if source_only:
50
+ click.echo("# Add to ~/.bashrc or ~/.profile:")
51
+ click.echo(f"source {bash_src}")
52
+ else:
53
+ completions_dir.mkdir(parents=True, exist_ok=True)
54
+ shutil.copy(bash_src, bash_dest)
55
+ click.echo(f"✓ Installed bash completion to {bash_dest}")
56
+
57
+ def do_zsh():
58
+ if shell not in ("zsh", "all"):
59
+ return
60
+ zsh_src = completions_path / "_dot-man.zsh"
61
+ zsh_dest = zsh_compdir / "_dot-man"
62
+ if source_only:
63
+ click.echo("# Add to ~/.zshrc:")
64
+ click.echo(f"source {zsh_src}")
65
+ else:
66
+ zsh_compdir.mkdir(parents=True, exist_ok=True)
67
+ shutil.copy(zsh_src, zsh_dest)
68
+ click.echo(f"✓ Installed zsh completion to {zsh_dest}")
69
+
70
+ def do_fish():
71
+ if shell not in ("fish", "all"):
72
+ return
73
+ fish_src = completions_path / "dot-man.fish"
74
+ fish_dest = fish_compdir / "dot-man.fish"
75
+ if source_only:
76
+ click.echo("# Add to ~/.config/fish/config.fish:")
77
+ click.echo(f"source {fish_src}")
78
+ else:
79
+ fish_compdir.mkdir(parents=True, exist_ok=True)
80
+ shutil.copy(fish_src, fish_dest)
81
+ click.echo(f"✓ Installed fish completion to {fish_dest}")
82
+
83
+ do_bash()
84
+ do_zsh()
85
+ do_fish()
86
+
87
+ if not source_only:
88
+ click.echo(
89
+ "\nRestart your shell or source your shell config to enable completions."
90
+ )
91
+
92
+
93
+ def run_install() -> None:
94
+ """Entry point for pip install hook - silently installs completions."""
95
+ try:
96
+ from dot_man import completions as completions_pkg
97
+ except ImportError:
98
+ return
99
+
100
+ home = Path.home()
101
+ completions_path = Path(completions_pkg.__file__).parent
102
+
103
+ # Bash
104
+ bash_dest = (
105
+ home / ".local" / "share" / "bash-completion" / "completions" / "dot-man"
106
+ )
107
+ if not bash_dest.exists():
108
+ bash_src = completions_path / "dot-man.bash"
109
+ if bash_src.exists():
110
+ bash_dest.parent.mkdir(parents=True, exist_ok=True)
111
+ shutil.copy(bash_src, bash_dest)
112
+
113
+ # Zsh
114
+ zsh_dest = home / ".local" / "share" / "zsh" / "site-functions" / "_dot-man"
115
+ if not zsh_dest.exists():
116
+ zsh_src = completions_path / "_dot-man.zsh"
117
+ if zsh_src.exists():
118
+ zsh_dest.parent.mkdir(parents=True, exist_ok=True)
119
+ shutil.copy(zsh_src, zsh_dest)
120
+
121
+ # Fish
122
+ fish_dest = home / ".config" / "fish" / "completions" / "dot-man.fish"
123
+ if not fish_dest.exists():
124
+ fish_src = completions_path / "dot-man.fish"
125
+ if fish_src.exists():
126
+ fish_dest.parent.mkdir(parents=True, exist_ok=True)
127
+ shutil.copy(fish_src, fish_dest)