aisc-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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,105 @@
1
+ """Profile service — read-only ``profile list`` and ``profile show``.
2
+
3
+ Reads profiles from ~/.aisc/profiles.json with fallback to built-in definitions.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Built-in profile definitions (fallback when profiles.json not found)
16
+ # ---------------------------------------------------------------------------
17
+
18
+ _BUILTIN_PROFILES: Dict[str, Dict[str, Any]] = {
19
+ "safe": {
20
+ "name": "safe",
21
+ "description": "Secure defaults (default profile)",
22
+ "dangerously_skip_permissions": False,
23
+ },
24
+ "unsafe": {
25
+ "name": "unsafe",
26
+ "description": "Explicit dangerous permissions for trusted projects",
27
+ "dangerously_skip_permissions": True,
28
+ },
29
+ }
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Profile loading from .aisc/profiles.json
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def _profiles_path(home: Optional[str] = None) -> Path:
37
+ """Return the path to profiles.json."""
38
+ home_path = Path(home).expanduser() if home is not None else Path.home()
39
+ return home_path / ".aisc" / "profiles.json"
40
+
41
+
42
+ def _load_profiles(home: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
43
+ """Load profiles from ~/.aisc/profiles.json, fallback to built-in."""
44
+ path = _profiles_path(home)
45
+ try:
46
+ if not path.is_file():
47
+ return _BUILTIN_PROFILES
48
+ raw = path.read_bytes()
49
+ data = json.loads(raw.decode("utf-8"))
50
+ if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict):
51
+ return _BUILTIN_PROFILES
52
+ return data["profiles"]
53
+ except (OSError, ValueError, json.JSONDecodeError):
54
+ return _BUILTIN_PROFILES
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Result types
59
+ # ---------------------------------------------------------------------------
60
+
61
+ @dataclass
62
+ class ProfileListResult:
63
+ data: Dict[str, Any] = field(default_factory=dict)
64
+ exit_code: int = 0
65
+ error_code: str = ""
66
+ error_message: str = ""
67
+
68
+
69
+ @dataclass
70
+ class ProfileShowResult:
71
+ data: Dict[str, Any] = field(default_factory=dict)
72
+ exit_code: int = 0
73
+ error_code: str = ""
74
+ error_message: str = ""
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Service functions
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def run_profile_list(home: Optional[str] = None) -> ProfileListResult:
82
+ """Return all profiles as a list."""
83
+ profiles_dict = _load_profiles(home)
84
+ profiles: List[Dict[str, Any]] = []
85
+ for key in sorted(profiles_dict.keys()):
86
+ profiles.append(dict(profiles_dict[key]))
87
+
88
+ data: Dict[str, Any] = {"profiles": profiles}
89
+ return ProfileListResult(data=data, exit_code=0)
90
+
91
+
92
+ def run_profile_show(name: str, home: Optional[str] = None) -> ProfileShowResult:
93
+ """Return a single profile by name."""
94
+ profiles_dict = _load_profiles(home)
95
+ profile = profiles_dict.get(name)
96
+ if profile is None:
97
+ return ProfileShowResult(
98
+ data={},
99
+ exit_code=1,
100
+ error_code="AISC_ERR_GENERAL",
101
+ error_message=f"Profile not found: {name}",
102
+ )
103
+
104
+ data = dict(profile)
105
+ return ProfileShowResult(data=data, exit_code=0)
@@ -0,0 +1,92 @@
1
+ """Provider status application logic for Workbench Phase 0 S0.4.
2
+
3
+ Implements the business logic behind ``aisc provider current``. Per
4
+ docs/gui-planning/05-cli-gui-contract.md §七. Secret-free: the in-container
5
+ ``aisc-provider-inspect`` emits only metadata; this layer adds runtime_id and
6
+ observed_at.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ import json
13
+ from typing import Any
14
+
15
+ from aisc.application.runtime import resolve_running_container, validate_uuid_v4
16
+ from aisc.domain.models import (
17
+ CliError,
18
+ ProviderStatus,
19
+ RuntimeErrorCode,
20
+ RuntimeExitCode,
21
+ )
22
+
23
+ _PROVIDER_INSPECT_PATH = "/usr/local/bin/aisc-provider-inspect"
24
+ _AGENTS = ("claude", "codex")
25
+
26
+
27
+ def _iso_now() -> str:
28
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
29
+
30
+
31
+ def current_provider(
32
+ runtime_id: str,
33
+ agent: str,
34
+ executor: Any,
35
+ registry_root: Any,
36
+ ) -> ProviderStatus:
37
+ """Return the observable provider status for *agent* in runtime *runtime_id*.
38
+
39
+ Requires the runtime to be running; reads config in-container via
40
+ ``docker exec <ct> aisc-provider-inspect <agent>``. Never returns secrets.
41
+ """
42
+ if not validate_uuid_v4(runtime_id):
43
+ raise CliError(
44
+ message=f"Invalid runtime ID (must be UUID v4): {runtime_id}",
45
+ exit_code=RuntimeExitCode.INVALID_RUNTIME_ID,
46
+ error_code=RuntimeErrorCode.INVALID_RUNTIME_ID,
47
+ )
48
+ if agent not in _AGENTS:
49
+ raise CliError(
50
+ message=f"Invalid agent (must be one of {', '.join(_AGENTS)}): {agent}",
51
+ exit_code=RuntimeExitCode.USAGE_ERROR,
52
+ error_code=RuntimeErrorCode.INVALID_AGENT,
53
+ )
54
+
55
+ container_name = resolve_running_container(runtime_id, executor, registry_root)
56
+
57
+ result = executor.run_captured(
58
+ ["exec", container_name, _PROVIDER_INSPECT_PATH, agent],
59
+ timeout=15.0,
60
+ )
61
+ if result.exit_code != 0:
62
+ raise CliError(
63
+ message=f"Provider status inspection failed: "
64
+ f"{(result.stderr or '').strip()[:200]}",
65
+ exit_code=RuntimeExitCode.PROVIDER_STATUS_FAILED,
66
+ error_code=RuntimeErrorCode.PROVIDER_STATUS_FAILED,
67
+ )
68
+
69
+ try:
70
+ data = json.loads(result.stdout.strip())
71
+ except json.JSONDecodeError as exc:
72
+ raise CliError(
73
+ message=f"Invalid provider status output: {exc}",
74
+ exit_code=RuntimeExitCode.PROVIDER_STATUS_FAILED,
75
+ error_code=RuntimeErrorCode.PROVIDER_STATUS_FAILED,
76
+ )
77
+ if not isinstance(data, dict):
78
+ raise CliError(
79
+ message="Provider status output is not a JSON object",
80
+ exit_code=RuntimeExitCode.PROVIDER_STATUS_FAILED,
81
+ error_code=RuntimeErrorCode.PROVIDER_STATUS_FAILED,
82
+ )
83
+
84
+ return ProviderStatus(
85
+ runtime_id=runtime_id,
86
+ agent=agent,
87
+ provider_id=str(data.get("provider_id", "")),
88
+ provider_name=str(data.get("provider_name", "")),
89
+ route_mode=str(data.get("route_mode", "unknown")) or "unknown",
90
+ auth_status=str(data.get("auth_status", "unknown")) or "unknown",
91
+ observed_at=_iso_now(),
92
+ )
@@ -0,0 +1,387 @@
1
+ """Environment repair — automatic installation of missing dependencies.
2
+
3
+ Provides interactive installation for Docker and other dependencies when
4
+ aisc doctor detects missing components.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import platform
11
+ import shutil
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Optional, Tuple
15
+
16
+ from aisc.adapters.system import ProcessRunner, RealProcessRunner
17
+ from aisc.domain.models import ProcessResult
18
+
19
+
20
+ def _run_install_command(
21
+ runner: ProcessRunner,
22
+ argv: list[str],
23
+ timeout: float,
24
+ ) -> ProcessResult:
25
+ """Run installers on the terminal when the runner supports streaming."""
26
+ run_streaming = getattr(runner, "run_streaming", None)
27
+ if callable(run_streaming):
28
+ return run_streaming(argv, timeout=timeout)
29
+ return runner.run(argv, timeout=timeout)
30
+
31
+
32
+ def _find_brew_executable() -> Optional[str]:
33
+ discovered = shutil.which("brew")
34
+ if discovered:
35
+ return discovered
36
+ for candidate in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew"):
37
+ if Path(candidate).is_file():
38
+ return candidate
39
+ return None
40
+
41
+
42
+ def detect_platform() -> Tuple[str, str]:
43
+ """Detect OS platform and architecture.
44
+
45
+ Returns:
46
+ (platform, arch) where platform is 'linux', 'darwin', or 'windows'
47
+ and arch is 'x86_64' or 'arm64'
48
+ """
49
+ system = platform.system().lower()
50
+ machine = platform.machine().lower()
51
+
52
+ # Normalize platform name
53
+ if system == "darwin":
54
+ plat = "darwin"
55
+ elif system == "linux":
56
+ plat = "linux"
57
+ elif system in ("windows", "cygwin", "msys"):
58
+ plat = "windows"
59
+ else:
60
+ plat = "unknown"
61
+
62
+ # Normalize architecture
63
+ if machine in ("x86_64", "amd64"):
64
+ arch = "x86_64"
65
+ elif machine in ("arm64", "aarch64"):
66
+ arch = "arm64"
67
+ else:
68
+ arch = "unknown"
69
+
70
+ return plat, arch
71
+
72
+
73
+ def can_install_docker(runner: Optional[ProcessRunner] = None) -> Tuple[bool, str]:
74
+ """Check if Docker can be automatically installed on this platform.
75
+
76
+ Returns:
77
+ (can_install, method) where method is the installation tool name
78
+ """
79
+ if runner is None:
80
+ runner = RealProcessRunner()
81
+
82
+ plat, _ = detect_platform()
83
+
84
+ if plat == "linux":
85
+ # Check for curl (needed for get.docker.com)
86
+ if shutil.which("curl"):
87
+ return True, "get.docker.com"
88
+ return False, "curl not found"
89
+
90
+ elif plat == "darwin":
91
+ # Check for Homebrew
92
+ if shutil.which("brew"):
93
+ return True, "homebrew"
94
+ return True, "homebrew-install-first"
95
+
96
+ elif plat == "windows":
97
+ # Check for winget
98
+ if shutil.which("winget"):
99
+ return True, "winget"
100
+ return False, "winget not found"
101
+
102
+ return False, "unsupported platform"
103
+
104
+
105
+ def get_docker_install_prompt(method: str) -> str:
106
+ """Get user-facing prompt for Docker installation.
107
+
108
+ Args:
109
+ method: Installation method from can_install_docker()
110
+
111
+ Returns:
112
+ Prompt message explaining what will be done
113
+ """
114
+ if method == "get.docker.com":
115
+ return """
116
+ Docker is not installed. AISC can automatically install it using the
117
+ official Docker installation script.
118
+
119
+ This will:
120
+ • Download and run https://get.docker.com
121
+ • Install Docker Engine and CLI tools
122
+ • Add your user to the 'docker' group (requires sudo)
123
+ • You may need to log out and log back in for group changes to take effect
124
+
125
+ Do you want to install Docker now?"""
126
+
127
+ elif method == "homebrew":
128
+ return """
129
+ Docker is not installed. AISC can automatically install Docker Desktop
130
+ using Homebrew.
131
+
132
+ This will:
133
+ • Run: brew install --cask docker
134
+ • Download and install Docker Desktop (~500MB)
135
+ • Automatically start Docker Desktop
136
+ • You may need to accept system permissions
137
+
138
+ Do you want to install Docker now?"""
139
+
140
+ elif method == "homebrew-install-first":
141
+ return """
142
+ Docker is not installed. AISC can install it via Homebrew, but Homebrew
143
+ is not currently installed.
144
+
145
+ This will:
146
+ • First install Homebrew (the macOS package manager)
147
+ • Then install Docker Desktop via: brew install --cask docker
148
+ • You will see prompts for your password and admin approval
149
+
150
+ Do you want to proceed?"""
151
+
152
+ elif method == "winget":
153
+ return """
154
+ Docker is not installed. AISC can automatically install Docker Desktop
155
+ using Windows Package Manager (winget).
156
+
157
+ This will:
158
+ • Run: winget install Docker.DockerDesktop --silent
159
+ • Download and install Docker Desktop (~500MB)
160
+ • Require administrator privileges (UAC prompt)
161
+ • Require logging out and back in (or restarting) after installation
162
+
163
+ Do you want to install Docker now?"""
164
+
165
+ return "Unknown installation method"
166
+
167
+
168
+ def install_docker_linux(runner: ProcessRunner) -> Tuple[bool, str]:
169
+ """Install Docker on Linux using get.docker.com.
170
+
171
+ Returns:
172
+ (success, message)
173
+ """
174
+ print("Downloading Docker installation script...")
175
+
176
+ # Download and run the official Docker install script
177
+ result = _run_install_command(
178
+ runner,
179
+ ["sh", "-c", "curl -fsSL https://get.docker.com | sudo sh"],
180
+ 300.0,
181
+ )
182
+
183
+ if result.exit_code != 0:
184
+ return False, f"Docker installation failed: {result.stderr or result.stdout}"
185
+
186
+ print("Docker installed successfully!")
187
+ print("\nConfiguring user permissions...")
188
+
189
+ # Add user to docker group
190
+ username = os.getenv("USER", os.getenv("USERNAME", ""))
191
+ if username:
192
+ perm_result = runner.run(
193
+ ["sudo", "usermod", "-aG", "docker", username],
194
+ timeout=10.0,
195
+ )
196
+ if perm_result.exit_code == 0:
197
+ return True, f"""
198
+ Docker installed successfully!
199
+
200
+ IMPORTANT: You need to log out and log back in for group permissions to take effect.
201
+
202
+ After logging back in, run 'aisc doctor' to verify the installation.
203
+ """
204
+ else:
205
+ return True, """
206
+ Docker installed, but failed to configure user permissions.
207
+ You may need to manually run: sudo usermod -aG docker $USER
208
+ Then log out and log back in.
209
+ """
210
+
211
+ return True, "Docker installed successfully!"
212
+
213
+
214
+ def install_docker_macos(runner: ProcessRunner, install_brew_first: bool) -> Tuple[bool, str]:
215
+ """Install Docker on macOS using Homebrew.
216
+
217
+ Args:
218
+ install_brew_first: Whether to install Homebrew first
219
+
220
+ Returns:
221
+ (success, message)
222
+ """
223
+ if install_brew_first:
224
+ print("Installing Homebrew first...")
225
+ brew_result = _run_install_command(
226
+ runner,
227
+ [
228
+ "/bin/bash", "-c",
229
+ "curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | /bin/bash",
230
+ ],
231
+ 300.0,
232
+ )
233
+ if brew_result.exit_code != 0:
234
+ return False, "Failed to install Homebrew"
235
+ print("Homebrew installed successfully!")
236
+
237
+ brew_executable = _find_brew_executable()
238
+ if not brew_executable:
239
+ return False, "Homebrew installation completed, but brew was not found"
240
+
241
+ brew_dir = os.path.dirname(brew_executable)
242
+ path_entries = os.environ.get("PATH", "").split(os.pathsep)
243
+ if brew_dir not in path_entries:
244
+ os.environ["PATH"] = os.pathsep.join([brew_dir, *path_entries])
245
+
246
+ print("Installing Docker Desktop via Homebrew...")
247
+ print("This may take several minutes to download (~500MB)...")
248
+
249
+ result = _run_install_command(
250
+ runner,
251
+ [brew_executable, "install", "--cask", "docker"],
252
+ 600.0,
253
+ )
254
+
255
+ if result.exit_code != 0:
256
+ return False, f"Docker installation failed: {result.stderr or result.stdout}"
257
+
258
+ print("Docker Desktop installed successfully!")
259
+ print("Starting Docker Desktop...")
260
+
261
+ # Try to start Docker Desktop
262
+ start_result = runner.run(
263
+ ["open", "-a", "Docker"],
264
+ timeout=10.0,
265
+ )
266
+
267
+ if start_result.exit_code == 0:
268
+ return True, """
269
+ Docker Desktop installed and started successfully!
270
+
271
+ Docker Desktop is launching in the background. This may take a minute.
272
+ Please accept any system permission prompts and the Docker service agreement.
273
+
274
+ Run 'aisc doctor' in a minute to verify Docker is running.
275
+ """
276
+ else:
277
+ return True, """
278
+ Docker Desktop installed, but failed to start automatically.
279
+ Please manually open Docker Desktop from your Applications folder.
280
+
281
+ After Docker Desktop is running, run 'aisc doctor' to verify.
282
+ """
283
+
284
+
285
+ def install_docker_windows(runner: ProcessRunner) -> Tuple[bool, str]:
286
+ """Install Docker on Windows using winget.
287
+
288
+ Returns:
289
+ (success, message)
290
+ """
291
+ print("Installing Docker Desktop via winget...")
292
+ print("You will see a UAC (User Account Control) prompt - please accept it.")
293
+ print("This may take several minutes to download (~500MB)...")
294
+
295
+ result = _run_install_command(
296
+ runner,
297
+ [
298
+ "winget", "install", "Docker.DockerDesktop",
299
+ "--silent",
300
+ "--accept-source-agreements",
301
+ "--accept-package-agreements",
302
+ ],
303
+ 600.0,
304
+ )
305
+
306
+ if result.exit_code != 0:
307
+ return False, f"Docker installation failed: {result.stderr or result.stdout}"
308
+
309
+ return True, """
310
+ Docker Desktop installed successfully!
311
+
312
+ IMPORTANT: You need to log out and log back in (or restart your computer)
313
+ for the installation to complete.
314
+
315
+ After logging back in:
316
+ 1. Start Docker Desktop from the Start menu
317
+ 2. Accept the service agreement
318
+ 3. Run 'aisc doctor' to verify the installation
319
+ """
320
+
321
+
322
+ def install_docker_interactive(runner: Optional[ProcessRunner] = None) -> bool:
323
+ """Interactively install Docker if missing.
324
+
325
+ Returns:
326
+ True if installation was successful (or user declined)
327
+ False if installation failed
328
+ """
329
+ if runner is None:
330
+ runner = RealProcessRunner()
331
+
332
+ can_install, method = can_install_docker(runner)
333
+
334
+ if not can_install:
335
+ print(f"\nCannot automatically install Docker: {method}", file=sys.stderr)
336
+ print("Please install Docker manually:", file=sys.stderr)
337
+ print(" https://docs.docker.com/get-docker/", file=sys.stderr)
338
+ return True # Not a failure, just unsupported
339
+
340
+ # Show installation prompt
341
+ prompt = get_docker_install_prompt(method)
342
+ print(prompt)
343
+
344
+ try:
345
+ response = input("\nProceed with installation? [y/N]: ").strip().lower()
346
+ except (EOFError, KeyboardInterrupt):
347
+ print("\nInstallation cancelled.")
348
+ return True
349
+
350
+ if response not in ("y", "yes"):
351
+ print("Installation cancelled. You can install Docker manually:")
352
+ print(" https://docs.docker.com/get-docker/")
353
+ return True
354
+
355
+ # Perform installation
356
+ print("\nStarting Docker installation...")
357
+
358
+ plat, _ = detect_platform()
359
+ success = False
360
+ message = ""
361
+
362
+ try:
363
+ if plat == "linux" and method == "get.docker.com":
364
+ success, message = install_docker_linux(runner)
365
+ elif plat == "darwin":
366
+ install_brew = (method == "homebrew-install-first")
367
+ success, message = install_docker_macos(runner, install_brew)
368
+ elif plat == "windows" and method == "winget":
369
+ success, message = install_docker_windows(runner)
370
+ else:
371
+ print(f"Unsupported platform/method: {plat}/{method}", file=sys.stderr)
372
+ return False
373
+ except Exception as exc:
374
+ print(f"\nInstallation failed with error: {exc}", file=sys.stderr)
375
+ return False
376
+
377
+ # Show result
378
+ print("\n" + "=" * 60)
379
+ if success:
380
+ print("✓ Installation completed")
381
+ print(message)
382
+ else:
383
+ print("✗ Installation failed")
384
+ print(message, file=sys.stderr)
385
+ print("=" * 60)
386
+
387
+ return success