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.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +383 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
scc_cli/doctor/render.py
ADDED
|
@@ -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()
|
scc_cli/doctor/types.py
ADDED
|
@@ -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")
|