dockerbrain 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.
core/cli.py ADDED
@@ -0,0 +1,369 @@
1
+ import platform
2
+ import click
3
+ from core import __version__
4
+
5
+ def print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
6
+ """Print version with build metadata and exit."""
7
+ if not value or ctx.resilient_parsing:
8
+ return
9
+ click.echo(
10
+ f"dockerbrain v{__version__} "
11
+ f"(Python {platform.python_version()}, {platform.system()} {platform.machine()})"
12
+ )
13
+ ctx.exit()
14
+
15
+
16
+ class _HiddenHelpCommand(click.Command):
17
+ """A Click Command that hides the built-in --help from the options list."""
18
+
19
+ def get_params(self, ctx: click.Context) -> list:
20
+ params = super().get_params(ctx)
21
+ for p in params:
22
+ if isinstance(p, click.Option) and p.name == "help":
23
+ p.hidden = True
24
+ return params
25
+
26
+
27
+ class _HiddenHelpGroup(click.Group):
28
+ """A Click Group whose subcommands all use HiddenHelpCommand."""
29
+ command_class = _HiddenHelpCommand
30
+
31
+
32
+ @click.group(cls=_HiddenHelpGroup, add_help_option=False)
33
+ @click.option(
34
+ "--help",
35
+ is_flag=True,
36
+ is_eager=True,
37
+ expose_value=False,
38
+ hidden=True,
39
+ callback=lambda ctx, param, value: (click.echo(ctx.get_help(), color=ctx.color) or ctx.exit()) if value else None,
40
+ )
41
+ @click.option(
42
+ "--version",
43
+ is_flag=True,
44
+ callback=print_version,
45
+ expose_value=False,
46
+ is_eager=True,
47
+ help="Check DockerBrain version.",
48
+ )
49
+ def cli() -> None:
50
+ pass
51
+
52
+ # Commands
53
+ @cli.command()
54
+ @click.option("--interval", "-i", default=1, show_default=True, help="Polling interval in seconds.")
55
+ @click.option("--duration", "-d", default=None, type=int, help="Total seconds to monitor (default: unlimited).")
56
+ def monitor(interval: int, duration: int | None) -> None:
57
+ """Display containers' stats."""
58
+ from core.monitor import run_monitor
59
+ run_monitor(interval=interval, duration=duration)
60
+
61
+ @cli.command("suggest")
62
+ @click.option("--container", "-c", default=None, help="Target a specific container (default: all).")
63
+ @click.option("--window", "-w", default=30, show_default=True, help="Minutes of metric history to include.")
64
+ @click.option("--dockerfile", "-f", default=None, type=click.Path(exists=True), help="Analyze a Dockerfile instead of containers.")
65
+ @click.option("--no-rules", is_flag=True, default=False, help="Skip rule-based suggestions, send raw metrics only.")
66
+ def ai_suggest(container: str | None, window: int, dockerfile: str | None, no_rules: bool) -> None:
67
+ """Get optimization suggestions for Dockerfiles and containers.
68
+
69
+ Two modes:
70
+
71
+ \b
72
+ 1. Container mode (default):
73
+ Collects metrics from the last --window minutes and asks LLM
74
+ for optimizations.
75
+ 2. Dockerfile mode (--dockerfile PATH):
76
+ Reads the Dockerfile and asks LLM to suggest overall improvements.
77
+
78
+ Requires an API key set in .dockerbrainrc.
79
+ """
80
+ from core.ai_advisor import run_ai_suggest
81
+
82
+ run_ai_suggest(
83
+ container_name=container,
84
+ window_minutes=window,
85
+ dockerfile_path=dockerfile,
86
+ no_rules=no_rules,
87
+ )
88
+
89
+ @cli.command()
90
+ @click.option("--dockerfile", "-f", default=None, type=click.Path(exists=True),
91
+ help="Path to a Dockerfile to auto-fix.")
92
+ @click.option("--container", "-c", default=None,
93
+ help="Target a specific container (default: all).")
94
+ def fix(dockerfile: str | None, container: str | None) -> None:
95
+ """Automatically diagnose and fix Docker issues.
96
+
97
+ \b
98
+ Two modes:
99
+ 1. Dockerfile mode (--dockerfile PATH):
100
+ Detects anti-patterns, issues of Dockerfile,
101
+ and overwrites with a .bak backup on confirmation.
102
+ 2. Container mode (default):
103
+ Runs analysis, asks LLM for improvements,
104
+ and executes with your confirmation.
105
+
106
+ Requires an API key set in .dockerbrainrc.
107
+ """
108
+ from core.fixer import run_fix
109
+
110
+ run_fix(dockerfile_path=dockerfile, container_name=container)
111
+
112
+ @cli.command()
113
+ @click.option("--force", is_flag=True, default=False,
114
+ help="Overwrite existing Dockerfile and .dockerignore forcefully.")
115
+ def dockerize(force: bool) -> None:
116
+ """Generate a Dockerfile and .dockerignore for your project.
117
+
118
+ \b
119
+ Examples:
120
+ dockerb dockerize # Generate a Dockerfile and .dockerignore for your project
121
+ dockerb dockerize --force # Overwrite existing Dockerfile forcefully
122
+
123
+ Requires an API key set in .dockerbrainrc.
124
+ """
125
+ from core.dockerizer import run_dockerize
126
+
127
+ run_dockerize(project_path=".", force=force)
128
+
129
+ class _NoHelpOptGroup(click.Group):
130
+ """Group that hides the --help option from the help output."""
131
+ def get_help_option(self, ctx: click.Context) -> click.Option | None:
132
+ opt = super().get_help_option(ctx)
133
+ if opt:
134
+ opt.hidden = True
135
+ return opt
136
+
137
+ @cli.group(cls=_NoHelpOptGroup)
138
+ def template() -> None:
139
+ """Browse and use curated Dockerfile templates."""
140
+
141
+ @template.command("list")
142
+ def template_list() -> None:
143
+ """Show all available Dockerfile templates."""
144
+ from rich.console import Console
145
+ from rich.table import Table
146
+
147
+ from core.templates import TEMPLATES
148
+
149
+ console = Console()
150
+ table = Table(
151
+ show_header=True,
152
+ header_style="bold cyan",
153
+ border_style="dim",
154
+ expand=False,
155
+ )
156
+ table.add_column("#", style="dim", width=3)
157
+ table.add_column("Template", style="bold green", no_wrap=True)
158
+ table.add_column("Stack")
159
+ table.add_column("Description")
160
+
161
+ for i, (key, tpl) in enumerate(sorted(TEMPLATES.items()), 1):
162
+ table.add_row(str(i), key, tpl["name"], tpl["description"])
163
+
164
+ console.print()
165
+ console.print(table)
166
+ console.print(
167
+ "\n[dim]Use a template:[/] [cyan]dockerb template use <name>[/]\n"
168
+ )
169
+
170
+ @template.command("use")
171
+ @click.argument("name")
172
+ @click.option("--force", "-f", is_flag=True, help="Overwrite existing Dockerfile.")
173
+ def template_use(name: str, force: bool) -> None:
174
+ """Generate a Dockerfile from a template.
175
+
176
+ \b
177
+ Examples:
178
+ dockerb template use fastapi
179
+ dockerb template use nextjs --force
180
+ dockerb template use go
181
+ """
182
+ from pathlib import Path
183
+
184
+ from rich.console import Console
185
+ from rich.syntax import Syntax
186
+
187
+ from core.templates import get_template, get_template_names
188
+
189
+ console = Console()
190
+ tpl = get_template(name)
191
+
192
+ if tpl is None:
193
+ console.print(f"[red]Unknown template:[/] [bold]{name}[/]")
194
+ console.print(
195
+ f"[dim]Available: {', '.join(get_template_names())}[/]"
196
+ )
197
+ return
198
+
199
+ dockerfile_path = Path("Dockerfile")
200
+
201
+ if dockerfile_path.exists() and not force:
202
+ from rich.prompt import Confirm
203
+ if not Confirm.ask(
204
+ "[yellow]Dockerfile already exists.[/] Overwrite?",
205
+ default=False,
206
+ ):
207
+ console.print("[dim]Skipped. No changes made.[/]")
208
+ return
209
+
210
+ dockerfile_path.write_text(tpl["dockerfile"], encoding="utf-8")
211
+ console.print(
212
+ f"[green]Created Dockerfile[/] [dim]({tpl['name']})[/]"
213
+ )
214
+
215
+ dockerignore_path = Path(".dockerignore")
216
+ if not dockerignore_path.exists():
217
+ dockerignore_content = """\
218
+ .git
219
+ .gitignore
220
+ node_modules
221
+ __pycache__
222
+ *.pyc
223
+ .env
224
+ .venv
225
+ .dockerignore
226
+ Dockerfile
227
+ README.md
228
+ .idea
229
+ .vscode
230
+ """
231
+ dockerignore_path.write_text(dockerignore_content, encoding="utf-8")
232
+ console.print("[green]Created .dockerignore[/]")
233
+
234
+ console.print()
235
+ console.print(Syntax(tpl["dockerfile"], "dockerfile", theme="monokai", line_numbers=True))
236
+
237
+
238
+ @cli.command()
239
+ def init() -> None:
240
+ """Create a .dockerbrainrc config file in the current directory.
241
+
242
+ Creates a TOML config with defaults for monitoring thresholds,
243
+ LLM model selection, and other settings. Edit it as per your requirements.
244
+ """
245
+ from pathlib import Path
246
+
247
+ from rich.console import Console
248
+
249
+ console = Console()
250
+ config_path = Path(".dockerbrainrc")
251
+
252
+ if config_path.exists():
253
+ console.print(f"[yellow] {config_path} already exists.[/]")
254
+ return
255
+
256
+ config_content = '''\
257
+ # DockerBrain Configuration
258
+
259
+ # LLM Provider & Models, To switch provider or model, edit below.
260
+
261
+ # GEMINI (Overall Best)
262
+ # provider = "gemini"
263
+ # model = "gemini-3.1-flash-lite-preview", "gemini-flash-latest"
264
+
265
+ # GROQ (Fast)
266
+ # provider = "groq"
267
+ # model = "openai/gpt-oss-120b", "llama-3.3-70b-versatile"
268
+
269
+ # OLLAMA (Local and Free)
270
+ # provider = "ollama"
271
+ # model = "llama3.1", "codestral", "qwen2.5-coder" (Or any other model)
272
+ # base_url = "http://localhost:11434/v1" # change if not default
273
+
274
+ [llm]
275
+ provider = "gemini"
276
+ model = "gemini-3.1-flash-lite-preview"
277
+ api_key = "" # Paste your LLM API key here
278
+ # base_url = "" # Only for Ollama
279
+
280
+ [monitor]
281
+ interval = 5 # Polling interval in seconds
282
+ alert_memory_pct = 80 # Memory % threshold for warnings
283
+ alert_cpu_idle = 0.5 # CPU % below which a container is "idle"
284
+ idle_consecutive_polls = 10 # Consecutive idle polls before flagging
285
+
286
+
287
+ [optimize]
288
+ check_secrets = true # Scan for hardcoded secrets in ENV
289
+ check_base_image = true # Flag large base images
290
+ check_apt_recommends = true # Flag apt-get without --no-install-recommends
291
+ check_cache_busting = true # Flag COPY . . before dependency install
292
+ check_dockerignore = true # Warn if .dockerignore is missing
293
+ '''
294
+
295
+ config_path.write_text(config_content, encoding="utf-8")
296
+ console.print(f"[green]Created [bold]{config_path}[/bold][/]")
297
+
298
+ @cli.command()
299
+ def env() -> None:
300
+ """Check your environment and configs."""
301
+ import sqlite3
302
+ from pathlib import Path
303
+
304
+ import docker
305
+ from rich.console import Console
306
+ from rich.panel import Panel
307
+ from rich.text import Text
308
+
309
+ console = Console()
310
+ lines = Text()
311
+ all_ok = True
312
+
313
+ def _ok(label: str, detail: str) -> None:
314
+ lines.append(f" {label:<20}", style="bold")
315
+ lines.append(f"{detail}\n")
316
+
317
+ def _fail(label: str, detail: str) -> None:
318
+ nonlocal all_ok
319
+ all_ok = False
320
+ lines.append(f" {label:<20}", style="bold red")
321
+ lines.append(f"{detail}\n")
322
+
323
+ try:
324
+ client = docker.from_env()
325
+ info = client.version()
326
+ _ok("Docker daemon", f"running (v{info.get('Version', '?')})")
327
+ except Exception :
328
+ _fail("Docker daemon", f"not reachable")
329
+
330
+ try:
331
+ _ok("Docker SDK", f"docker-py {docker.__version__}")
332
+ except ImportError:
333
+ _fail("Docker SDK", "not installed → run: pip install docker")
334
+
335
+ try:
336
+ from core.llm import load_llm_config
337
+ cfg = load_llm_config()
338
+ _ok("LLM Provider", cfg.provider)
339
+ _ok("LLM Model", cfg.model)
340
+ masked = cfg.api_key[:4] + "…" + cfg.api_key[-4:] if len(cfg.api_key) > 8 else "set ✓"
341
+ _ok("API Key", f"{masked}")
342
+ except SystemExit:
343
+ _fail("API Key", "not set in .dockerbrainrc")
344
+
345
+ db_path = Path.home() / ".dockerbrain" / "metrics.db"
346
+ if db_path.exists():
347
+ size_kb = db_path.stat().st_size / 1024
348
+ try:
349
+ conn = sqlite3.connect(str(db_path))
350
+ row_count = conn.execute("SELECT COUNT(*) FROM container_metrics").fetchone()[0]
351
+ conn.close()
352
+ _ok("SQLite DB", f"{db_path} ({size_kb:.0f} KB, {row_count:,} rows)")
353
+ except Exception:
354
+ _ok("SQLite DB", f"{db_path} ({size_kb:.0f} KB)")
355
+ else:
356
+ _fail("SQLite DB", f"not found at {db_path} — run: dockerb monitor")
357
+
358
+ status = "[bold green]All checks passed!" if all_ok else "[bold yellow]Some checks failed"
359
+ console.print(
360
+ Panel(
361
+ lines,
362
+ title="[bold cyan] Environment Check[/]",
363
+ subtitle=status,
364
+ border_style="cyan",
365
+ expand=False,
366
+ padding=(1, 2),
367
+ )
368
+ )
369
+ raise SystemExit(0 if all_ok else 1)
core/dockerizer.py ADDED
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from rich.columns import Columns
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Confirm
10
+ from rich.syntax import Syntax
11
+ from rich.table import Table
12
+
13
+ from core.llm import load_llm_config, generate, LLMConfig
14
+
15
+ console = Console()
16
+
17
+ _LANGUAGE_SIGNATURES: dict[str, list[str]] = {
18
+ "Python": ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "setup.cfg", "poetry.lock"],
19
+ "Node.js": ["package.json", "yarn.lock", "pnpm-lock.yaml", "package-lock.json"],
20
+ "TypeScript": ["tsconfig.json"],
21
+ "Deno": ["deno.json", "deno.jsonc"],
22
+ "Go": ["go.mod", "go.sum"],
23
+ "Rust": ["Cargo.toml", "Cargo.lock"],
24
+ "Java/Kotlin": ["pom.xml", "build.gradle", "build.gradle.kts", "gradlew"],
25
+ "Scala": ["build.sbt"],
26
+ "Ruby": ["Gemfile", "Gemfile.lock"],
27
+ "PHP": ["composer.json", "composer.lock"],
28
+ "C#/.NET": ["*.csproj", "*.sln", "Program.cs"],
29
+ "F#": ["*.fsproj"],
30
+ "C/C++": ["CMakeLists.txt", "*.c", "*.cpp", "configure.ac", "meson.build"],
31
+ "Swift": ["Package.swift"],
32
+ "Dart/Flutter": ["pubspec.yaml"],
33
+ "Elixir": ["mix.exs"],
34
+ "Erlang": ["rebar.config", "rebar.lock"],
35
+ "Haskell": ["stack.yaml", "*.cabal"],
36
+ "Clojure": ["project.clj", "deps.edn"],
37
+ "Perl": ["cpanfile", "Makefile.PL"],
38
+ "R": ["DESCRIPTION", "*.Rproj"],
39
+ "Lua": ["*.rockspec"],
40
+ "Crystal": ["shard.yml"],
41
+ "Static/HTML": ["index.html"],
42
+ }
43
+
44
+ _SKIP_DIRS = {
45
+ ".git", ".venv", "venv", "node_modules", "__pycache__", ".tox",
46
+ ".pytest_cache", ".mypy_cache", ".egg-info", "dist", "build",
47
+ ".idea", ".vscode", "target", "bin", "obj", ".next", ".nuxt",
48
+ ".dart_tool", ".pub-cache", "_build", "deps", "vendor",
49
+ ".stack-work", "elm-stuff", "zig-cache",
50
+ }
51
+
52
+ _SKIP_EXTENSIONS = {
53
+ ".pyc", ".pyo", ".exe", ".dll", ".so", ".dylib", ".o",
54
+ ".class", ".jar", ".war", ".db", ".sqlite", ".sqlite3",
55
+ ".lock", ".log", ".bak", ".swp", ".swo",
56
+ }
57
+
58
+ _DOCKERIZE_SYSTEM = (
59
+ "You are a world-class Docker expert. Given a project's directory structure and key config "
60
+ "file contents, generate a production-ready Dockerfile.\n\n"
61
+ "CRITICAL RULES:\n"
62
+ "1. Return ONLY a valid JSON object with exactly two keys:\n"
63
+ ' - "dockerfile": the full Dockerfile content as a string\n'
64
+ ' - "dockerignore": the full .dockerignore content as a string\n'
65
+ "2. No markdown, no code fences, no explanation — ONLY the raw JSON object.\n"
66
+ "3. Use multi-stage builds where appropriate (especially for compiled languages).\n"
67
+ "4. Always use slim or alpine base images.\n"
68
+ "5. Install dependencies BEFORE copying source code (layer caching).\n"
69
+ "6. Chain RUN commands with && to minimize layers.\n"
70
+ "7. Clean up package manager caches (apt, apk, pip, npm).\n"
71
+ "8. Add a non-root USER for security.\n"
72
+ "9. Add a HEALTHCHECK if a web server or API is detected.\n"
73
+ "10. Use exec-form CMD (JSON array).\n"
74
+ "11. Pin base image versions (e.g., python:3.12-slim, not python:latest).\n"
75
+ "12. Add brief comments in the Dockerfile explaining each stage/step.\n"
76
+ "13. The .dockerignore should exclude: .git, .venv, venv, node_modules, __pycache__, "
77
+ "*.pyc, .env, .idea, .vscode, *.md, dist, build, tests, .pytest_cache, .mypy_cache, "
78
+ "and any other development-only files detected in the project.\n"
79
+ )
80
+
81
+ def _scan_project(project_dir: Path) -> dict:
82
+ """Scan the project directory and return structured info for the AI prompt."""
83
+
84
+ tree_lines: list[str] = []
85
+
86
+ def _walk(current: Path, prefix: str, depth: int) -> None:
87
+ if depth > 3:
88
+ return
89
+ try:
90
+ entries = sorted(current.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
91
+ except PermissionError:
92
+ return
93
+
94
+ dirs = [e for e in entries if e.is_dir() and e.name not in _SKIP_DIRS and not e.name.startswith(".")]
95
+ files = [e for e in entries if e.is_file() and e.suffix not in _SKIP_EXTENSIONS]
96
+
97
+ for f in files:
98
+ tree_lines.append(f"{prefix}{f.name}")
99
+ for d in dirs:
100
+ tree_lines.append(f"{prefix}{d.name}/")
101
+ _walk(d, prefix + " ", depth + 1)
102
+
103
+ _walk(project_dir, "", 0)
104
+
105
+ # Detect language
106
+ detected_languages: list[str] = []
107
+ for language, signatures in _LANGUAGE_SIGNATURES.items():
108
+ for sig in signatures:
109
+ if "*" in sig:
110
+ # Glob pattern
111
+ if list(project_dir.glob(sig)):
112
+ detected_languages.append(language)
113
+ break
114
+ elif (project_dir / sig).exists():
115
+ detected_languages.append(language)
116
+ break
117
+
118
+ # Read key config files
119
+ key_files_content: dict[str, str] = {}
120
+ priority_files = [
121
+ "requirements.txt", "pyproject.toml", "setup.py", "Pipfile",
122
+ "package.json", "tsconfig.json", "deno.json",
123
+ "go.mod", "Cargo.toml", "pom.xml",
124
+ "build.gradle", "build.sbt",
125
+ "Gemfile", "composer.json", "mix.exs",
126
+ "CMakeLists.txt", "Package.swift", "pubspec.yaml",
127
+ "stack.yaml", "project.clj", "deps.edn",
128
+ "rebar.config", "shard.yml", "cpanfile",
129
+ "Makefile", "Procfile", "app.json", "runtime.txt",
130
+ ]
131
+
132
+ collected = 0
133
+ for fname in priority_files:
134
+ fpath = project_dir / fname
135
+ if fpath.exists() and collected < 5:
136
+ try:
137
+ lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines()[:200]
138
+ key_files_content[fname] = "\n".join(lines)
139
+ collected += 1
140
+ except Exception:
141
+ pass
142
+
143
+ has_dockerfile = (project_dir / "Dockerfile").exists()
144
+ has_dockerignore = (project_dir / ".dockerignore").exists()
145
+
146
+ return {
147
+ "tree": "\n".join(tree_lines[:150]),
148
+ "detected_languages": detected_languages,
149
+ "key_files": key_files_content,
150
+ "has_dockerfile": has_dockerfile,
151
+ "has_dockerignore": has_dockerignore,
152
+ }
153
+
154
+
155
+ def _display_scan_results(scan: dict) -> None:
156
+ """Show the user what was detected before generating."""
157
+
158
+ table = Table(
159
+ show_header=True,
160
+ header_style="bold cyan",
161
+ border_style="bright_blue",
162
+ expand=False,
163
+ )
164
+ table.add_column("Property", style="bold", no_wrap=True)
165
+ table.add_column("Value", style="white")
166
+
167
+ languages = ", ".join(scan["detected_languages"]) if scan["detected_languages"] else "Unknown"
168
+ table.add_row("Detected Language", f"[green]{languages}[/]")
169
+ table.add_row("Configs", ", ".join(scan["key_files"].keys()) or "None")
170
+ table.add_row("Dockerfile", "[yellow]Yes[/]" if scan["has_dockerfile"] else "[dim]No[/]")
171
+ table.add_row(".dockerignore", "[yellow]Yes[/]" if scan["has_dockerignore"] else "[dim]No[/]")
172
+
173
+ console.print()
174
+ console.print(table)
175
+ console.print()
176
+
177
+
178
+ def _generate_via_ai(scan: dict, config: LLMConfig) -> dict:
179
+ """Send project info to the configured LLM and get back Dockerfile + .dockerignore."""
180
+
181
+ # Build the prompt
182
+ parts = ["## Project Analysis\n"]
183
+
184
+ if scan["detected_languages"]:
185
+ parts.append(f"**Detected language(s):** {', '.join(scan['detected_languages'])}\n")
186
+
187
+ parts.append(f"### Directory Structure\n```\n{scan['tree']}\n```\n")
188
+
189
+ for fname, content in scan["key_files"].items():
190
+ parts.append(f"### {fname}\n```\n{content}\n```\n")
191
+
192
+ parts.append("\nGenerate an optimized Dockerfile and .dockerignore for this project.")
193
+
194
+ prompt = "\n".join(parts)
195
+
196
+ with console.status("[bold green]Generating Dockerfile...[/]", spinner="dots"):
197
+ raw = generate(
198
+ prompt=prompt,
199
+ system_instruction=_DOCKERIZE_SYSTEM,
200
+ config=config,
201
+ )
202
+
203
+ try:
204
+ return json.loads(raw)
205
+ except json.JSONDecodeError:
206
+ console.print(
207
+ Panel(
208
+ f"[red]Could not parse LLM response as JSON.[/]\n\n[dim]{raw[:500]}[/]",
209
+ title="[bold red]Parse Error[/]",
210
+ border_style="red",
211
+ expand=False,
212
+ )
213
+ )
214
+ return {}
215
+
216
+ def _preview_files(dockerfile: str, dockerignore: str) -> None:
217
+ """Show side-by-side preview of the generated files."""
218
+
219
+ left = Panel(
220
+ Syntax(dockerfile, "dockerfile", theme="monokai", line_numbers=True, word_wrap=True),
221
+ title="[bold cyan]Dockerfile[/]",
222
+ border_style="cyan",
223
+ expand=True,
224
+ )
225
+ right = Panel(
226
+ Syntax(dockerignore, "ini", theme="monokai", line_numbers=True, word_wrap=True),
227
+ title="[bold cyan].dockerignore[/]",
228
+ border_style="cyan",
229
+ expand=True,
230
+ )
231
+ console.print()
232
+ console.print(Columns([left, right], equal=True, expand=True))
233
+
234
+ def run_dockerize(project_path: str, force: bool = False) -> None:
235
+ """Scan a project directory and generate a Dockerfile + .dockerignore via AI."""
236
+
237
+ project_dir = Path(project_path).resolve()
238
+
239
+ if not project_dir.is_dir():
240
+ console.print(f"[red]Not a directory: {project_dir}[/]")
241
+ raise SystemExit(1)
242
+
243
+ console.print(
244
+ Panel(
245
+ f"[bold]Scanning:[/] [cyan]{project_dir}[/]",
246
+ border_style="cyan",
247
+ expand=False,
248
+ )
249
+ )
250
+
251
+ scan = _scan_project(project_dir)
252
+
253
+ if not scan["detected_languages"]:
254
+ console.print(
255
+ "[yellow]Could not auto-detect the project language. "
256
+ "Attempt to generate a Dockerfile based on the directory structure.[/]\n"
257
+ )
258
+
259
+ _display_scan_results(scan)
260
+
261
+ # Check for existing files
262
+ dockerfile_path = project_dir / "Dockerfile"
263
+ dockerignore_path = project_dir / ".dockerignore"
264
+
265
+ if not force:
266
+ if dockerfile_path.exists():
267
+ if not Confirm.ask(
268
+ f"[yellow]Dockerfile already exists at [cyan]{dockerfile_path}[/cyan]. Overwrite?[/]",
269
+ default=False,
270
+ ):
271
+ console.print("[dim]Aborted.[/]")
272
+ return
273
+
274
+ config = load_llm_config()
275
+ result = _generate_via_ai(scan, config)
276
+
277
+ if not result:
278
+ return
279
+
280
+ dockerfile_content = result.get("dockerfile", "")
281
+ dockerignore_content = result.get("dockerignore", "")
282
+
283
+ if not dockerfile_content:
284
+ console.print("[red]Returned an empty Dockerfile.[/]")
285
+ return
286
+
287
+ # Preview
288
+ _preview_files(dockerfile_content, dockerignore_content)
289
+
290
+ console.print()
291
+ if not Confirm.ask("[bold]Write these files?[/]", default=True):
292
+ console.print("[dim]Aborted. No files written.[/]")
293
+ return
294
+
295
+ dockerfile_path.write_text(dockerfile_content + "\n", encoding="utf-8")
296
+ console.print(f"[green bold]Created:[/] [cyan]{dockerfile_path}[/]")
297
+
298
+ if dockerignore_content:
299
+ if dockerignore_path.exists() and not force:
300
+ if Confirm.ask(
301
+ f"[yellow].dockerignore already exists. Overwrite?[/]",
302
+ default=False,
303
+ ):
304
+ dockerignore_path.write_text(dockerignore_content + "\n", encoding="utf-8")
305
+ console.print(f"[green bold]Created:[/] [cyan]{dockerignore_path}[/]")
306
+ else:
307
+ console.print("[dim]Skipped .dockerignore.[/]")
308
+ else:
309
+ dockerignore_path.write_text(dockerignore_content + "\n", encoding="utf-8")
310
+ console.print(f"[green bold]Created:[/] [cyan]{dockerignore_path}[/]")
core/fixer/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ from core.fixer.dockerfile import fix_dockerfile
2
+ from core.fixer.container import fix_containers, _is_safe_command
3
+
4
+
5
+ def run_fix(
6
+ dockerfile_path: str | None = None,
7
+ container_name: str | None = None,
8
+ ) -> None:
9
+ """Route to dockerfile or container fix mode."""
10
+ if dockerfile_path:
11
+ fix_dockerfile(dockerfile_path)
12
+ else:
13
+ fix_containers(container_name)
14
+
15
+
16
+ __all__ = [
17
+ "run_fix",
18
+ "fix_dockerfile",
19
+ "fix_containers",
20
+ "_is_safe_command",
21
+ ]