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,169 @@
1
+ """Deploy command for dot-man CLI."""
2
+
3
+ import subprocess
4
+
5
+ import click
6
+ from rich.panel import Panel
7
+
8
+ from .. import ui
9
+ from ..exceptions import DotManError
10
+ from .common import (
11
+ complete_branches,
12
+ error,
13
+ handle_exception,
14
+ require_init,
15
+ success,
16
+ warn,
17
+ )
18
+ from .interface import cli as main
19
+
20
+
21
+ @main.command()
22
+ @click.argument("branch", shell_complete=complete_branches)
23
+ @click.option("--force", is_flag=True, help="Skip confirmation prompt")
24
+ @click.option("--dry-run", is_flag=True, help="Show what would be deployed")
25
+ @require_init
26
+ def deploy(branch: str, force: bool, dry_run: bool):
27
+ """One-way deployment of a branch configuration.
28
+
29
+ Deploys files from the specified branch to your home directory.
30
+ Unlike 'switch', this does NOT save current local changes first.
31
+ Typically used for setting up a new machine.
32
+
33
+ Example: dot-man deploy main
34
+ """
35
+ try:
36
+ from ..operations import get_operations
37
+
38
+ ops = get_operations()
39
+ git = ops.git
40
+
41
+ # Check branch exists
42
+ if not git.branch_exists(branch):
43
+ available = ", ".join(git.list_branches())
44
+ error(f"Branch '{branch}' not found. Available: {available}")
45
+
46
+ if not force and not dry_run:
47
+ ui.console.print(
48
+ Panel(
49
+ "[yellow]WARNING: Deploy will OVERWRITE local files![/yellow]\n\n"
50
+ "This will:\n"
51
+ f"• Deploy '{branch}' configuration\n"
52
+ "• Overwrite existing dotfiles\n"
53
+ "• Local changes will be LOST\n\n"
54
+ "[dim]Typical use: Setting up a new machine[/dim]",
55
+ title="⚠️ Destructive Operation",
56
+ border_style="yellow",
57
+ )
58
+ )
59
+
60
+ if not ui.confirm("Continue?"):
61
+ ui.info("Aborted.")
62
+ return
63
+
64
+ # Checkout branch
65
+ if not dry_run:
66
+ git.checkout(branch)
67
+ ops.reload_config()
68
+
69
+ # Get sections
70
+ section_names = ops.get_sections()
71
+ if not section_names:
72
+ warn("No files configured in this branch")
73
+ return
74
+
75
+ sections = [ops.get_section(name) for name in section_names]
76
+
77
+ # Phase 1: Scan
78
+ if not dry_run:
79
+ ui.console.print("Scanning for changes...")
80
+
81
+ plan = ops.scan_deployable_changes(sections)
82
+
83
+ sections_to_process = plan["sections_to_deploy"]
84
+ pre_hooks = list(dict.fromkeys(plan["pre_hooks"]))
85
+ post_hooks = list(dict.fromkeys(plan["post_hooks"]))
86
+ scan_errors = plan["errors"]
87
+
88
+ for err in scan_errors:
89
+ warn(err)
90
+
91
+ if not sections_to_process:
92
+ ui.console.print("[yellow]No changes detected.[/yellow]")
93
+ return
94
+
95
+ # Display Plan / Dry Run
96
+ if dry_run:
97
+ ui.console.print(
98
+ f"\n[bold]Dry Run Summary - {len(sections_to_process)} files to deploy:[/bold]"
99
+ )
100
+ for section, local_path, repo_path in sections_to_process:
101
+ action = "OVERWRITE" if local_path.exists() else "CREATE"
102
+ ui.console.print(f" {action}: {local_path}")
103
+
104
+ if pre_hooks:
105
+ ui.console.print("\n[bold]Pre-Hooks:[/bold]")
106
+ for cmd in pre_hooks:
107
+ ui.console.print(f" [dim]{cmd}[/dim]")
108
+
109
+ if post_hooks:
110
+ ui.console.print("\n[bold]Post-Hooks:[/bold]")
111
+ for cmd in post_hooks:
112
+ ui.console.print(f" [dim]{cmd}[/dim]")
113
+ return
114
+
115
+ # Confirm
116
+ ui.console.print(f"Found {len(sections_to_process)} files to deploy.")
117
+ if not force:
118
+ if not ui.confirm(f"Deploy {len(sections_to_process)} files?"):
119
+ ui.info("Aborted.")
120
+ return
121
+
122
+ # Execute Pre-Hooks
123
+ if pre_hooks:
124
+ ui.console.print("\n[bold]Running pre-deploy hooks...[/bold]")
125
+ for cmd in pre_hooks:
126
+ ui.console.print(f" Exec: [cyan]{cmd}[/cyan]")
127
+ try:
128
+ subprocess.run(cmd, shell=True, check=False)
129
+ except Exception as e:
130
+ warn(f"Failed to run command '{cmd}': {e}")
131
+
132
+ # Phase 2: Execute Deployment (Parallel)
133
+ ui.console.print("\n[bold]Deploying files...[/bold]")
134
+ result = ops.execute_deployment_plan(plan)
135
+
136
+ deployed = result["deployed"]
137
+ exec_errors = result["errors"]
138
+
139
+ if exec_errors:
140
+ for err in exec_errors:
141
+ ui.console.print(f" [red]Error:[/red] {err}")
142
+ import logging
143
+
144
+ logging.error(f"Deployment error: {err}")
145
+
146
+ ui.console.print(f"\nDeployed: {deployed}/{len(sections_to_process)} files.")
147
+
148
+ # Execute Post-Hooks
149
+ if post_hooks:
150
+ ui.console.print("\n[bold]Running post-deploy hooks...[/bold]")
151
+ for cmd in post_hooks:
152
+ ui.console.print(f" Exec: [cyan]{cmd}[/cyan]")
153
+ try:
154
+ subprocess.run(cmd, shell=True, check=False)
155
+ except Exception as e:
156
+ warn(f"Failed to run command '{cmd}': {e}")
157
+
158
+ # Update global config
159
+ ops.global_config.current_branch = branch
160
+ ops.global_config.save()
161
+
162
+ success(f"Deployment complete! ({deployed} files)")
163
+
164
+ except DotManError as e:
165
+ error(str(e), e.exit_code)
166
+ except KeyboardInterrupt:
167
+ handle_exception(KeyboardInterrupt())
168
+ except Exception as e:
169
+ handle_exception(e, "Deployment")
@@ -0,0 +1,105 @@
1
+ """Discover command for auto-detecting existing dotfiles."""
2
+
3
+ import click
4
+
5
+ from .. import ui
6
+ from ..config_detector import ConfigDetector
7
+ from .common import require_init
8
+ from .interface import cli as main
9
+
10
+
11
+ @main.command("discover")
12
+ @click.option(
13
+ "--include-extended/--no-extended",
14
+ default=True,
15
+ help="Include extended configs (VS Code, Sublime, etc.)",
16
+ )
17
+ @click.option(
18
+ "--add",
19
+ is_flag=True,
20
+ help="Automatically add detected configs to dot-man.toml",
21
+ )
22
+ @require_init
23
+ def discover_cmd(include_extended: bool, add: bool):
24
+ """Auto-detect existing dotfiles on your system.
25
+
26
+ Scans common locations for popular configuration files and directories,
27
+ then offers to add them to dot-man for tracking.
28
+
29
+ Examples:
30
+ dot-man discover # Show all detected dotfiles
31
+ dot-man discover --no-extended # Only common configs
32
+ dot-man discover --add # Auto-add to config
33
+ """
34
+ ui.console.print("[bold]🔍 Scanning for dotfiles...[/bold]")
35
+ ui.console.print()
36
+
37
+ detected = ConfigDetector.detect_popular_configs(include_extended=include_extended)
38
+ detected.extend(ConfigDetector.detect_quickshell_configs())
39
+
40
+ if not detected:
41
+ ui.console.print("[yellow]No dotfiles detected on this system.[/yellow]")
42
+ return
43
+
44
+ ui.console.print(f"[bold]Found {len(detected)} configurations:[/bold]")
45
+ ui.console.print()
46
+
47
+ for config in detected:
48
+ exists = "✓" if config["paths"] else "?"
49
+ hook = f" ({config['default_hook']})" if config["default_hook"] else ""
50
+ ui.console.print(f" [{exists}] {config['display_name']}")
51
+ ui.console.print(f" [{config['section_name']}]{hook}")
52
+ for path in config["paths"]:
53
+ ui.console.print(f" {path}")
54
+
55
+ if add:
56
+ _add_detected_configs(detected)
57
+ else:
58
+ ui.console.print()
59
+ ui.console.print(
60
+ "[dim]Tip: Use --add to automatically add these to dot-man.toml[/dim]"
61
+ )
62
+
63
+
64
+ def _add_detected_configs(detected: list):
65
+ """Add detected configs to dot-man.toml."""
66
+ from ..dotman_config import DotManConfig
67
+ from ..operations import get_operations
68
+
69
+ config = DotManConfig()
70
+ ops = get_operations()
71
+ added_count = 0
72
+
73
+ for conf in detected:
74
+ section_name = conf["section_name"]
75
+
76
+ if section_name in ops.get_sections():
77
+ continue
78
+
79
+ try:
80
+ config.add_section(
81
+ name=section_name,
82
+ paths=conf["paths"],
83
+ )
84
+
85
+ if conf["default_hook"]:
86
+ config.update_section(
87
+ section_name,
88
+ post_deploy=conf["default_hook"],
89
+ )
90
+
91
+ added_count += 1
92
+ ui.console.print(f" [green]✓[/green] Added: {section_name}")
93
+ except Exception as e:
94
+ ui.console.print(f" [red]✗[/red] Failed to add {section_name}: {e}")
95
+
96
+ config.save()
97
+
98
+ if added_count > 0:
99
+ ui.console.print()
100
+ ui.success(f"Added {added_count} sections to dot-man.toml")
101
+ ui.console.print("[dim]Run 'dot-man status' to see your tracked files[/dim]")
102
+ else:
103
+ ui.console.print(
104
+ "[dim]No new sections to add (already tracked or duplicates)[/dim]"
105
+ )
@@ -0,0 +1,229 @@
1
+ """Doctor command for dot-man CLI - diagnostics and health checks."""
2
+
3
+ import os
4
+ import shutil
5
+
6
+ from .. import ui
7
+ from ..constants import DOT_MAN_DIR, DOT_MAN_TOML, GLOBAL_TOML, REPO_DIR
8
+ from .common import error, require_init, success
9
+ from .interface import cli as main
10
+
11
+
12
+ @main.command("doctor")
13
+ @require_init
14
+ def doctor():
15
+ """Run diagnostics and health checks.
16
+
17
+ Checks the dot-man installation for common issues:
18
+ git availability, repository integrity, configuration validity,
19
+ file permissions, and remote connectivity.
20
+ """
21
+ from ..operations import get_operations
22
+
23
+ checks_passed = 0
24
+ checks_failed = 0
25
+ checks_warned = 0
26
+
27
+ def check_pass(label: str, detail: str = "") -> None:
28
+ nonlocal checks_passed
29
+ checks_passed += 1
30
+ msg = f"[success]✓[/success] {label}"
31
+ if detail:
32
+ msg += f" [dim]({detail})[/dim]"
33
+ ui.console.print(msg)
34
+
35
+ def check_fail(label: str, detail: str = "") -> None:
36
+ nonlocal checks_failed
37
+ checks_failed += 1
38
+ msg = f"[error]✗[/error] {label}"
39
+ if detail:
40
+ msg += f" [dim]({detail})[/dim]"
41
+ ui.console.print(msg)
42
+
43
+ def check_warn(label: str, detail: str = "") -> None:
44
+ nonlocal checks_warned
45
+ checks_warned += 1
46
+ msg = f"[warning]⚠[/warning] {label}"
47
+ if detail:
48
+ msg += f" [dim]({detail})[/dim]"
49
+ ui.console.print(msg)
50
+
51
+ ui.console.print("[bold]dot-man doctor[/bold]")
52
+ ui.console.print()
53
+
54
+ # 1. Git availability
55
+ ui.console.print("[bold]System[/bold]")
56
+ git_path = shutil.which("git")
57
+ if git_path:
58
+ import subprocess
59
+
60
+ result = subprocess.run(
61
+ ["git", "--version"], capture_output=True, text=True, timeout=5
62
+ )
63
+ version = result.stdout.strip() if result.returncode == 0 else "unknown"
64
+ check_pass("Git installed", version)
65
+ else:
66
+ check_fail("Git not found", "Install git and ensure it's in PATH")
67
+
68
+ # 2. Python version
69
+ import sys
70
+
71
+ py_version = (
72
+ f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
73
+ )
74
+ if sys.version_info >= (3, 9):
75
+ check_pass("Python version", py_version)
76
+ else:
77
+ check_fail("Python version too old", f"{py_version} (requires 3.9+)")
78
+
79
+ ui.console.print()
80
+
81
+ # 3. Repository checks
82
+ ui.console.print("[bold]Repository[/bold]")
83
+ if DOT_MAN_DIR.exists():
84
+ check_pass("Config directory exists", str(DOT_MAN_DIR))
85
+ else:
86
+ check_fail("Config directory missing", "Run 'dot-man init'")
87
+
88
+ if REPO_DIR.exists():
89
+ check_pass("Repository directory exists", str(REPO_DIR))
90
+ else:
91
+ check_fail("Repository directory missing", "Run 'dot-man init'")
92
+
93
+ # Check .git directory
94
+ git_dir = REPO_DIR / ".git"
95
+ if git_dir.exists():
96
+ check_pass("Git repository initialized")
97
+ else:
98
+ check_fail("No .git directory in repo", "Repository may be corrupted")
99
+
100
+ # Check repo permissions
101
+ if REPO_DIR.exists():
102
+ if os.access(REPO_DIR, os.R_OK | os.W_OK):
103
+ check_pass("Repository permissions", "read/write OK")
104
+ else:
105
+ check_fail("Repository permissions", "Cannot read/write to repo directory")
106
+
107
+ ui.console.print()
108
+
109
+ # 4. Configuration checks
110
+ ui.console.print("[bold]Configuration[/bold]")
111
+
112
+ if GLOBAL_TOML.exists():
113
+ check_pass("Global config exists", str(GLOBAL_TOML))
114
+ else:
115
+ check_warn("No global config", "Will use defaults")
116
+
117
+ if DOT_MAN_TOML.exists():
118
+ check_pass("dot-man.toml exists", str(DOT_MAN_TOML))
119
+ # Try to parse it
120
+ try:
121
+ ops = get_operations()
122
+ sections = ops.get_sections()
123
+ check_pass("Config is valid", f"{len(sections)} section(s)")
124
+ except Exception as e:
125
+ check_fail("Config parse error", str(e))
126
+ else:
127
+ check_warn("No dot-man.toml", "Run 'dot-man config create' or 'dot-man edit'")
128
+
129
+ ui.console.print()
130
+
131
+ # 5. Branch checks
132
+ ui.console.print("[bold]Branches[/bold]")
133
+ try:
134
+ ops = get_operations()
135
+ current = ops.current_branch
136
+ check_pass("Current branch", current)
137
+
138
+ branches = ops.git.list_branches()
139
+ check_pass("Available branches", ", ".join(branches) if branches else "none")
140
+ except Exception as e:
141
+ check_fail("Branch check failed", str(e))
142
+
143
+ ui.console.print()
144
+
145
+ # 6. Remote checks
146
+ ui.console.print("[bold]Remote[/bold]")
147
+ try:
148
+ ops = get_operations()
149
+ remote_url = ops.global_config.remote_url
150
+ if remote_url:
151
+ check_pass("Remote configured", remote_url)
152
+ else:
153
+ check_warn("No remote configured", "Run 'dot-man setup' to configure")
154
+ except Exception as e:
155
+ check_warn("Could not check remote", str(e))
156
+
157
+ ui.console.print()
158
+
159
+ # 7. Tracked files check
160
+ ui.console.print("[bold]Tracked Files[/bold]")
161
+ try:
162
+ ops = get_operations()
163
+ missing_count = 0
164
+ total_paths = 0
165
+ for section_name in ops.get_sections():
166
+ section = ops.get_section(section_name)
167
+ for p in section.paths:
168
+ total_paths += 1
169
+ if not p.exists():
170
+ missing_count += 1
171
+ check_warn(f"Missing: {p}", f"section '{section_name}'")
172
+
173
+ if missing_count == 0 and total_paths > 0:
174
+ check_pass("All tracked paths exist", f"{total_paths} path(s)")
175
+ elif total_paths == 0:
176
+ check_warn("No paths tracked", "Add files with 'dot-man add'")
177
+ except Exception as e:
178
+ check_fail("File check failed", str(e))
179
+
180
+ # 8. Orphaned files
181
+ try:
182
+ ops = get_operations()
183
+ orphans = ops.get_orphaned_files()
184
+ if orphans:
185
+ check_warn(
186
+ f"{len(orphans)} orphaned file(s) in repo",
187
+ "Run 'dot-man clean --orphans' to remove",
188
+ )
189
+ else:
190
+ check_pass("No orphaned files")
191
+ except Exception as e:
192
+ check_warn("Could not check orphans", str(e))
193
+
194
+ # 9. Backup check
195
+ try:
196
+ ops = get_operations()
197
+ backup_list = ops.backups.list_backups()
198
+ if backup_list:
199
+ check_pass("Backups available", f"{len(backup_list)} backup(s)")
200
+ else:
201
+ check_warn(
202
+ "No backups",
203
+ "Backups are created automatically before destructive operations",
204
+ )
205
+ except Exception as e:
206
+ check_warn("Could not check backups", str(e))
207
+
208
+ ui.console.print()
209
+
210
+ # Summary
211
+ ui.console.print("[bold]Summary[/bold]")
212
+ total = checks_passed + checks_failed + checks_warned
213
+ ui.console.print(
214
+ f" [success]{checks_passed}[/success] passed, "
215
+ f"[warning]{checks_warned}[/warning] warnings, "
216
+ f"[error]{checks_failed}[/error] failed "
217
+ f"[dim]({total} checks)[/dim]"
218
+ )
219
+
220
+ if checks_failed == 0:
221
+ ui.console.print()
222
+ success("dot-man is healthy!")
223
+ else:
224
+ ui.console.print()
225
+ error(
226
+ f"{checks_failed} check(s) failed. See above for details.",
227
+ exit_code=0,
228
+ )
229
+ raise SystemExit(1)
@@ -0,0 +1,177 @@
1
+ """Edit command for dot-man CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ import questionary
7
+
8
+ from .. import ui
9
+ from ..config import GlobalConfig
10
+ from ..constants import DOT_MAN_TOML, GLOBAL_TOML, REPO_DIR
11
+ from ..exceptions import DotManError
12
+ from .common import error, require_init, success, warn
13
+ from .interface import cli as main
14
+
15
+
16
+ @main.command()
17
+ @click.option("--editor", help="Editor to use (default: config or $VISUAL or $EDITOR)")
18
+ @click.option("--global", "edit_global", is_flag=True, help="Edit global configuration")
19
+ @click.option(
20
+ "--raw", is_flag=True, help="Use raw text editor instead of interactive TUI"
21
+ )
22
+ @require_init
23
+ def edit(editor: str | None, edit_global: bool, raw: bool):
24
+ """Open the configuration file in your text editor.
25
+
26
+ By default, opens the dot-man.toml file for the current branch.
27
+ Use --global to edit the global configuration.
28
+ """
29
+ try:
30
+ from ..interactive import (
31
+ custom_style,
32
+ run_global_wizard,
33
+ run_section_wizard,
34
+ run_templates_wizard,
35
+ )
36
+
37
+ # Determine target file path
38
+ if edit_global:
39
+ target = GLOBAL_TOML
40
+ desc = "global configuration"
41
+ else:
42
+ target = REPO_DIR / DOT_MAN_TOML
43
+ desc = "dot-man.toml"
44
+
45
+ # If --raw flag, skip interactive mode
46
+ if raw:
47
+ if not target.exists():
48
+ error(f"Configuration file not found: {target}")
49
+ _open_raw_editor(target, desc, editor)
50
+ return
51
+
52
+ # Interactive Mode
53
+ from ..operations import get_operations
54
+
55
+ try:
56
+ ops = get_operations()
57
+
58
+ while True:
59
+ ops.reload_config()
60
+ sections = ops.get_sections()
61
+
62
+ choices = []
63
+ choices.append(
64
+ questionary.Choice("⚙️ Global Configuration", value="global")
65
+ )
66
+
67
+ if sections:
68
+ choices.append(questionary.Separator("--- Sections ---"))
69
+ for name in sections:
70
+ choices.append(
71
+ questionary.Choice(f"📄 {name}", value=f"section:{name}")
72
+ )
73
+ else:
74
+ choices.append(questionary.Separator("--- No Sections ---"))
75
+
76
+ choices.append(questionary.Separator("--- Actions ---"))
77
+ choices.append(
78
+ questionary.Choice("➕ Add New Section", value="add_new")
79
+ )
80
+ choices.append(
81
+ questionary.Choice("📝 Edit Templates", value="templates")
82
+ )
83
+ choices.append(
84
+ questionary.Choice("📝 Open Raw File (Advanced)", value="raw")
85
+ )
86
+ choices.append(
87
+ questionary.Choice("🚪 Quit", value="quit", shortcut_key="q")
88
+ )
89
+
90
+ selection = questionary.select(
91
+ "What would you like to configure?",
92
+ choices=choices,
93
+ use_shortcuts=True,
94
+ style=custom_style,
95
+ ).ask()
96
+
97
+ if not selection or selection == "quit":
98
+ break
99
+
100
+ if selection == "global":
101
+ run_global_wizard(ops.global_config)
102
+
103
+ elif selection == "raw":
104
+ _open_raw_editor(target, desc, editor)
105
+ break
106
+
107
+ elif selection == "add_new":
108
+ path_str = questionary.path(
109
+ "Path to file or directory:", style=custom_style
110
+ ).ask()
111
+ if path_str:
112
+ try:
113
+ path = Path(path_str).expanduser()
114
+ if not path.exists():
115
+ warn(f"Path does not exist: {path}")
116
+ continue
117
+
118
+ section_name = questionary.text(
119
+ "Section Name:", default=path.stem, style=custom_style
120
+ ).ask()
121
+ if section_name:
122
+ from .add_cmd import add
123
+
124
+ ctx = click.get_current_context()
125
+ ctx.invoke(
126
+ add,
127
+ path=str(path),
128
+ section=section_name,
129
+ repo_base=None,
130
+ exclude=(),
131
+ include=(),
132
+ inherits=(),
133
+ post_deploy=None,
134
+ pre_deploy=None,
135
+ )
136
+ ui.console.print()
137
+ ui.console.print("Press Enter to continue...")
138
+ input()
139
+ except Exception as e:
140
+ warn(f"Error adding section: {e}")
141
+
142
+ elif selection == "templates":
143
+ run_templates_wizard(ops.dotman_config)
144
+
145
+ elif selection.startswith("section:"):
146
+ section_name = selection.split(":", 1)[1]
147
+ run_section_wizard(ops.dotman_config, section_name)
148
+
149
+ except KeyboardInterrupt:
150
+ return
151
+ except Exception as e:
152
+ warn(f"Interactive menu error: {e}")
153
+ ui.console.print("Falling back to raw editor...")
154
+ _open_raw_editor(target, desc, editor)
155
+
156
+ except DotManError as e:
157
+ error(str(e), e.exit_code)
158
+
159
+
160
+ def _open_raw_editor(target: Path, desc: str, editor: str | None = None):
161
+ """Helper to open raw editor."""
162
+ from ..utils import get_editor, open_in_editor
163
+
164
+ global_config = GlobalConfig()
165
+ try:
166
+ global_config.load()
167
+ config_editor = global_config.editor
168
+ except (FileNotFoundError, DotManError):
169
+ config_editor = None
170
+
171
+ editor_cmd = editor or config_editor or get_editor()
172
+ ui.console.print(f"Opening {desc} in [cyan]{editor_cmd}[/cyan]...")
173
+
174
+ if not open_in_editor(target, editor_cmd):
175
+ error(f"Editor '{editor_cmd}' exited with error")
176
+
177
+ success(f"Edited {desc}")