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
@@ -0,0 +1,532 @@
1
+ """Init command for dot-man CLI."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from .. import ui
11
+ from ..config import DotManConfig, GlobalConfig
12
+ from ..config_detector import ConfigDetector, get_auto_hooks_for_config
13
+ from ..constants import BACKUPS_DIR, DOT_MAN_DIR, FILE_PERMISSIONS, REPO_DIR
14
+ from ..core import GitManager
15
+ from ..utils import is_git_installed
16
+ from .common import error, handle_exception, success, warn
17
+ from .interface import cli as main
18
+
19
+
20
+ @main.command()
21
+ @click.option("--force", is_flag=True, help="Reinitialize even if already exists")
22
+ @click.option("--no-wizard", is_flag=True, help="Skip interactive setup wizard")
23
+ @click.option(
24
+ "--sandbox",
25
+ "sandbox_dir",
26
+ type=click.Path(),
27
+ default=None,
28
+ help="Test init in a temporary sandbox directory (for testing wizard)",
29
+ )
30
+ @click.option(
31
+ "--import",
32
+ "import_path",
33
+ type=click.Path(exists=True),
34
+ default=None,
35
+ help="Import from an existing git repository",
36
+ )
37
+ def init(
38
+ force: bool, no_wizard: bool, sandbox_dir: str | None, import_path: str | None
39
+ ):
40
+ """Initialize a new dot-man repository.
41
+
42
+ By default, runs an interactive setup wizard to detect and add
43
+ common dotfiles. Use --no-wizard for manual setup.
44
+
45
+ Examples:
46
+ dot-man init # Interactive wizard
47
+ dot-man init --sandbox /tmp/test # Test wizard in sandbox
48
+ dot-man init --no-wizard # Manual setup only
49
+ dot-man init --import ~/dotfiles # Import existing dotfiles repo
50
+ """
51
+ # Pre-checks
52
+ if not is_git_installed():
53
+ error("Git not found. Please install git first.", exit_code=2)
54
+
55
+ if DOT_MAN_DIR.exists() and not force:
56
+ if not ui.confirm(
57
+ "Repository already initialized. Reinitialize? (This will DELETE all data)"
58
+ ):
59
+ ui.info("Aborted.")
60
+ sys.exit(1)
61
+
62
+ shutil.rmtree(DOT_MAN_DIR)
63
+
64
+ try:
65
+ # Create directory structure
66
+ DOT_MAN_DIR.mkdir(parents=True, exist_ok=True)
67
+ DOT_MAN_DIR.chmod(FILE_PERMISSIONS)
68
+ REPO_DIR.mkdir(parents=True, exist_ok=True)
69
+ BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
70
+
71
+ # Handle import from existing repo
72
+ if import_path:
73
+ source_path: Path
74
+
75
+ # Check if it's a GitHub URL
76
+ github_url = _parse_github_url(import_path)
77
+ if github_url:
78
+ cloned_path = _clone_github_repo(github_url)
79
+ if cloned_path is None:
80
+ error("Failed to clone GitHub repository.", exit_code=1)
81
+ source_path = cloned_path # type: ignore[assignment]
82
+ else:
83
+ # It's a local path
84
+ source_path = Path(import_path).expanduser().resolve()
85
+ if not source_path.exists():
86
+ error(f"Path '{source_path}' does not exist.", exit_code=1)
87
+ if not (source_path / ".git").exists():
88
+ error(f"'{source_path}' is not a git repository.", exit_code=1)
89
+
90
+ ui.console.print(f"[dim]Importing from {source_path}...[/dim]")
91
+
92
+ # Get the current branch from source repo
93
+ current_branch = "master"
94
+ try:
95
+ result = subprocess.run(
96
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
97
+ cwd=source_path,
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=5,
101
+ )
102
+ if result.returncode == 0:
103
+ current_branch = result.stdout.strip()
104
+ except Exception:
105
+ pass
106
+
107
+ # Copy entire repo including .git
108
+ shutil.copytree(source_path, REPO_DIR, dirs_exist_ok=True)
109
+
110
+ # Initialize git to get the repo object
111
+ git = GitManager()
112
+
113
+ # Set the active branch to match source
114
+ try:
115
+ if current_branch in git.list_branches():
116
+ git.checkout(current_branch)
117
+ except Exception:
118
+ pass
119
+
120
+ ui.success(f"Imported dotfiles from {source_path}")
121
+ else:
122
+ # Initialize git repository
123
+ git = GitManager()
124
+ git.init()
125
+
126
+ # Verify git config exists (user.name and user.email)
127
+ try:
128
+ git.repo.config_reader().get_value("user", "name")
129
+ git.repo.config_reader().get_value("user", "email")
130
+ except Exception:
131
+ ui.console.print()
132
+ ui.warn("Git user configuration not found. Setting defaults...")
133
+ with git.repo.config_writer() as config:
134
+ config.set_value("user", "name", "dot-man-user")
135
+ config.set_value("user", "email", "dot-man@localhost")
136
+ ui.console.print("[dim] Configured default git user.[/dim]")
137
+ ui.console.print(
138
+ "[dim] Run 'git config --global user.name \"Your Name\"' to customize[/dim]"
139
+ )
140
+ ui.console.print(
141
+ "[dim] Run 'git config --global user.email \"you@example.com\"' to customize[/dim]"
142
+ )
143
+ ui.console.print()
144
+
145
+ # Create global configuration
146
+ global_config = GlobalConfig()
147
+ global_config.create_default()
148
+
149
+ # Create minimal dot-man.toml
150
+ dotman_config = DotManConfig()
151
+ dotman_config.create_default()
152
+
153
+ # Initial commit
154
+ git.commit("dot-man: Initial commit")
155
+
156
+ # Success message
157
+ ui.console.print()
158
+ ui.print_banner("🎉 dot-man initialized successfully!")
159
+ ui.console.print()
160
+
161
+ # Run wizard by default (unless --no-wizard)
162
+ if not no_wizard:
163
+ run_setup_wizard(global_config, dotman_config, git)
164
+ else:
165
+ show_quick_start()
166
+
167
+ except KeyboardInterrupt:
168
+ handle_exception(KeyboardInterrupt())
169
+ except Exception as e:
170
+ handle_exception(e, "Initialization")
171
+
172
+
173
+ def run_setup_wizard(
174
+ global_config: GlobalConfig, dotman_config: DotManConfig, git: GitManager
175
+ ):
176
+ """Interactive setup wizard for new users."""
177
+ ui.print_banner("🧙 Setup Wizard")
178
+ ui.console.print()
179
+ ui.console.print(
180
+ "Let's get your dotfiles set up! I'll detect common files automatically."
181
+ )
182
+ ui.console.print()
183
+
184
+ # Detect common dotfiles
185
+ ui.console.print("[bold]Detecting dotfiles...[/bold]")
186
+ ui.console.print()
187
+
188
+ # Detect quickshell configs using ConfigDetector
189
+ qs_configs = ConfigDetector.detect_quickshell_configs()
190
+
191
+ # Build common_files with quickshell detection
192
+ common_files = [
193
+ ("~/.bashrc", "Bash shell", "bashrc"),
194
+ ("~/.zshrc", "Zsh shell", "zshrc"),
195
+ ("~/.gitconfig", "Git config", "gitconfig"),
196
+ ("~/.vimrc", "Vim editor", "vimrc"),
197
+ ("~/.config/nvim", "Neovim", "nvim"),
198
+ ("~/.config/fish", "Fish shell", "fish"),
199
+ ("~/.config/kitty", "Kitty terminal", "kitty"),
200
+ ("~/.config/alacritty", "Alacritty terminal", "alacritty"),
201
+ ("~/.config/hypr", "Hyprland WM", "hypr"),
202
+ ("~/.config/i3", "i3 WM", "i3"),
203
+ ("~/.tmux.conf", "tmux", "tmux"),
204
+ ("~/.ssh/config", "SSH config", "ssh-config"),
205
+ ]
206
+
207
+ # Add quickshell configs as separate entries
208
+ for qs_config in qs_configs:
209
+ common_files.append(
210
+ (
211
+ qs_config["paths"][0],
212
+ qs_config["display_name"],
213
+ qs_config["section_name"],
214
+ )
215
+ )
216
+
217
+ files_to_add = []
218
+ found_count = 0
219
+
220
+ for path_str, desc, section_name in common_files:
221
+ path = Path(path_str).expanduser()
222
+
223
+ # Skip quickshell subdirs (detected separately by ConfigDetector)
224
+ if section_name.startswith("qs-"):
225
+ if path.exists():
226
+ found_count += 1
227
+ ui.console.print(
228
+ f" [green]✓[/green] Found: [cyan]{path_str}[/cyan] ({desc})"
229
+ )
230
+ if ui.confirm(" Track this?", default=True):
231
+ files_to_add.append((path_str, section_name))
232
+ continue
233
+
234
+ # Special handling for Quickshell root ambiguity
235
+ if section_name == "quickshell" and path.exists():
236
+ subdirs = sorted(
237
+ [
238
+ d
239
+ for d in path.iterdir()
240
+ if d.is_dir() and not d.name.startswith(".")
241
+ ],
242
+ key=lambda x: x.name,
243
+ )
244
+ if len(subdirs) > 1:
245
+ found_count += 1
246
+ ui.console.print(
247
+ f" [green]✓[/green] Found: [cyan]{path_str}[/cyan] ({desc})"
248
+ )
249
+ ui.console.print(
250
+ " [yellow]⚠️ Multiple configurations detected:[/yellow]"
251
+ )
252
+
253
+ # List options
254
+ options = subdirs + [path] # subdirs + root
255
+ for i, opt in enumerate(options, 1):
256
+ if opt == path:
257
+ label = f"Track root directory ({path_str})"
258
+ else:
259
+ label = f"Track '{opt.name}'"
260
+ ui.console.print(f" [bold]{i}.[/bold] {label}")
261
+
262
+ # Ask user
263
+ while True:
264
+ choice = ui.ask(
265
+ f" Which one to track? (1-{len(options)})",
266
+ default=str(len(options)),
267
+ )
268
+ try:
269
+ idx = int(choice) - 1
270
+ if 0 <= idx < len(options):
271
+ selected_path = options[idx]
272
+
273
+ # Determine section name
274
+ if selected_path == path:
275
+ final_section = section_name
276
+ final_path_str = path_str
277
+ else:
278
+ final_section = selected_path.name
279
+ final_path_str = f"{path_str}/{selected_path.name}"
280
+
281
+ if ui.confirm(
282
+ f" Track '{final_section}'?", default=True
283
+ ):
284
+ files_to_add.append((final_path_str, final_section))
285
+ break
286
+ else:
287
+ warn("Invalid selection")
288
+ except ValueError:
289
+ warn("Please enter a number")
290
+ continue
291
+
292
+ if path.exists():
293
+ found_count += 1
294
+ ui.console.print(
295
+ f" [green]✓[/green] Found: [cyan]{path_str}[/cyan] ({desc})"
296
+ )
297
+ if ui.confirm(" Track this?", default=True):
298
+ files_to_add.append((path_str, section_name))
299
+
300
+ if found_count == 0:
301
+ ui.console.print(
302
+ " [dim]No common dotfiles detected in default locations[/dim]"
303
+ )
304
+ ui.console.print()
305
+ else:
306
+ ui.console.print()
307
+
308
+ # Offer to add custom files
309
+ if ui.confirm("Add custom files not in the list?", default=False):
310
+ ui.console.print()
311
+ while True:
312
+ custom_path = ui.ask(
313
+ "Path to track (or press Enter to finish)",
314
+ default="",
315
+ show_default=False,
316
+ )
317
+
318
+ if not custom_path:
319
+ break
320
+
321
+ path = Path(custom_path).expanduser()
322
+ if not path.exists():
323
+ warn(f"Path doesn't exist: {custom_path}")
324
+ continue
325
+
326
+ # Auto-generate section name from path
327
+ if path.name.startswith("."):
328
+ default_section = path.name[1:] if path.suffix else path.name[1:]
329
+ else:
330
+ default_section = path.stem or path.name
331
+
332
+ section_name = ui.ask("Section name", default=default_section)
333
+ files_to_add.append((custom_path, section_name))
334
+
335
+ # Add files to config
336
+ if files_to_add:
337
+ ui.console.print()
338
+ ui.console.print(f"[bold]Adding {len(files_to_add)} files...[/bold]")
339
+ ui.console.print()
340
+
341
+ for path_str, section_name in files_to_add:
342
+ try:
343
+ dotman_config.add_section(
344
+ name=section_name,
345
+ paths=[path_str],
346
+ )
347
+ # Auto-detect and suggest hooks for popular configs
348
+ auto_hooks = get_auto_hooks_for_config(section_name, [path_str])
349
+ if auto_hooks:
350
+ for hook_type, hook_cmd in auto_hooks.items():
351
+ # Map to actual config keys
352
+ if hook_type == "post_deploy":
353
+ dotman_config.update_section(
354
+ section_name, post_deploy=hook_cmd
355
+ )
356
+ elif hook_type == "pre_deploy":
357
+ dotman_config.update_section(
358
+ section_name, pre_deploy=hook_cmd
359
+ )
360
+ ui.console.print(
361
+ f" [dim]Auto-detected {hook_type} hook for {section_name}[/dim]"
362
+ )
363
+ ui.console.print(f" [green]✓[/green] [{section_name}]: {path_str}")
364
+ except Exception as e:
365
+ warn(f"Could not add {path_str}: {e}")
366
+
367
+ dotman_config.save()
368
+
369
+ # Commit the initial config
370
+ git.add_all()
371
+ git.commit("Add initial dotfiles configuration")
372
+
373
+ ui.console.print()
374
+ success(f"Added {len(files_to_add)} files to configuration")
375
+ ui.console.print()
376
+
377
+ # Show what was configured
378
+ ui.console.print("[bold]Your dotfiles are now tracked:[/bold]")
379
+ for path_str, section_name in files_to_add[:5]:
380
+ ui.console.print(f" • [{section_name}] {path_str}")
381
+ if len(files_to_add) > 5:
382
+ ui.console.print(f" ... and {len(files_to_add) - 5} more")
383
+
384
+ ui.console.print()
385
+
386
+ # Offer remote setup
387
+ if ui.confirm("Set up remote repository for syncing? (optional)", default=False):
388
+ ui.console.print()
389
+ from .remote_cmd import setup
390
+
391
+ ctx = click.Context(setup)
392
+ ctx.invoke(setup)
393
+
394
+ # Final instructions
395
+ ui.console.print()
396
+ ui.print_banner("🎉 Setup Complete!")
397
+ ui.console.print()
398
+
399
+ if files_to_add:
400
+ ui.console.print("[bold]Next steps:[/bold]")
401
+ ui.console.print(
402
+ " 1. [cyan]dot-man status[/cyan] - View your tracked files"
403
+ )
404
+ ui.console.print(
405
+ " 2. [cyan]dot-man navigate work[/cyan] - Create a work configuration branch"
406
+ )
407
+ ui.console.print(
408
+ " 3. [cyan]dot-man add <path>[/cyan] - Track more files"
409
+ )
410
+ else:
411
+ ui.console.print("[bold]Get started:[/bold]")
412
+ ui.console.print(
413
+ " [cyan]dot-man add ~/.bashrc[/cyan] - Add files to track"
414
+ )
415
+ ui.console.print(
416
+ " [cyan]dot-man edit[/cyan] - Edit config file"
417
+ )
418
+ ui.console.print(" [cyan]dot-man status[/cyan] - View status")
419
+
420
+ ui.console.print()
421
+ ui.console.print("[dim]💡 Run 'dot-man --help' to see all commands[/dim]")
422
+ ui.console.print()
423
+
424
+
425
+ def show_quick_start():
426
+ """Display quick start guide (for --no-wizard users)."""
427
+ ui.console.print("[bold]📚 Quick Start Guide:[/bold]")
428
+ ui.console.print()
429
+ ui.console.print("[bold cyan]Adding files to track:[/bold cyan]")
430
+ ui.console.print(" dot-man add ~/.bashrc [dim]# Single file[/dim]")
431
+ ui.console.print(" dot-man add ~/.config/nvim [dim]# Directory[/dim]")
432
+ ui.console.print(
433
+ " dot-man edit [dim]# Edit config manually[/dim]"
434
+ )
435
+ ui.console.print()
436
+ ui.console.print("[bold cyan]Managing configurations:[/bold cyan]")
437
+ ui.console.print(
438
+ " dot-man status [dim]# View tracked files[/dim]"
439
+ )
440
+ ui.console.print(
441
+ " dot-man navigate main [dim]# Save & switch to main[/dim]"
442
+ )
443
+ ui.console.print(
444
+ " dot-man navigate work --preview [dim]# Preview work branch[/dim]"
445
+ )
446
+ ui.console.print()
447
+ ui.console.print(
448
+ "[dim]💡 Tip: Config is at ~/.config/dot-man/repo/dot-man.toml[/dim]"
449
+ )
450
+ ui.console.print()
451
+
452
+
453
+ def _parse_github_url(url: str) -> str | None:
454
+ """Parse GitHub URL and return repo URL if valid.
455
+
456
+ Supports:
457
+ - github.com/user/repo
458
+ - https://github.com/user/repo
459
+ - git@github.com:user/repo
460
+ - https://github.com/user/repo.git
461
+
462
+ Returns:
463
+ Clone URL if valid GitHub repo, None otherwise
464
+ """
465
+ import re
466
+
467
+ url = url.strip()
468
+
469
+ # https://github.com/user/repo or https://github.com/user/repo.git
470
+ match = re.match(r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?$", url)
471
+ if match:
472
+ return f"https://github.com/{match.group(1)}/{match.group(2)}.git"
473
+
474
+ # git@github.com:user/repo
475
+ match = re.match(r"^git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url)
476
+ if match:
477
+ return f"git@github.com:{match.group(1)}/{match.group(2)}.git"
478
+
479
+ # github.com/user/repo (shorthand)
480
+ match = re.match(r"^github\.com/([^/]+)/([^/]+)$", url)
481
+ if match:
482
+ return f"https://github.com/{match.group(1)}/{match.group(2)}.git"
483
+
484
+ return None
485
+
486
+
487
+ def _clone_github_repo(github_url: str) -> Path | None:
488
+ """Clone a GitHub repo to a temporary directory.
489
+
490
+ Args:
491
+ github_url: GitHub clone URL
492
+
493
+ Returns:
494
+ Path to cloned repo, or None on failure
495
+ """
496
+ import tempfile
497
+
498
+ ui.console.print(f"[dim]Cloning {github_url}...[/dim]")
499
+
500
+ try:
501
+ temp_dir = tempfile.mkdtemp(prefix="dotman_import_")
502
+ result = subprocess.run(
503
+ ["git", "clone", "--mirror", github_url, temp_dir],
504
+ capture_output=True,
505
+ text=True,
506
+ timeout=60,
507
+ )
508
+ if result.returncode != 0:
509
+ ui.error(f"Failed to clone: {result.stderr}")
510
+ return None
511
+
512
+ # Get the actual repo path (git clone --mirror creates a bare repo)
513
+ # We need to convert it to a working repo
514
+ # Clone again to get a working copy
515
+ work_dir = tempfile.mkdtemp(prefix="dotman_working_")
516
+ result = subprocess.run(
517
+ ["git", "clone", temp_dir, work_dir],
518
+ capture_output=True,
519
+ text=True,
520
+ timeout=60,
521
+ )
522
+ if result.returncode != 0:
523
+ ui.error(f"Failed to clone working copy: {result.stderr}")
524
+ return None
525
+
526
+ return Path(work_dir)
527
+ except subprocess.TimeoutExpired:
528
+ ui.error("Clone timed out")
529
+ return None
530
+ except Exception as e:
531
+ ui.error(f"Clone failed: {e}")
532
+ return None
@@ -0,0 +1,56 @@
1
+ """CLI Interface definition.
2
+
3
+ This module defines the main Click group to avoid circular imports
4
+ when subcommands need to decorate themselves with @main.command().
5
+ """
6
+
7
+ import logging
8
+
9
+ import click
10
+
11
+ from .. import __version__, ui
12
+ from ..constants import DOT_MAN_DIR
13
+ from .common import DotManGroup
14
+
15
+
16
+ @click.group(cls=DotManGroup)
17
+ @click.version_option(version=__version__, prog_name="dot-man")
18
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output on console")
19
+ @click.option("--debug", is_flag=True, help="Enable debug logging to file")
20
+ @click.pass_context
21
+ def cli(ctx, verbose: bool, debug: bool):
22
+ """dot-man: The Dotfile Manager for Professionals."""
23
+ # Ensure config dir exists for logs
24
+ if not DOT_MAN_DIR.exists():
25
+ try:
26
+ DOT_MAN_DIR.mkdir(parents=True, exist_ok=True)
27
+ except OSError:
28
+ pass # Init command will handle main creation
29
+
30
+ log_file = DOT_MAN_DIR / "dot-man.log"
31
+ level = logging.DEBUG if (debug or verbose) else logging.INFO
32
+
33
+ # Configure file logging
34
+ logging.basicConfig(
35
+ filename=str(log_file),
36
+ level=level,
37
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
38
+ filemode="a",
39
+ )
40
+
41
+ # If verbose, also log to console
42
+ if verbose:
43
+ console_handler = logging.StreamHandler()
44
+ console_handler.setLevel(logging.DEBUG)
45
+ console_handler.setFormatter(
46
+ logging.Formatter("[dim]%(levelname)s:[/dim] %(message)s")
47
+ )
48
+ logging.getLogger().addHandler(console_handler)
49
+ ui.console.print("[dim]Verbose mode enabled[/dim]")
50
+ elif debug:
51
+ ui.console.print("[dim]Debug logging enabled (see dot-man.log)[/dim]")
52
+
53
+ # Store flags in context for subcommands
54
+ ctx.ensure_object(dict)
55
+ ctx.obj["DEBUG"] = debug
56
+ ctx.obj["VERBOSE"] = verbose