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/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """dot-man: Dotfile manager with git-powered branching."""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "Bishoy Ehab"
dot_man/backups.py ADDED
@@ -0,0 +1,211 @@
1
+ """Backup management for dot-man."""
2
+
3
+ __all__ = ["BackupManager"]
4
+
5
+ import json
6
+ import shutil
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from .constants import BACKUPS_DIR, MAX_BACKUPS
11
+ from .exceptions import BackupError
12
+
13
+
14
+ class BackupManager:
15
+ """Manages local safety snapshots."""
16
+
17
+ def __init__(self, backups_dir: Path | None = None, max_backups: int | None = None):
18
+ self.backups_dir = backups_dir or BACKUPS_DIR
19
+ self.max_backups = max_backups or MAX_BACKUPS
20
+ # Ensure backups directory exists
21
+ self.backups_dir.mkdir(parents=True, exist_ok=True)
22
+
23
+ def _get_backup_name(self, note: str = "manual") -> str:
24
+ """Generate a unique backup name."""
25
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
26
+ # Sanitize note
27
+ note_safe = "".join(c if c.isalnum() else "_" for c in note)
28
+ return f"{timestamp}_{note_safe}"
29
+
30
+ def create_backup(self, paths: list[Path], note: str = "manual") -> str:
31
+ """
32
+ Create a backup snapshot of the given paths.
33
+
34
+ Args:
35
+ paths: List of file/directory paths to backup.
36
+ note: Short description/reason for backup.
37
+
38
+ Returns:
39
+ The name (ID) of the created backup.
40
+ """
41
+ if not paths:
42
+ return ""
43
+
44
+ backup_id = self._get_backup_name(note)
45
+ backup_path = self.backups_dir / backup_id
46
+
47
+ try:
48
+ backup_path.mkdir()
49
+
50
+ # Metadata to store original paths relative to home or absolute
51
+ manifest = {}
52
+ count = 0
53
+
54
+ for path in paths:
55
+ if not path.exists():
56
+ continue
57
+
58
+ # Determine destination structure within backup
59
+ # We replicate the structure relative to user's home or root
60
+ # For simplicity, we flattening somewhat or mirroring full path?
61
+ # Mirroring full path is safest to avoid collisions.
62
+ # E.g. /home/user/.bashrc -> backup/home/user/.bashrc
63
+
64
+ # Strip root anchor to make it relative
65
+ rel_path = path.relative_to(path.anchor)
66
+ dest = backup_path / rel_path
67
+
68
+ dest.parent.mkdir(parents=True, exist_ok=True)
69
+
70
+ if path.is_file():
71
+ shutil.copy2(path, dest)
72
+ elif path.is_dir():
73
+ shutil.copytree(path, dest)
74
+
75
+ manifest[str(rel_path)] = str(path)
76
+ count += 1
77
+
78
+ if count == 0:
79
+ # No files backed up, remove empty directory
80
+ backup_path.rmdir()
81
+ return ""
82
+
83
+ # Save manifest
84
+ (backup_path / "manifest.json").write_text(
85
+ json.dumps(manifest, indent=2), encoding="utf-8"
86
+ )
87
+
88
+ # Rotate old backups
89
+ self._rotate_backups()
90
+
91
+ return backup_id
92
+
93
+ except OSError as e:
94
+ # Cleanup on failure
95
+ if backup_path.exists():
96
+ shutil.rmtree(backup_path, ignore_errors=True)
97
+ raise BackupError(f"Failed to create backup '{backup_id}': {e}")
98
+
99
+ def list_backups(self) -> list[dict]:
100
+ """
101
+ List all available backups.
102
+
103
+ Returns:
104
+ List of dicts with keys: id, date, note, path
105
+ """
106
+ backups = []
107
+ if not self.backups_dir.exists():
108
+ return []
109
+
110
+ for p in self.backups_dir.iterdir():
111
+ if p.is_dir():
112
+ # Parse name: YYYYMMDD_HHMMSS_note
113
+ parts = p.name.split("_", 2)
114
+ if len(parts) >= 2:
115
+ date_str = f"{parts[0]} {parts[1][:2]}:{parts[1][2:4]}"
116
+ note = parts[2] if len(parts) > 2 else "auto"
117
+ backups.append(
118
+ {"id": p.name, "date": date_str, "note": note, "path": p}
119
+ )
120
+
121
+ # Sort by ID (timestamp) descending
122
+ return sorted(backups, key=lambda x: x["id"], reverse=True)
123
+
124
+ def restore_backup(self, backup_id: str) -> bool:
125
+ """
126
+ Restore files from a backup.
127
+
128
+ Args:
129
+ backup_id: The ID (folder name) of the backup to restore.
130
+ """
131
+ backup_path = self.backups_dir / backup_id
132
+ if not backup_path.exists():
133
+ raise BackupError(f"Backup '{backup_id}' not found")
134
+
135
+ manifest_file = backup_path / "manifest.json"
136
+ if not manifest_file.exists():
137
+ raise BackupError(f"Backup '{backup_id}' is corrupt (missing manifest)")
138
+
139
+ try:
140
+ manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
141
+
142
+ for rel_path_str, original_path_str in manifest.items():
143
+ start_source = backup_path / rel_path_str
144
+ original_path = Path(original_path_str)
145
+
146
+ if start_source.exists():
147
+ # Ensure parent exists
148
+ original_path.parent.mkdir(parents=True, exist_ok=True)
149
+
150
+ if original_path.exists():
151
+ if original_path.is_dir():
152
+ shutil.rmtree(original_path)
153
+ else:
154
+ original_path.unlink()
155
+
156
+ if start_source.is_dir():
157
+ shutil.copytree(start_source, original_path)
158
+ else:
159
+ shutil.copy2(start_source, original_path)
160
+
161
+ return True
162
+
163
+ except (OSError, json.JSONDecodeError) as e:
164
+ raise BackupError(f"Failed to restore backup '{backup_id}': {e}")
165
+
166
+ def _rotate_backups(self) -> None:
167
+ """Keep only the last MAX_BACKUPS backups."""
168
+ self.clean_backups(keep=MAX_BACKUPS)
169
+
170
+ def delete_backup(self, backup_id: str) -> bool:
171
+ """
172
+ Delete a specific backup by ID.
173
+
174
+ Args:
175
+ backup_id: The ID of the backup to delete.
176
+
177
+ Returns:
178
+ True if deleted, False if not found.
179
+ """
180
+ backup_path = self.backups_dir / backup_id
181
+ if not backup_path.exists():
182
+ return False
183
+
184
+ try:
185
+ shutil.rmtree(backup_path)
186
+ return True
187
+ except OSError:
188
+ return False
189
+
190
+ def clean_backups(self, keep: int = 0) -> int:
191
+ """
192
+ Remove old backups, keeping the specified number of most recent ones.
193
+
194
+ Args:
195
+ keep: Number of recent backups to keep.
196
+
197
+ Returns:
198
+ Number of backups deleted.
199
+ """
200
+ backups = self.list_backups()
201
+ if len(backups) <= keep:
202
+ return 0
203
+
204
+ to_delete = backups[keep:]
205
+ deleted_count = 0
206
+
207
+ for b in to_delete:
208
+ if self.delete_backup(b["id"]):
209
+ deleted_count += 1
210
+
211
+ return deleted_count
dot_man/branch_ops.py ADDED
@@ -0,0 +1,347 @@
1
+ """Branch and revert operations mixin for DotManOperations."""
2
+
3
+ from abc import abstractmethod
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Any, Callable, Optional
6
+
7
+ from .constants import LOCK_FILE, REPO_DIR
8
+ from .exceptions import DotManError
9
+ from .files import atomic_write_text, clear_comparison_cache, copy_file
10
+ from .lock import FileLock
11
+ from .merge import get_hook_for_config
12
+ from .secrets import SecretMatch
13
+ from .ui import warn
14
+
15
+ if TYPE_CHECKING:
16
+ pass
17
+
18
+ FILE_TO_HOOK_MAP = {
19
+ ".bashrc": "bash_reload",
20
+ ".zshrc": "zsh_reload",
21
+ ".bash_profile": "bash_reload",
22
+ ".zprofile": "zsh_reload",
23
+ ".config/fish": "fish_reload",
24
+ ".tmux.conf": "tmux_reload",
25
+ ".config/nvim": "nvim_sync",
26
+ ".config/kitty": "kitty_reload",
27
+ ".config/alacritty": "alacritty_reload",
28
+ ".config/wezterm": "wezterm_reload",
29
+ ".config/hypr": "hyprland_reload",
30
+ ".config/hyprland": "hyprland_reload",
31
+ ".config/sway": "sway_reload",
32
+ ".config/i3": "i3_reload",
33
+ ".config/awesome": "awesome_reload",
34
+ ".config/polybar": "polybar_reload",
35
+ ".config/waybar": "waybar_reload",
36
+ ".config/dunst": "dunst_reload",
37
+ ".config/picom": "picom_reload",
38
+ ".Xresources": "xreload",
39
+ ".config/starship": "starship_reload",
40
+ ".fzf.bash": "fzf_reload",
41
+ ".fzf.zsh": "fzf_reload",
42
+ ".emacs.d": "emacs_reload",
43
+ ".vimrc": "vim_reload",
44
+ ".gitconfig": "git_reload",
45
+ }
46
+
47
+
48
+ class BranchMixin:
49
+ """Mixin providing branch switch and file revert operations."""
50
+
51
+ @property
52
+ @abstractmethod
53
+ def current_branch(self) -> str: ...
54
+
55
+ @abstractmethod
56
+ def save_all(
57
+ self, secret_handler: Optional[Callable[[SecretMatch], str]] = None
58
+ ) -> dict[str, Any]: ...
59
+
60
+ @property
61
+ @abstractmethod
62
+ def git(self) -> Any: ...
63
+
64
+ @abstractmethod
65
+ def reload_config(self) -> None: ...
66
+
67
+ @abstractmethod
68
+ def get_sections(self) -> list[str]: ...
69
+
70
+ @abstractmethod
71
+ def get_section(self, name: str) -> Any: ...
72
+
73
+ @property
74
+ @abstractmethod
75
+ def backups(self) -> Any: ...
76
+
77
+ @abstractmethod
78
+ def scan_deployable_changes(self, sections: list[Any]) -> Any: ...
79
+
80
+ @abstractmethod
81
+ def execute_deployment_plan(self, plan: Any) -> dict[str, Any]: ...
82
+
83
+ @property
84
+ @abstractmethod
85
+ def global_config(self) -> Any: ...
86
+
87
+ @property
88
+ @abstractmethod
89
+ def vault(self) -> Any: ...
90
+
91
+ def switch_branch(
92
+ self,
93
+ target_branch: str,
94
+ dry_run: bool = False,
95
+ secret_handler: Optional[Callable[[SecretMatch], str]] = None,
96
+ ) -> dict:
97
+ """
98
+ Switch to a different branch.
99
+
100
+ Returns dict with:
101
+ - saved_count: files saved from current branch
102
+ - deployed_count: files deployed from target branch
103
+ - secrets_redacted: number of secrets redacted
104
+ - pre_hooks: list of pre-deploy commands
105
+ - post_hooks: list of post-deploy commands
106
+ - created_branch: True if branch was newly created
107
+ - errors: list of error messages
108
+ """
109
+ current_branch = self.current_branch
110
+ result: dict[str, Any] = {
111
+ "saved_count": 0,
112
+ "deployed_count": 0,
113
+ "secrets_redacted": 0,
114
+ "pre_hooks": [],
115
+ "post_hooks": [],
116
+ "created_branch": False,
117
+ "errors": [],
118
+ }
119
+
120
+ if current_branch == target_branch and not dry_run:
121
+ return result
122
+
123
+ import contextlib
124
+
125
+ lock_context = FileLock(LOCK_FILE) if not dry_run else contextlib.nullcontext()
126
+
127
+ with lock_context:
128
+ # Phase 1: Save current branch
129
+ if not dry_run:
130
+ save_result = self.save_all(secret_handler)
131
+ result["saved_count"] = save_result["saved"]
132
+ result["secrets_redacted"] = len(save_result["secrets"])
133
+ result["errors"].extend(save_result["errors"])
134
+
135
+ commit_msg = f"Auto-save from '{current_branch}' before switch to '{target_branch}'"
136
+ self.git.commit(commit_msg)
137
+
138
+ # Phase 2: Switch git branch
139
+ branch_exists = self.git.branch_exists(target_branch)
140
+ result["created_branch"] = not branch_exists
141
+
142
+ if not dry_run:
143
+ self.git.checkout(target_branch, create=not branch_exists)
144
+ self.reload_config()
145
+
146
+ # Phase 3: Deploy target branch
147
+ if not dry_run:
148
+ # Auto-backup before potentially destructive deployment
149
+ try:
150
+ paths_to_backup = []
151
+ for section_name in self.get_sections():
152
+ section = self.get_section(section_name)
153
+ paths_to_backup.extend([p for p in section.paths if p.exists()])
154
+
155
+ if paths_to_backup:
156
+ self.backups.create_backup(
157
+ paths_to_backup, note=f"pre-switch-{target_branch}"
158
+ )
159
+ except Exception as e:
160
+ result["errors"].append(f"Warning: Auto-backup failed: {e}")
161
+
162
+ # Two-Phase Deployment for Target Branch
163
+ try:
164
+ sections = [self.get_section(name) for name in self.get_sections()]
165
+
166
+ plan = self.scan_deployable_changes(sections)
167
+
168
+ result["pre_hooks"].extend(plan["pre_hooks"])
169
+ result["post_hooks"].extend(plan["post_hooks"])
170
+ result["errors"].extend(plan["errors"])
171
+
172
+ result["pre_hooks"] = list(dict.fromkeys(result["pre_hooks"]))
173
+ result["post_hooks"] = list(dict.fromkeys(result["post_hooks"]))
174
+
175
+ deploy_result = self.execute_deployment_plan(plan)
176
+
177
+ result["deployed_count"] = deploy_result["deployed"]
178
+ result["errors"].extend(deploy_result["errors"])
179
+
180
+ # Auto-detect hooks based on changed files
181
+ changed_files = self.get_changed_files_between_branches(
182
+ current_branch, target_branch
183
+ )
184
+ if changed_files:
185
+ auto_hooks = self.detect_hooks_for_changed_files(changed_files)
186
+ for hook in auto_hooks:
187
+ if hook and hook not in result["post_hooks"]:
188
+ result["post_hooks"].append(hook)
189
+
190
+ except Exception as e:
191
+ result["errors"].append(
192
+ f"Critical error during switch deployment: {e}"
193
+ )
194
+
195
+ self.global_config.current_branch = target_branch
196
+ self.global_config.save()
197
+
198
+ # Clear file comparison cache since files have likely changed
199
+ clear_comparison_cache()
200
+
201
+ return result
202
+
203
+ def revert_file(self, path: Path) -> bool:
204
+ """
205
+ Revert a specific file to its repository version.
206
+
207
+ Args:
208
+ path: Absolute path to the file to revert.
209
+
210
+ Returns:
211
+ True if successful, False if file not tracked or not found in repo.
212
+ """
213
+ path = path.resolve()
214
+
215
+ # Find which section tracks this file
216
+ target_section = None
217
+ repo_source = None
218
+
219
+ for section_name in self.get_sections():
220
+ section = self.get_section(section_name)
221
+ if path in section.paths:
222
+ target_section = section
223
+ repo_source = section.get_repo_path(path, REPO_DIR)
224
+ break
225
+
226
+ for p in section.paths:
227
+ if p.is_dir() and p in path.parents:
228
+ target_section = section
229
+ repo_source = section.get_repo_path(path, REPO_DIR)
230
+ break
231
+
232
+ if target_section:
233
+ break
234
+
235
+ if not target_section or not repo_source:
236
+ warn(f"File not tracked by any section: {path}")
237
+ return False
238
+
239
+ if not repo_source.exists():
240
+ warn(
241
+ f"File not found in repository (branch: {self.current_branch}): {repo_source}"
242
+ )
243
+ return False
244
+
245
+ try:
246
+ success, _ = copy_file(repo_source, path, filter_secrets_enabled=False)
247
+
248
+ if success and target_section.secrets_filter:
249
+ try:
250
+ content = path.read_text(encoding="utf-8")
251
+ restored = self.vault.restore_secrets_in_content(
252
+ content, str(path), self.current_branch
253
+ )
254
+ if restored != content:
255
+ atomic_write_text(path, restored)
256
+ except (OSError, UnicodeDecodeError):
257
+ pass
258
+
259
+ return success
260
+
261
+ except Exception as e:
262
+ raise DotManError(f"Failed to revert {path}: {e}")
263
+
264
+ def get_changed_files_between_branches(
265
+ self, source_branch: str, target_branch: str
266
+ ) -> list[str]:
267
+ """Get list of files that differ between two branches.
268
+
269
+ Args:
270
+ source_branch: The source branch name
271
+ target_branch: The target branch name
272
+
273
+ Returns:
274
+ List of file paths that changed
275
+ """
276
+ try:
277
+ diff_output = self.git.repo.git.diff(
278
+ f"{source_branch}...{target_branch}", "--name-only"
279
+ )
280
+ if diff_output.strip():
281
+ return [f.strip() for f in diff_output.strip().split("\n") if f.strip()]
282
+ except Exception:
283
+ pass
284
+ return []
285
+
286
+ def detect_hooks_for_changed_files(self, changed_files: list[str]) -> list[str]:
287
+ """Determine which hooks to run based on changed files.
288
+
289
+ Args:
290
+ changed_files: List of file paths that changed
291
+
292
+ Returns:
293
+ List of hook commands to execute (deduplicated)
294
+ """
295
+ hooks_to_run = set()
296
+
297
+ for file_path in changed_files:
298
+ file_str = str(file_path)
299
+
300
+ for pattern, hook_name in FILE_TO_HOOK_MAP.items():
301
+ if pattern in file_str or file_str.endswith(pattern):
302
+ hook_cmd = get_hook_for_config(pattern)
303
+ if hook_cmd:
304
+ hooks_to_run.add(hook_cmd)
305
+ break
306
+
307
+ section_name = self._find_section_for_file(file_path)
308
+ if section_name:
309
+ section = self.get_section(section_name)
310
+ if section.pre_deploy:
311
+ hooks_to_run.add(section.pre_deploy)
312
+ if section.post_deploy:
313
+ hooks_to_run.add(section.post_deploy)
314
+
315
+ return list(hooks_to_run)
316
+
317
+ def _find_section_for_file(self, file_path: str) -> str | None:
318
+ """Find which section tracks a given file path.
319
+
320
+ Args:
321
+ file_path: Path to search for
322
+
323
+ Returns:
324
+ Section name or None
325
+ """
326
+ path = (
327
+ Path(file_path).expanduser()
328
+ if file_path.startswith("~")
329
+ else Path(file_path)
330
+ )
331
+
332
+ for section_name in self.get_sections():
333
+ section = self.get_section(section_name)
334
+ for section_path in section.paths:
335
+ if path == section_path or (
336
+ section_path.is_dir() and self._is_subpath(path, section_path)
337
+ ):
338
+ return section_name
339
+ return None
340
+
341
+ def _is_subpath(self, path: Path, parent: Path) -> bool:
342
+ """Check if path is under parent directory."""
343
+ try:
344
+ path.resolve().relative_to(parent.resolve())
345
+ return True
346
+ except ValueError:
347
+ return False
@@ -0,0 +1,113 @@
1
+ """dot-man CLI package.
2
+
3
+ This package provides the CLI entry point for dot-man.
4
+ It exposes the modular CLI implementation.
5
+ """
6
+
7
+ from .add_cmd import add
8
+ from .audit_cmd import audit
9
+ from .backup_cmd import backup
10
+ from .branch_cmd import branch
11
+ from .clean_cmd import clean
12
+ from .common import (
13
+ DotManGroup,
14
+ _clear_completion_cache,
15
+ _set_git_runner,
16
+ complete_branches,
17
+ complete_commits,
18
+ complete_config_keys,
19
+ complete_profiles,
20
+ complete_switch_args,
21
+ complete_tags,
22
+ complete_template_keys,
23
+ error,
24
+ get_secret_handler,
25
+ parse_branch_arg,
26
+ require_init,
27
+ success,
28
+ warn,
29
+ )
30
+ from .completions_cmd import completions
31
+ from .config_cmd import config
32
+ from .deploy_cmd import deploy
33
+ from .discover_cmd import discover_cmd
34
+ from .doctor_cmd import doctor
35
+ from .edit_cmd import edit
36
+ from .encrypt_cmd import encrypt_cmd
37
+ from .export_cmd import export_cmd
38
+ from .import_cmd import import_cmd
39
+
40
+ # Import commands for easier access and backward compatibility
41
+ from .init_cmd import init
42
+ from .interface import cli
43
+ from .log_cmd import checkout, diff, log
44
+ from .main import main
45
+ from .navigate_cmd import hooks, navigate
46
+ from .onboarding import is_first_run, mark_onboarded, run_onboarding
47
+ from .profile_cmd import profile
48
+ from .remote_cmd import remote, sync
49
+ from .restore_cmd import restore
50
+ from .revert_cmd import revert
51
+ from .show_cmd import show
52
+ from .status_cmd import status
53
+ from .switch_cmd import switch
54
+ from .tag_cmd import tag
55
+ from .template_cmd import template
56
+ from .tui_cmd import tui
57
+ from .verify_cmd import verify
58
+
59
+ __all__ = [
60
+ "main",
61
+ "cli",
62
+ "error",
63
+ "success",
64
+ "warn",
65
+ "require_init",
66
+ "DotManGroup",
67
+ "complete_branches",
68
+ "complete_tags",
69
+ "complete_commits",
70
+ "complete_template_keys",
71
+ "complete_config_keys",
72
+ "complete_profiles",
73
+ "complete_switch_args",
74
+ "parse_branch_arg",
75
+ "get_secret_handler",
76
+ "_set_git_runner",
77
+ "_clear_completion_cache",
78
+ "run_onboarding",
79
+ "is_first_run",
80
+ "mark_onboarded",
81
+ "init",
82
+ "add",
83
+ "status",
84
+ "switch",
85
+ "deploy",
86
+ "edit",
87
+ "audit",
88
+ "backup",
89
+ "branch",
90
+ "remote",
91
+ "sync",
92
+ "tui",
93
+ "config",
94
+ "revert",
95
+ "restore",
96
+ "clean",
97
+ "doctor",
98
+ "verify",
99
+ "log",
100
+ "show",
101
+ "checkout",
102
+ "diff",
103
+ "tag",
104
+ "template",
105
+ "profile",
106
+ "navigate",
107
+ "hooks",
108
+ "import_cmd",
109
+ "export_cmd",
110
+ "encrypt_cmd",
111
+ "discover_cmd",
112
+ "completions",
113
+ ]