devdoctor-cli 0.1.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.
@@ -0,0 +1,65 @@
1
+ """PostgreSQL service and connection diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import socket
7
+ from typing import List
8
+
9
+ from devdoctor.checks.base import Check, register_check
10
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
11
+ from devdoctor.utils.platform_info import is_windows
12
+ from devdoctor.utils.process_utils import is_port_in_use
13
+
14
+
15
+ @register_check
16
+ class PostgresCheck(Check):
17
+ name = "postgres"
18
+ title = "PostgreSQL Diagnostics"
19
+ category = "framework"
20
+ description = "Checks PostgreSQL service availability, port 5432 reachability, and configuration."
21
+
22
+ def run(self, context: ScanContext) -> List[Diagnostic]:
23
+ diagnostics: List[Diagnostic] = []
24
+ is_postgres_project = context.project_info and "PostgreSQL" in context.project_info.technologies
25
+
26
+ # Check port 5432
27
+ port_open = is_port_in_use(5432)
28
+
29
+ if port_open:
30
+ diagnostics.append(Diagnostic(
31
+ title="PostgreSQL service listening",
32
+ severity=Severity.OK,
33
+ message="PostgreSQL server is listening on port 5432.",
34
+ check_id=f"{self.name}_port_ok",
35
+ ))
36
+ elif is_postgres_project:
37
+ start_cmd = "docker compose up -d postgres" if (context.project_path / "docker-compose.yml").exists() else ("net start postgresql-x64" if is_windows() else "sudo systemctl start postgresql")
38
+ diagnostics.append(Diagnostic(
39
+ title="PostgreSQL not running",
40
+ severity=Severity.ERROR,
41
+ message="PostgreSQL service is not listening on default port 5432.",
42
+ why="The project requires PostgreSQL, but no service was detected on port 5432.",
43
+ evidence="Port 5432 connection refused.",
44
+ fix=f"Start PostgreSQL service:\n {start_cmd}",
45
+ command=start_cmd,
46
+ confidence=Confidence.HIGH,
47
+ check_id=f"{self.name}_not_running",
48
+ ))
49
+
50
+ # Check DATABASE_URL format if present in env
51
+ db_url = context.env_vars.get("DATABASE_URL")
52
+ if db_url:
53
+ if not re.match(r"^postgres(ql)?:\/\/", db_url):
54
+ diagnostics.append(Diagnostic(
55
+ title="Malformed DATABASE_URL",
56
+ severity=Severity.WARNING,
57
+ message="DATABASE_URL does not start with postgres:// or postgresql://.",
58
+ why=f"DATABASE_URL value is '{db_url[:15]}...', which might not be a valid PostgreSQL connection string.",
59
+ evidence=f"DATABASE_URL = {db_url}",
60
+ fix="Verify and correct the connection URL in .env.",
61
+ confidence=Confidence.MEDIUM,
62
+ check_id=f"{self.name}_malformed_url",
63
+ ))
64
+
65
+ return diagnostics
@@ -0,0 +1,105 @@
1
+ """Check Python runtime version and project compatibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
11
+ from packaging.version import Version
12
+
13
+ from devdoctor.checks.base import Check, register_check
14
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
15
+ from devdoctor.utils.platform_info import get_python_version_str
16
+
17
+
18
+ def get_required_python_spec(project_path: Path) -> Optional[str]:
19
+ """Read Python version requirement from pyproject.toml, .python-version, or runtime.txt."""
20
+ pyproject = project_path / "pyproject.toml"
21
+ if pyproject.is_file():
22
+ try:
23
+ content = pyproject.read_text(encoding="utf-8", errors="replace")
24
+ # Match requires-python = ">=3.11"
25
+ match = re.search(r'requires-python\s*=\s*["\']([^"\']+)["\']', content)
26
+ if match:
27
+ return match.group(1).strip()
28
+ except Exception:
29
+ pass
30
+
31
+ python_version_file = project_path / ".python-version"
32
+ if python_version_file.is_file():
33
+ try:
34
+ version_str = python_version_file.read_text(encoding="utf-8", errors="replace").strip()
35
+ if version_str:
36
+ return f"=={version_str}" if not any(c in version_str for c in "<>=~") else version_str
37
+ except Exception:
38
+ pass
39
+
40
+ runtime_txt = project_path / "runtime.txt"
41
+ if runtime_txt.is_file():
42
+ try:
43
+ content = runtime_txt.read_text(encoding="utf-8", errors="replace").strip()
44
+ # python-3.12.2 -> ==3.12.2
45
+ match = re.search(r"python-([0-9.]+)", content)
46
+ if match:
47
+ return f"=={match.group(1)}"
48
+ except Exception:
49
+ pass
50
+
51
+ return None
52
+
53
+
54
+ @register_check
55
+ class PythonVersionCheck(Check):
56
+ name = "python_version"
57
+ title = "Python Version"
58
+ category = "system"
59
+ description = "Verifies Python version and project requirement compatibility."
60
+
61
+ def run(self, context: ScanContext) -> Diagnostic:
62
+ current_version_str = get_python_version_str()
63
+ current_ver = Version(current_version_str)
64
+ spec_str = get_required_python_spec(context.project_path)
65
+
66
+ if spec_str:
67
+ try:
68
+ spec = SpecifierSet(spec_str)
69
+ if current_ver not in spec:
70
+ return Diagnostic(
71
+ title="Python version incompatible",
72
+ severity=Severity.ERROR,
73
+ message=f"Your project requires Python {spec_str}, but you are running {current_version_str}.",
74
+ why=f"The current Python runtime ({current_version_str}) does not satisfy the specified version constraints.",
75
+ evidence=f"Active: {current_version_str} | Required: {spec_str}",
76
+ fix=f"Install Python {spec_str} and recreate your virtual environment.",
77
+ command=f"py -{spec_str.replace('>=', '').replace('==', '')} -m venv .venv",
78
+ confidence=Confidence.HIGH,
79
+ check_id=self.name,
80
+ metadata={"current_version": current_version_str, "required_spec": spec_str},
81
+ )
82
+ except InvalidSpecifier:
83
+ pass
84
+
85
+ # Check for ancient / unsupported Python versions (< 3.9)
86
+ if sys.version_info < (3, 9):
87
+ return Diagnostic(
88
+ title=f"Python {current_version_str}",
89
+ severity=Severity.WARNING,
90
+ message=f"Python {current_version_str} is End-Of-Life and no longer receives security updates.",
91
+ why="Python versions below 3.9 are deprecated by python.org and most modern libraries.",
92
+ evidence=f"Active runtime version: {current_version_str}",
93
+ fix="Upgrade to Python 3.11 or 3.12 for improved performance and security.",
94
+ command="py -3.12 -m venv .venv",
95
+ confidence=Confidence.HIGH,
96
+ check_id=self.name,
97
+ )
98
+
99
+ return Diagnostic(
100
+ title=f"Python {current_version_str}",
101
+ severity=Severity.OK,
102
+ message=f"Python {current_version_str} is installed and compatible.",
103
+ check_id=self.name,
104
+ metadata={"version": current_version_str},
105
+ )
@@ -0,0 +1,70 @@
1
+ """Redis server service and ping diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ from typing import List
7
+
8
+ from devdoctor.checks.base import Check, register_check
9
+ from devdoctor.models import Confidence, Diagnostic, ScanContext, Severity
10
+ from devdoctor.utils.process_utils import is_port_in_use
11
+
12
+
13
+ @register_check
14
+ class RedisCheck(Check):
15
+ name = "redis"
16
+ title = "Redis Diagnostics"
17
+ category = "framework"
18
+ description = "Checks Redis server availability on port 6379 and RESP PING responsiveness."
19
+
20
+ def run(self, context: ScanContext) -> List[Diagnostic]:
21
+ diagnostics: List[Diagnostic] = []
22
+ is_redis_project = context.project_info and "Redis" in context.project_info.technologies
23
+
24
+ # Check port 6379
25
+ port_open = is_port_in_use(6379)
26
+
27
+ if port_open:
28
+ # Try raw RESP PING
29
+ ping_ok = False
30
+ try:
31
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
32
+ s.settimeout(1.0)
33
+ s.connect(("127.0.0.1", 6379))
34
+ s.sendall(b"*1\r\n$4\r\nPING\r\n")
35
+ response = s.recv(1024)
36
+ if b"+PONG" in response:
37
+ ping_ok = True
38
+ except Exception:
39
+ pass
40
+
41
+ if ping_ok:
42
+ diagnostics.append(Diagnostic(
43
+ title="Redis healthy",
44
+ severity=Severity.OK,
45
+ message="Redis server is running and responded to PING with PONG.",
46
+ check_id=f"{self.name}_ping_ok",
47
+ ))
48
+ else:
49
+ diagnostics.append(Diagnostic(
50
+ title="Redis port open",
51
+ severity=Severity.OK,
52
+ message="Port 6379 is listening (Redis server active).",
53
+ check_id=f"{self.name}_port_ok",
54
+ ))
55
+
56
+ elif is_redis_project:
57
+ start_cmd = "docker run -d -p 6379:6379 --name redis redis:alpine"
58
+ diagnostics.append(Diagnostic(
59
+ title="Redis not running",
60
+ severity=Severity.ERROR,
61
+ message="Redis server is not listening on default port 6379.",
62
+ why="The project uses Redis, but no local Redis instance is reachable on port 6379.",
63
+ evidence="Port 6379 connection refused.",
64
+ fix=f"Start Redis with Docker:\n {start_cmd}",
65
+ command=start_cmd,
66
+ confidence=Confidence.HIGH,
67
+ check_id=f"{self.name}_not_running",
68
+ ))
69
+
70
+ return diagnostics
@@ -0,0 +1,79 @@
1
+ """Check virtual environment activation and isolation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from devdoctor.checks.base import Check, register_check
10
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
11
+ from devdoctor.utils.platform_info import (
12
+ find_local_venv,
13
+ get_venv_activation_command,
14
+ is_in_virtualenv,
15
+ is_windows,
16
+ )
17
+
18
+
19
+ @register_check
20
+ class VirtualenvCheck(Check):
21
+ name = "virtual_environment"
22
+ title = "Virtual Environment"
23
+ category = "environment"
24
+ description = "Verifies that the shell is operating within an active virtual environment."
25
+
26
+ def run(self, context: ScanContext) -> Diagnostic:
27
+ in_venv = is_in_virtualenv()
28
+ context.is_venv = in_venv
29
+
30
+ if in_venv:
31
+ venv_name = os.environ.get("VIRTUAL_ENV") or sys.prefix
32
+ venv_display = Path(venv_name).name if venv_name else "active"
33
+ return Diagnostic(
34
+ title=f"Virtual environment ({venv_display})",
35
+ severity=Severity.OK,
36
+ message="Current shell is using an active virtual environment.",
37
+ check_id=self.name,
38
+ metadata={"venv_path": venv_name},
39
+ )
40
+
41
+ # Not in virtual environment
42
+ local_venv = find_local_venv(context.project_path)
43
+ venv_dir = local_venv.name if local_venv else ".venv"
44
+
45
+ if is_windows():
46
+ fix_text = f"Windows:\n {venv_dir}\\Scripts\\activate"
47
+ activate_cmd = f"{venv_dir}\\Scripts\\activate"
48
+ else:
49
+ fix_text = f"Linux / macOS:\n source {venv_dir}/bin/activate"
50
+ activate_cmd = f"source {venv_dir}/bin/activate"
51
+
52
+ if not local_venv:
53
+ create_cmd = f"{sys.executable} -m venv .venv"
54
+ fix_text = f"Create and activate virtualenv:\n {create_cmd}\n {activate_cmd}"
55
+ fix_action = FixAction(
56
+ description="Create .venv virtual environment",
57
+ command=create_cmd,
58
+ safe=True,
59
+ )
60
+ else:
61
+ fix_action = FixAction(
62
+ description=f"Activate existing {venv_dir} environment",
63
+ command=activate_cmd,
64
+ safe=True,
65
+ )
66
+
67
+ return Diagnostic(
68
+ title="Virtual environment",
69
+ severity=Severity.ERROR,
70
+ message="Current shell is not using a virtual environment.",
71
+ why="Installing packages or running tasks outside a virtual environment pollutes the global Python installation and causes dependency collisions.",
72
+ evidence=f"Active interpreter is global: {sys.executable}",
73
+ fix=fix_text,
74
+ command=activate_cmd,
75
+ confidence=Confidence.HIGH,
76
+ fix_action=fix_action,
77
+ check_id=self.name,
78
+ metadata={"local_venv_found": local_venv is not None},
79
+ )
devdoctor/cli.py ADDED
@@ -0,0 +1,319 @@
1
+ """Command-line interface for DevDoctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from rich.console import Console
11
+
12
+ from devdoctor import __version__
13
+ from devdoctor.fixer import Fixer
14
+ from devdoctor.project import detect_project
15
+ from devdoctor.reporter import Reporter
16
+ from devdoctor.scanner import Scanner
17
+
18
+ # Ensure UTF-8 output on Windows terminals
19
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
20
+ try:
21
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
22
+ except Exception:
23
+ pass
24
+
25
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
26
+ try:
27
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
28
+ except Exception:
29
+ pass
30
+
31
+ console = Console(legacy_windows=False)
32
+ reporter = Reporter(console)
33
+
34
+
35
+ def create_parser() -> argparse.ArgumentParser:
36
+ """Construct the command line argument parser for DevDoctor."""
37
+ parser = argparse.ArgumentParser(
38
+ prog="devdoctor",
39
+ description="Deterministic developer machine and project diagnostic doctor.",
40
+ formatter_class=argparse.RawDescriptionHelpFormatter,
41
+ )
42
+ parser.add_argument(
43
+ "--version", "-V",
44
+ action="version",
45
+ version=f"DevDoctor {__version__}",
46
+ )
47
+
48
+ subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
49
+
50
+ # 1. scan (default command)
51
+ scan_parser = subparsers.add_parser("scan", help="Scan developer machine and project for issues.")
52
+ scan_parser.add_argument("path", nargs="?", default=".", help="Path to project directory (default: current directory)")
53
+ scan_parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
54
+ scan_parser.add_argument("--markdown", action="store_true", help="Output GitHub-flavored Markdown")
55
+ scan_parser.add_argument("-v", "--verbose", action="store_true", help="Show comprehensive diagnostic breakdown (WHY, EVIDENCE, FIX, COMMAND)")
56
+ scan_parser.add_argument("-o", "--output", help="Save diagnostic report to a specified file")
57
+ scan_parser.add_argument("--strict", action="store_true", help="Exit with code 1 if any warnings or errors are found")
58
+ scan_parser.add_argument("--checks-dir", help="Path to custom check plugins directory")
59
+
60
+ # 2. fix
61
+ fix_parser = subparsers.add_parser("fix", help="Interactively or automatically apply safe fixes for identified problems.")
62
+ fix_parser.add_argument("path", nargs="?", default=".", help="Path to project directory")
63
+ fix_parser.add_argument("--safe", action="store_true", help="Apply only safe, non-destructive automatic fixes")
64
+ fix_parser.add_argument("--dry-run", action="store_true", help="Show planned fixes without modifying anything")
65
+ fix_parser.add_argument("-y", "--yes", action="store_true", help="Confirm all fixes without interactive prompt")
66
+
67
+ # 3. dependencies
68
+ dep_parser = subparsers.add_parser("dependencies", help="Check for dependency conflicts and broken imports.")
69
+ dep_parser.add_argument("path", nargs="?", default=".", help="Path to project directory")
70
+ dep_parser.add_argument("-v", "--verbose", action="store_true", help="Show full evidence and fix commands")
71
+ dep_parser.add_argument("--json", action="store_true", help="Output dependency report as JSON")
72
+
73
+ # 4. port
74
+ port_parser = subparsers.add_parser("port", help="Network port tools and process management.")
75
+ port_sub = port_parser.add_subparsers(dest="port_action", required=True)
76
+ port_free_parser = port_sub.add_parser("free", help="Safely terminate process occupying a port.")
77
+ port_free_parser.add_argument("port_number", type=int, help="Port number to free (e.g. 8000)")
78
+ port_free_parser.add_argument("-y", "--yes", action="store_true", help="Terminate without interactive prompt")
79
+
80
+ # 5. project
81
+ proj_parser = subparsers.add_parser("project", help="Inspect detected project type, framework, and tech stack.")
82
+ proj_parser.add_argument("path", nargs="?", default=".", help="Path to project directory")
83
+ proj_parser.add_argument("--json", action="store_true", help="Output project profile as JSON")
84
+
85
+ # 6. report
86
+ rep_parser = subparsers.add_parser("report", help="Generate full diagnostic report in Markdown or JSON format.")
87
+ rep_parser.add_argument("path", nargs="?", default=".", help="Path to project directory")
88
+ rep_parser.add_argument("--format", choices=["terminal", "markdown", "json"], default="markdown", help="Output format")
89
+ rep_parser.add_argument("-o", "--output", help="Output file path (default: stdout)")
90
+
91
+ # 7. Framework commands
92
+ for fw in ["django", "fastapi", "docker", "postgres", "redis"]:
93
+ fw_parser = subparsers.add_parser(fw, help=f"Run diagnostics specifically for {fw.capitalize()}.")
94
+ fw_parser.add_argument("path", nargs="?", default=".", help="Path to project directory")
95
+ fw_parser.add_argument("-v", "--verbose", action="store_true", help="Show full diagnostics")
96
+ fw_parser.add_argument("--json", action="store_true", help="Output as JSON")
97
+
98
+ return parser
99
+
100
+
101
+ def cmd_scan(args: argparse.Namespace) -> int:
102
+ """Execute devdoctor scan."""
103
+ project_path = Path(args.path).resolve()
104
+ checks_dir = Path(args.checks_dir).resolve() if getattr(args, "checks_dir", None) else None
105
+
106
+ scanner = Scanner(project_path, custom_checks_dir=checks_dir)
107
+ report = scanner.scan()
108
+
109
+ if getattr(args, "json", False):
110
+ output = reporter.render_json(report)
111
+ if getattr(args, "output", None):
112
+ Path(args.output).write_text(output, encoding="utf-8")
113
+ console.print(f"[green]Report saved to {args.output}[/green]")
114
+ else:
115
+ print(output)
116
+ elif getattr(args, "markdown", False):
117
+ output = reporter.render_markdown(report)
118
+ if getattr(args, "output", None):
119
+ Path(args.output).write_text(output, encoding="utf-8")
120
+ console.print(f"[green]Report saved to {args.output}[/green]")
121
+ else:
122
+ print(output)
123
+ else:
124
+ reporter.render_terminal(report, verbose=getattr(args, "verbose", False))
125
+ if getattr(args, "output", None):
126
+ Path(args.output).write_text(reporter.render_markdown(report), encoding="utf-8")
127
+ console.print(f"[dim]Markdown copy saved to {args.output}[/dim]")
128
+
129
+ if getattr(args, "strict", False):
130
+ return 1 if report.total_problems > 0 else 0
131
+ return 1 if report.errors else 0
132
+
133
+
134
+ def cmd_fix(args: argparse.Namespace) -> int:
135
+ """Execute devdoctor fix."""
136
+ project_path = Path(args.path).resolve()
137
+ scanner = Scanner(project_path)
138
+ report = scanner.scan()
139
+ context = scanner.create_context()
140
+
141
+ fixer = Fixer(console)
142
+ plan = fixer.build_plan(report)
143
+
144
+ safe_only = getattr(args, "safe", False)
145
+ dry_run = getattr(args, "dry_run", False)
146
+ auto_yes = getattr(args, "yes", False)
147
+
148
+ res = fixer.execute_plan(
149
+ plan=plan,
150
+ context=context,
151
+ safe_only=safe_only,
152
+ dry_run=dry_run,
153
+ auto_yes=auto_yes,
154
+ interactive=not auto_yes and not dry_run,
155
+ )
156
+ return 0 if len(res.failed) == 0 else 1
157
+
158
+
159
+ def cmd_dependencies(args: argparse.Namespace) -> int:
160
+ """Execute devdoctor dependencies."""
161
+ project_path = Path(args.path).resolve()
162
+ scanner = Scanner(project_path)
163
+ report = scanner.scan(check_names={"dependency_conflicts", "broken_imports"})
164
+
165
+ if getattr(args, "json", False):
166
+ print(reporter.render_json(report))
167
+ return 0
168
+
169
+ console.print("[bold cyan]DevDoctor Dependency Diagnostics[/bold cyan]")
170
+ console.print("──────────────────────────────────────────\n")
171
+
172
+ if not report.problems:
173
+ console.print("[bold green]✓ No dependency conflicts or broken imports detected.[/bold green]")
174
+ return 0
175
+
176
+ for d in report.problems:
177
+ sym = "[red]❌[/red]" if d.is_error else "[yellow]⚠[/yellow]"
178
+ console.print(f"{sym} [bold]{d.title}[/bold]\n")
179
+ if d.evidence:
180
+ for line in d.evidence.splitlines():
181
+ console.print(f"{line}")
182
+ console.print()
183
+ elif d.message:
184
+ console.print(f"{d.message}\n")
185
+
186
+ if d.fix:
187
+ console.print(f"[bold]Suggested action:[/bold]\n{d.fix}\n")
188
+
189
+ return 1 if report.errors else 0
190
+
191
+
192
+ def cmd_port(args: argparse.Namespace) -> int:
193
+ """Execute devdoctor port free <port>."""
194
+ if args.port_action == "free":
195
+ port = args.port_number
196
+ auto_yes = getattr(args, "yes", False)
197
+ fixer = Fixer(console)
198
+ success = fixer.free_port_interactive(port, auto_yes=auto_yes)
199
+ return 0 if success else 1
200
+ return 0
201
+
202
+
203
+ def cmd_project(args: argparse.Namespace) -> int:
204
+ """Execute devdoctor project."""
205
+ project_path = Path(args.path).resolve()
206
+ info = detect_project(project_path)
207
+
208
+ if getattr(args, "json", False):
209
+ import json
210
+ print(json.dumps(info.to_dict(), indent=2))
211
+ return 0
212
+
213
+ console.print(f"[bold cyan]Project Profile: {info.name}[/bold cyan]")
214
+ console.print("──────────────────────────────────────────")
215
+ console.print(f"[bold]Detected Type:[/bold] {info.project_type or 'Generic Python'}")
216
+ console.print(f"[bold]Stack Summary:[/bold] {info.summary_stack}")
217
+ console.print(f"[bold]Technologies:[/bold] {', '.join(info.technologies) if info.technologies else 'None'}")
218
+ console.print(f"[bold]Frameworks:[/bold] {', '.join(info.frameworks) if info.frameworks else 'None'}")
219
+ console.print(f"[bold]Config Files:[/bold] {', '.join(info.config_files) if info.config_files else 'None'}")
220
+ console.print(f"[bold]Dependencies:[/bold] {len(info.dependencies)} declared")
221
+ return 0
222
+
223
+
224
+ def cmd_report(args: argparse.Namespace) -> int:
225
+ """Execute devdoctor report."""
226
+ project_path = Path(args.path).resolve()
227
+ scanner = Scanner(project_path)
228
+ report = scanner.scan()
229
+
230
+ fmt = getattr(args, "format", "markdown")
231
+ if fmt == "json":
232
+ output = reporter.render_json(report)
233
+ elif fmt == "markdown":
234
+ output = reporter.render_markdown(report)
235
+ else:
236
+ reporter.render_terminal(report, verbose=True)
237
+ return 0
238
+
239
+ if getattr(args, "output", None):
240
+ Path(args.output).write_text(output, encoding="utf-8")
241
+ console.print(f"[bold green]Report saved to {args.output}[/bold green]")
242
+ else:
243
+ print(output)
244
+ return 0
245
+
246
+
247
+ def cmd_framework(name: str, args: argparse.Namespace) -> int:
248
+ """Execute framework diagnostic command."""
249
+ project_path = Path(args.path).resolve()
250
+ scanner = Scanner(project_path)
251
+ report = scanner.scan(check_names={name})
252
+
253
+ if getattr(args, "json", False):
254
+ print(reporter.render_json(report))
255
+ return 0
256
+
257
+ console.print(f"[bold cyan]DevDoctor: {name.capitalize()} Diagnostics[/bold cyan]")
258
+ console.print("──────────────────────────────────────────\n")
259
+
260
+ if not report.diagnostics:
261
+ console.print(f"[dim]No specific {name} checks triggered.[/dim]")
262
+ return 0
263
+
264
+ if report.passed:
265
+ for p in report.passed:
266
+ console.print(f"[green]✓[/green] {p.title}")
267
+ console.print()
268
+
269
+ if report.problems:
270
+ for d in report.problems:
271
+ sym = "[red]❌[/red]" if d.is_error else "[yellow]⚠[/yellow]"
272
+ console.print(f"{sym} [bold]{d.title}[/bold]")
273
+ if d.message:
274
+ console.print(f" {d.message}\n")
275
+ if d.evidence:
276
+ console.print(f" {d.evidence}\n")
277
+ if d.fix:
278
+ console.print(f" Fix:\n {d.fix}\n")
279
+ else:
280
+ console.print(f"[bold green]✓ All {name} diagnostics passed![/bold green]")
281
+
282
+ return 1 if report.errors else 0
283
+
284
+
285
+ def main(argv: Optional[List[str]] = None) -> int:
286
+ """CLI entry point for DevDoctor."""
287
+ if argv is None:
288
+ argv = sys.argv[1:]
289
+
290
+ # Default to 'scan' if no arguments or flags provided
291
+ if not argv:
292
+ argv = ["scan"]
293
+ elif argv[0].startswith("-") and argv[0] not in ("-h", "--help", "-V", "--version"):
294
+ argv = ["scan"] + argv
295
+
296
+ parser = create_parser()
297
+ args = parser.parse_args(argv)
298
+
299
+ if args.command == "scan":
300
+ return cmd_scan(args)
301
+ elif args.command == "fix":
302
+ return cmd_fix(args)
303
+ elif args.command == "dependencies":
304
+ return cmd_dependencies(args)
305
+ elif args.command == "port":
306
+ return cmd_port(args)
307
+ elif args.command == "project":
308
+ return cmd_project(args)
309
+ elif args.command == "report":
310
+ return cmd_report(args)
311
+ elif args.command in ("django", "fastapi", "docker", "postgres", "redis"):
312
+ return cmd_framework(args.command, args)
313
+ else:
314
+ parser.print_help()
315
+ return 0
316
+
317
+
318
+ if __name__ == "__main__":
319
+ sys.exit(main())