scc-cli 1.5.3__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.

Potentially problematic release.


This version of scc-cli might be problematic. Click here for more details.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,278 @@
1
+ """Git worktree health checks for doctor module.
2
+
3
+ Checks for worktree health, version compatibility, and branch conflicts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ..types import CheckResult
13
+
14
+
15
+ def check_worktree_health(cwd: Path | None = None) -> CheckResult | None:
16
+ """Check health of git worktrees in the current repository.
17
+
18
+ Parses `git worktree list --porcelain` to detect:
19
+ - Prunable worktrees (stale entries)
20
+ - Locked worktrees (with lock reason)
21
+ - Detached HEAD states
22
+ - Branch conflicts (branch checked out elsewhere)
23
+
24
+ Args:
25
+ cwd: Directory to check (defaults to current working directory).
26
+
27
+ Returns:
28
+ CheckResult with worktree health status, or None if not in a git repo.
29
+ """
30
+ if cwd is None:
31
+ cwd = Path.cwd()
32
+
33
+ # Check if we're in a git repo
34
+ try:
35
+ result = subprocess.run(
36
+ ["git", "-C", str(cwd), "rev-parse", "--is-inside-work-tree"],
37
+ capture_output=True,
38
+ text=True,
39
+ timeout=5,
40
+ )
41
+ if result.returncode != 0:
42
+ # Not a git repo - skip check
43
+ return None
44
+ except (subprocess.TimeoutExpired, FileNotFoundError):
45
+ return None
46
+
47
+ # Parse worktree list --porcelain
48
+ worktree_data = _parse_worktree_porcelain(cwd)
49
+ if not worktree_data:
50
+ # No worktrees or failed to parse
51
+ return CheckResult(
52
+ name="Worktrees",
53
+ passed=True,
54
+ message="No worktrees configured (single checkout)",
55
+ )
56
+
57
+ # Count issues
58
+ prunable_count = sum(1 for wt in worktree_data if wt.get("prunable"))
59
+ locked_count = sum(1 for wt in worktree_data if wt.get("locked"))
60
+ detached_count = sum(1 for wt in worktree_data if wt.get("detached"))
61
+
62
+ # Build summary message
63
+ issues = []
64
+ if prunable_count > 0:
65
+ issues.append(f"{prunable_count} prunable")
66
+ if locked_count > 0:
67
+ issues.append(f"{locked_count} locked")
68
+ if detached_count > 0:
69
+ issues.append(f"{detached_count} detached")
70
+
71
+ total = len(worktree_data)
72
+
73
+ if not issues:
74
+ return CheckResult(
75
+ name="Worktrees",
76
+ passed=True,
77
+ message=f"{total} worktree{'s' if total != 1 else ''}, all healthy",
78
+ )
79
+
80
+ # Build fix hints
81
+ fix_hints = []
82
+ fix_commands = []
83
+ if prunable_count > 0:
84
+ fix_hints.append("Remove stale worktree entries with prune")
85
+ fix_commands.append("scc worktree prune")
86
+ if locked_count > 0:
87
+ # Find locked worktrees and show reasons
88
+ for wt in worktree_data:
89
+ if wt.get("locked"):
90
+ reason = wt.get("lock_reason", "no reason given")
91
+ path = Path(wt.get("path", "")).name
92
+ fix_hints.append(f"'{path}' locked: {reason}")
93
+
94
+ return CheckResult(
95
+ name="Worktrees",
96
+ passed=prunable_count == 0, # Fail only if prunable (needs cleanup)
97
+ message=f"{total} worktree{'s' if total != 1 else ''}: {', '.join(issues)}",
98
+ fix_hint="; ".join(fix_hints) if fix_hints else None,
99
+ fix_commands=fix_commands if fix_commands else None,
100
+ severity="warning" if prunable_count > 0 else "info",
101
+ )
102
+
103
+
104
+ def _parse_worktree_porcelain(repo_path: Path) -> list[dict[str, Any]]:
105
+ """Parse git worktree list --porcelain output.
106
+
107
+ Porcelain format example:
108
+ worktree /path/to/main
109
+ HEAD abc123
110
+ branch refs/heads/main
111
+
112
+ worktree /path/to/feature
113
+ HEAD def456
114
+ branch refs/heads/feature
115
+ locked
116
+ locked reason: deployment in progress
117
+
118
+ worktree /path/to/old
119
+ HEAD ghi789
120
+ detached
121
+ prunable
122
+
123
+ Returns:
124
+ List of dicts with keys: path, branch, detached, locked, lock_reason, prunable
125
+ """
126
+ try:
127
+ result = subprocess.run(
128
+ ["git", "-C", str(repo_path), "worktree", "list", "--porcelain"],
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=10,
132
+ )
133
+ if result.returncode != 0:
134
+ return []
135
+
136
+ worktrees = []
137
+ current: dict[str, Any] = {}
138
+
139
+ for line in result.stdout.split("\n"):
140
+ if line.startswith("worktree "):
141
+ # Start of new worktree entry
142
+ if current and current.get("path"):
143
+ worktrees.append(current)
144
+ current = {
145
+ "path": line[9:],
146
+ "branch": "",
147
+ "detached": False,
148
+ "locked": False,
149
+ "lock_reason": "",
150
+ "prunable": False,
151
+ }
152
+ elif line.startswith("branch "):
153
+ current["branch"] = line[7:].replace("refs/heads/", "")
154
+ elif line == "detached":
155
+ current["detached"] = True
156
+ elif line == "locked":
157
+ current["locked"] = True
158
+ elif line.startswith("locked "):
159
+ # "locked reason: ..." format
160
+ current["locked"] = True
161
+ current["lock_reason"] = line[7:] # Includes "reason: " prefix
162
+ elif line == "prunable":
163
+ current["prunable"] = True
164
+ elif line.startswith("prunable "):
165
+ # "prunable reason: ..." format
166
+ current["prunable"] = True
167
+
168
+ # Don't forget the last worktree
169
+ if current and current.get("path"):
170
+ worktrees.append(current)
171
+
172
+ return worktrees
173
+
174
+ except (subprocess.TimeoutExpired, FileNotFoundError):
175
+ return []
176
+
177
+
178
+ def check_git_version_for_worktrees() -> CheckResult | None:
179
+ """Check if git version supports stable worktree operations.
180
+
181
+ Git 2.20+ is recommended for stable worktree behavior.
182
+ Earlier versions may have issues with locked worktrees and pruning.
183
+
184
+ Returns:
185
+ CheckResult with version check status, or None if git not installed.
186
+ """
187
+ from ... import git as git_module
188
+
189
+ if not git_module.check_git_installed():
190
+ return None # Already covered by check_git()
191
+
192
+ version = git_module.get_git_version()
193
+ if not version:
194
+ return None
195
+
196
+ # Parse version (e.g., "git version 2.39.3" or "git version 2.39.3 (Apple Git-145)")
197
+ # Extract the version number (third word or second word if starts with number)
198
+ words = version.split()
199
+ version_str = ""
200
+ for word in words:
201
+ if word and word[0].isdigit():
202
+ version_str = word
203
+ break
204
+
205
+ if not version_str:
206
+ return None
207
+
208
+ try:
209
+ parts = version_str.split(".")
210
+ major = int(parts[0]) if len(parts) > 0 else 0
211
+ minor = int(parts[1]) if len(parts) > 1 else 0
212
+
213
+ if major < 2 or (major == 2 and minor < 20):
214
+ return CheckResult(
215
+ name="Git Version (Worktrees)",
216
+ passed=True, # Still pass, just warn
217
+ message=f"Git {version_str} works, but 2.20+ recommended for worktrees",
218
+ severity="info",
219
+ )
220
+
221
+ return CheckResult(
222
+ name="Git Version (Worktrees)",
223
+ passed=True,
224
+ message=f"Git {version_str} fully supports worktrees",
225
+ )
226
+ except (ValueError, IndexError):
227
+ # Can't parse version, skip
228
+ return None
229
+
230
+
231
+ def check_worktree_branch_conflicts(cwd: Path | None = None) -> CheckResult | None:
232
+ """Check for branches that are checked out in multiple worktrees.
233
+
234
+ This is a common source of confusion when switching worktrees.
235
+ Git prevents checking out a branch that's already checked out elsewhere.
236
+
237
+ Args:
238
+ cwd: Directory to check (defaults to current working directory).
239
+
240
+ Returns:
241
+ CheckResult with branch conflict status, or None if not in a git repo.
242
+ """
243
+ if cwd is None:
244
+ cwd = Path.cwd()
245
+
246
+ worktree_data = _parse_worktree_porcelain(cwd)
247
+ if len(worktree_data) < 2:
248
+ # Need at least 2 worktrees for conflicts
249
+ return None
250
+
251
+ # Build a map of branch -> worktrees
252
+ branch_worktrees: dict[str, list[str]] = {}
253
+ for wt in worktree_data:
254
+ branch = wt.get("branch")
255
+ if branch:
256
+ path = Path(wt.get("path", "")).name
257
+ if branch not in branch_worktrees:
258
+ branch_worktrees[branch] = []
259
+ branch_worktrees[branch].append(path)
260
+
261
+ # Find branches checked out in multiple worktrees
262
+ conflicts = {branch: paths for branch, paths in branch_worktrees.items() if len(paths) > 1}
263
+
264
+ if not conflicts:
265
+ return None # No conflicts to report
266
+
267
+ # Build message
268
+ conflict_msgs = []
269
+ for branch, paths in conflicts.items():
270
+ conflict_msgs.append(f"'{branch}' in: {', '.join(paths)}")
271
+
272
+ return CheckResult(
273
+ name="Branch Conflicts",
274
+ passed=False,
275
+ message=f"Branch checked out in multiple worktrees: {'; '.join(conflict_msgs)}",
276
+ fix_hint="Each branch can only be checked out in one worktree at a time",
277
+ severity="error",
278
+ )
@@ -0,0 +1,365 @@
1
+ """Orchestration and rendering functions for the doctor module.
2
+
3
+ This module contains:
4
+ - run_doctor(): Main orchestrator that runs all health checks
5
+ - build_doctor_json_data(): JSON serialization for CLI output
6
+ - render_doctor_results(): Rich terminal UI rendering
7
+ - render_doctor_compact(): Compact inline status display
8
+ - render_quick_status(): Single-line pass/fail indicator
9
+ - quick_check(): Fast prerequisite validation
10
+ - is_first_run(): First-run detection
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from rich import box
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+
24
+ from scc_cli import __version__
25
+
26
+ from .checks import (
27
+ check_config_directory,
28
+ check_docker,
29
+ check_docker_running,
30
+ check_docker_sandbox,
31
+ check_git,
32
+ check_user_config_valid,
33
+ check_workspace_path,
34
+ check_wsl2,
35
+ )
36
+ from .types import DoctorResult
37
+
38
+ # ═══════════════════════════════════════════════════════════════════════════════
39
+ # JSON Serialization
40
+ # ═══════════════════════════════════════════════════════════════════════════════
41
+
42
+
43
+ def build_doctor_json_data(result: DoctorResult) -> dict[str, Any]:
44
+ """Build JSON-serializable data from DoctorResult.
45
+
46
+ Args:
47
+ result: The DoctorResult to convert.
48
+
49
+ Returns:
50
+ Dictionary suitable for JSON envelope data field.
51
+ """
52
+ checks_data = []
53
+ for check in result.checks:
54
+ check_dict: dict[str, Any] = {
55
+ "name": check.name,
56
+ "passed": check.passed,
57
+ "message": check.message,
58
+ "severity": check.severity,
59
+ }
60
+ if check.version:
61
+ check_dict["version"] = check.version
62
+ if check.fix_hint:
63
+ check_dict["fix_hint"] = check.fix_hint
64
+ if check.fix_url:
65
+ check_dict["fix_url"] = check.fix_url
66
+ if check.fix_commands:
67
+ check_dict["fix_commands"] = check.fix_commands
68
+ if check.code_frame:
69
+ check_dict["code_frame"] = check.code_frame
70
+ checks_data.append(check_dict)
71
+
72
+ # Calculate summary stats
73
+ total = len(result.checks)
74
+ passed = sum(1 for c in result.checks if c.passed)
75
+ errors = sum(1 for c in result.checks if not c.passed and c.severity == "error")
76
+ warnings = sum(1 for c in result.checks if not c.passed and c.severity == "warning")
77
+
78
+ return {
79
+ "checks": checks_data,
80
+ "summary": {
81
+ "total": total,
82
+ "passed": passed,
83
+ "errors": errors,
84
+ "warnings": warnings,
85
+ "all_ok": result.all_ok,
86
+ },
87
+ }
88
+
89
+
90
+ # ═══════════════════════════════════════════════════════════════════════════════
91
+ # Main Doctor Orchestrator
92
+ # ═══════════════════════════════════════════════════════════════════════════════
93
+
94
+
95
+ def run_doctor(workspace: Path | None = None) -> DoctorResult:
96
+ """Run all health checks and return comprehensive results.
97
+
98
+ Args:
99
+ workspace: Optional workspace path to check for optimization
100
+
101
+ Returns:
102
+ DoctorResult with all check results
103
+ """
104
+ result = DoctorResult()
105
+
106
+ # Git check
107
+ git_check = check_git()
108
+ result.checks.append(git_check)
109
+ result.git_ok = git_check.passed
110
+ result.git_version = git_check.version
111
+
112
+ # Docker check
113
+ docker_check = check_docker()
114
+ result.checks.append(docker_check)
115
+ result.docker_ok = docker_check.passed
116
+ result.docker_version = docker_check.version
117
+
118
+ # Docker daemon check (only if Docker is installed)
119
+ if result.docker_ok:
120
+ daemon_check = check_docker_running()
121
+ result.checks.append(daemon_check)
122
+ if not daemon_check.passed:
123
+ result.docker_ok = False
124
+
125
+ # Docker sandbox check (only if Docker is OK)
126
+ if result.docker_ok:
127
+ sandbox_check = check_docker_sandbox()
128
+ result.checks.append(sandbox_check)
129
+ result.sandbox_ok = sandbox_check.passed
130
+ else:
131
+ result.sandbox_ok = False
132
+
133
+ # WSL2 check
134
+ wsl2_check, is_wsl2 = check_wsl2()
135
+ result.checks.append(wsl2_check)
136
+ result.wsl2_detected = is_wsl2
137
+
138
+ # Workspace path check (if WSL2 and workspace provided)
139
+ if workspace:
140
+ path_check = check_workspace_path(workspace)
141
+ result.checks.append(path_check)
142
+ result.windows_path_warning = not path_check.passed and path_check.severity == "warning"
143
+
144
+ # Config directory check
145
+ config_check = check_config_directory()
146
+ result.checks.append(config_check)
147
+
148
+ # Git worktree health checks (may return None if not in a git repo)
149
+ from .checks import (
150
+ check_git_version_for_worktrees,
151
+ check_worktree_branch_conflicts,
152
+ check_worktree_health,
153
+ )
154
+
155
+ git_version_wt_check = check_git_version_for_worktrees()
156
+ if git_version_wt_check is not None:
157
+ result.checks.append(git_version_wt_check)
158
+
159
+ worktree_health_check = check_worktree_health()
160
+ if worktree_health_check is not None:
161
+ result.checks.append(worktree_health_check)
162
+
163
+ branch_conflict_check = check_worktree_branch_conflicts()
164
+ if branch_conflict_check is not None:
165
+ result.checks.append(branch_conflict_check)
166
+
167
+ # User config JSON validation check
168
+ user_config_check = check_user_config_valid()
169
+ result.checks.append(user_config_check)
170
+
171
+ return result
172
+
173
+
174
+ # ═══════════════════════════════════════════════════════════════════════════════
175
+ # Rich Terminal UI Rendering
176
+ # ═══════════════════════════════════════════════════════════════════════════════
177
+
178
+
179
+ def render_doctor_results(console: Console, result: DoctorResult) -> None:
180
+ """Render doctor results with beautiful Rich formatting.
181
+
182
+ Uses consistent styling with the rest of the CLI:
183
+ - Cyan for info/brand
184
+ - Green for success
185
+ - Yellow for warnings
186
+ - Red for errors
187
+ """
188
+ # Header
189
+ console.print()
190
+
191
+ # Build results table
192
+ table = Table(
193
+ box=box.ROUNDED,
194
+ show_header=True,
195
+ header_style="bold cyan",
196
+ border_style="dim",
197
+ padding=(0, 1),
198
+ )
199
+
200
+ table.add_column("Status", width=8, justify="center")
201
+ table.add_column("Check", min_width=20)
202
+ table.add_column("Details", min_width=30)
203
+
204
+ for check in result.checks:
205
+ # Status icon with color
206
+ if check.passed:
207
+ status = Text(" ", style="bold green")
208
+ elif check.severity == "warning":
209
+ status = Text(" ", style="bold yellow")
210
+ else:
211
+ status = Text(" ", style="bold red")
212
+
213
+ # Check name
214
+ name = Text(check.name, style="white")
215
+
216
+ # Details with version and message
217
+ details = Text()
218
+ if check.version:
219
+ details.append(f"{check.version}\n", style="cyan")
220
+ details.append(check.message, style="dim" if check.passed else "white")
221
+
222
+ if not check.passed and check.fix_hint:
223
+ details.append(f"\n{check.fix_hint}", style="yellow")
224
+
225
+ table.add_row(status, name, details)
226
+
227
+ # Wrap table in panel
228
+ title_style = "bold green" if result.all_ok else "bold red"
229
+ version_suffix = f" (scc-cli v{__version__})"
230
+ title_text = (
231
+ f"System Health Check{version_suffix}"
232
+ if result.all_ok
233
+ else f"System Health Check - Issues Found{version_suffix}"
234
+ )
235
+
236
+ panel = Panel(
237
+ table,
238
+ title=f"[{title_style}]{title_text}[/{title_style}]",
239
+ border_style="green" if result.all_ok else "red",
240
+ padding=(1, 1),
241
+ )
242
+
243
+ console.print(panel)
244
+
245
+ # Display code frames for any checks with syntax errors (beautiful error display)
246
+ code_frame_checks = [c for c in result.checks if c.code_frame and not c.passed]
247
+ for check in code_frame_checks:
248
+ if check.code_frame is not None: # Type guard for mypy
249
+ console.print()
250
+ # Create a panel for the code frame with Rich styling
251
+ code_panel = Panel(
252
+ check.code_frame,
253
+ title=f"[bold red]⚠️ JSON Syntax Error: {check.name}[/bold red]",
254
+ border_style="red",
255
+ padding=(1, 2),
256
+ )
257
+ console.print(code_panel)
258
+
259
+ # Summary line
260
+ if result.all_ok:
261
+ console.print()
262
+ console.print(
263
+ " [bold green]All prerequisites met![/bold green] [dim]Ready to run Claude Code.[/dim]"
264
+ )
265
+ else:
266
+ console.print()
267
+ summary_parts = []
268
+ if result.error_count > 0:
269
+ summary_parts.append(f"[bold red]{result.error_count} error(s)[/bold red]")
270
+ if result.warning_count > 0:
271
+ summary_parts.append(f"[bold yellow]{result.warning_count} warning(s)[/bold yellow]")
272
+
273
+ console.print(f" Found {' and '.join(summary_parts)}. ", end="")
274
+ console.print("[dim]Fix the issues above to continue.[/dim]")
275
+
276
+ # Next Steps section with fix_commands
277
+ checks_with_commands = [c for c in result.checks if not c.passed and c.fix_commands]
278
+ if checks_with_commands:
279
+ console.print()
280
+ console.print(" [bold cyan]Next Steps[/bold cyan]")
281
+ console.print(" [dim]────────────────────────────────────────────────────[/dim]")
282
+ console.print()
283
+
284
+ for check in checks_with_commands:
285
+ console.print(f" [bold white]{check.name}:[/bold white]")
286
+ if check.fix_hint:
287
+ console.print(f" [dim]{check.fix_hint}[/dim]")
288
+ if check.fix_commands:
289
+ for i, cmd in enumerate(check.fix_commands, 1):
290
+ console.print(f" [cyan]{i}.[/cyan] [white]{cmd}[/white]")
291
+ console.print()
292
+
293
+ console.print()
294
+
295
+
296
+ def render_doctor_compact(console: Console, result: DoctorResult) -> None:
297
+ """Render compact doctor results for inline display.
298
+
299
+ Used during startup to show quick status.
300
+ """
301
+ checks = []
302
+
303
+ # Git
304
+ if result.git_ok:
305
+ checks.append("[green]Git[/green]")
306
+ else:
307
+ checks.append("[red]Git[/red]")
308
+
309
+ # Docker
310
+ if result.docker_ok:
311
+ checks.append("[green]Docker[/green]")
312
+ else:
313
+ checks.append("[red]Docker[/red]")
314
+
315
+ # Sandbox
316
+ if result.sandbox_ok:
317
+ checks.append("[green]Sandbox[/green]")
318
+ else:
319
+ checks.append("[red]Sandbox[/red]")
320
+
321
+ console.print(f" [dim]Prerequisites:[/dim] {' | '.join(checks)}")
322
+
323
+
324
+ def render_quick_status(console: Console, result: DoctorResult) -> None:
325
+ """Render a single-line status for quick checks.
326
+
327
+ Returns immediately with pass/fail indicator.
328
+ """
329
+ if result.all_ok:
330
+ console.print("[green] All systems operational[/green]")
331
+ else:
332
+ failed = [c.name for c in result.checks if not c.passed and c.severity == "error"]
333
+ console.print(f"[red] Issues detected:[/red] {', '.join(failed)}")
334
+
335
+
336
+ # ═══════════════════════════════════════════════════════════════════════════════
337
+ # Quick Check Utilities
338
+ # ═══════════════════════════════════════════════════════════════════════════════
339
+
340
+
341
+ def quick_check() -> bool:
342
+ """Perform a quick prerequisite check.
343
+
344
+ Returns True if all critical prerequisites are met.
345
+ Used for fast startup validation.
346
+ """
347
+ result = run_doctor()
348
+ return result.all_ok
349
+
350
+
351
+ def is_first_run() -> bool:
352
+ """Check if this is the first run of scc.
353
+
354
+ Returns True if config directory doesn't exist or is empty.
355
+ """
356
+ from scc_cli import config
357
+
358
+ config_dir = config.CONFIG_DIR
359
+
360
+ if not config_dir.exists():
361
+ return True
362
+
363
+ # Check if config file exists
364
+ config_file = config.CONFIG_FILE
365
+ return not config_file.exists()
@@ -0,0 +1,66 @@
1
+ """Define data types for the doctor health check module.
2
+
3
+ Provide dataclasses for representing check results, validation results,
4
+ and overall doctor diagnostic results.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass
14
+ class CheckResult:
15
+ """Result of a single health check."""
16
+
17
+ name: str
18
+ passed: bool
19
+ message: str
20
+ version: str | None = None
21
+ fix_hint: str | None = None
22
+ fix_url: str | None = None
23
+ severity: str = "error" # "error", "warning", "info"
24
+ code_frame: str | None = None # Optional code frame for syntax errors
25
+ fix_commands: list[str] | None = None # Copy-pasteable fix commands
26
+
27
+
28
+ @dataclass
29
+ class JsonValidationResult:
30
+ """Result of JSON file validation with error details."""
31
+
32
+ valid: bool
33
+ error_message: str | None = None
34
+ line: int | None = None
35
+ column: int | None = None
36
+ file_path: Path | None = None
37
+ code_frame: str | None = None
38
+
39
+
40
+ @dataclass
41
+ class DoctorResult:
42
+ """Complete health check results."""
43
+
44
+ git_ok: bool = False
45
+ git_version: str | None = None
46
+ docker_ok: bool = False
47
+ docker_version: str | None = None
48
+ sandbox_ok: bool = False
49
+ wsl2_detected: bool = False
50
+ windows_path_warning: bool = False
51
+ checks: list[CheckResult] = field(default_factory=list)
52
+
53
+ @property
54
+ def all_ok(self) -> bool:
55
+ """Check if all critical prerequisites pass."""
56
+ return self.git_ok and self.docker_ok and self.sandbox_ok
57
+
58
+ @property
59
+ def error_count(self) -> int:
60
+ """Return the count of failed critical checks."""
61
+ return sum(1 for c in self.checks if not c.passed and c.severity == "error")
62
+
63
+ @property
64
+ def warning_count(self) -> int:
65
+ """Return the count of warnings."""
66
+ return sum(1 for c in self.checks if not c.passed and c.severity == "warning")