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,787 @@
1
+ """``aisc status | stop | restart | shell | switch`` — container lifecycle commands.
2
+
3
+ All container operations go through an injected ``DockerExecutor``.
4
+ Container discovery: ``--name`` override → state file → structured error.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from aisc.adapters.container_registry import resolve_target
14
+ from aisc.adapters.docker_ import DockerExecutor, RealDockerExecutor
15
+ from aisc.domain.models import CliError, ProcessResult
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Shared helper — classify process errors
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def _classify_process_error(proc: ProcessResult, name: str, action: str) -> CliError:
23
+ """Map a failed ProcessResult to a controlled CliError with stable codes."""
24
+ if proc.command_not_found:
25
+ return CliError(
26
+ message="Docker CLI not found",
27
+ exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE",
28
+ )
29
+ if proc.timed_out:
30
+ return CliError(
31
+ message=f"docker {action} timed out for container '{name}'",
32
+ exit_code=1, error_code="AISC_ERR_GENERAL",
33
+ )
34
+ stderr_lower = (proc.stderr or "").lower()
35
+ if "permission denied" in stderr_lower:
36
+ return CliError(
37
+ message=f"Permission denied accessing container '{name}'",
38
+ exit_code=9, error_code="AISC_ERR_PERMISSION_DENIED",
39
+ )
40
+ if any(kw in stderr_lower for kw in (
41
+ "cannot connect", "is the docker daemon running",
42
+ "connection refused", "error during connect",
43
+ )):
44
+ return CliError(
45
+ message="Docker daemon unreachable",
46
+ exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE",
47
+ )
48
+ return CliError(
49
+ message=f"docker {action} failed for container '{name}' (exit {proc.exit_code}): "
50
+ f"{(proc.stderr or '').strip()[:200]}",
51
+ exit_code=1, error_code="AISC_ERR_GENERAL",
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Container discovery
57
+ # ---------------------------------------------------------------------------
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Container discovery — delegates to container_registry.resolve_target
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def discover_container(
64
+ name_override: Optional[str] = None,
65
+ explicit_root: Optional[str] = None,
66
+ label_override: Optional[str] = None,
67
+ executor: Optional[DockerExecutor] = None,
68
+ ) -> str:
69
+ """Discover the target container name via the registry.
70
+
71
+ Priority (handled in :func:`container_registry.resolve_target`):
72
+ 1. ``name_override``
73
+ 2. ``label_override`` (unique match)
74
+ 3. ``default`` pointer (last ``run``)
75
+ 4. single registered container
76
+ 5. multiple → ``CliError`` listing candidates
77
+
78
+ A lazy GC prunes registry entries whose container no longer exists.
79
+
80
+ Returns:
81
+ Container name string.
82
+
83
+ Raises:
84
+ CliError: If no container can be discovered.
85
+ """
86
+ return resolve_target(
87
+ root=None,
88
+ name_override=name_override,
89
+ label_override=label_override,
90
+ executor=executor,
91
+ explicit_root=explicit_root,
92
+ )
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Status result
97
+ # ---------------------------------------------------------------------------
98
+
99
+ @dataclass
100
+ class StatusResult:
101
+ """Structured result of container status query."""
102
+ name: str = ""
103
+ exists: bool = False
104
+ running: bool = False
105
+ status: str = ""
106
+ image: str = ""
107
+ container_id: str = ""
108
+
109
+ def to_dict(self) -> Dict[str, Any]:
110
+ return {
111
+ "name": self.name,
112
+ "exists": self.exists,
113
+ "running": self.running,
114
+ "status": self.status,
115
+ "image": self.image,
116
+ "container_id": self.container_id,
117
+ }
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Status command
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def cmd_status(
125
+ name_override: Optional[str] = None,
126
+ explicit_root: Optional[str] = None,
127
+ executor: Optional[DockerExecutor] = None,
128
+ label_override: Optional[str] = None,
129
+ ) -> StatusResult:
130
+ """Query container status via ``docker inspect``.
131
+
132
+ A missing container returns ``exists=False`` (success), while daemon /
133
+ permission failures raise CliError.
134
+ """
135
+ exec_ = executor or RealDockerExecutor()
136
+ root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
137
+ name = discover_container(name_override=name_override,
138
+ explicit_root=root,
139
+ label_override=label_override,
140
+ executor=exec_)
141
+
142
+ # Use docker inspect with Go template for structured output
143
+ fmt = '{{.Name}}\t{{.State.Running}}\t{{.State.Status}}\t{{.Config.Image}}\t{{.Id}}'
144
+ argv = ["inspect", "--format", fmt, name]
145
+
146
+ proc = exec_.run_captured(argv, timeout=10.0)
147
+
148
+ if proc.command_not_found or proc.timed_out:
149
+ raise _classify_process_error(proc, name, "inspect")
150
+
151
+ stderr_lower = (proc.stderr or "").lower()
152
+ if proc.exit_code != 0:
153
+ # "No such object" / "not found" → container doesn't exist (success)
154
+ if any(kw in stderr_lower for kw in (
155
+ "no such object", "no such container", "not found",
156
+ )):
157
+ return StatusResult(name=name, exists=False)
158
+ # Delegate other errors to classifier
159
+ raise _classify_process_error(proc, name, "inspect")
160
+
161
+ # Parse tab-separated output
162
+ stdout = proc.stdout.strip()
163
+ if not stdout:
164
+ return StatusResult(name=name, exists=False)
165
+
166
+ parts = stdout.split("\t")
167
+ running = parts[1].lower() == "true" if len(parts) > 1 else False
168
+ status = parts[2] if len(parts) > 2 else ""
169
+ image = parts[3] if len(parts) > 3 else ""
170
+ cid = parts[4][:12] if len(parts) > 4 else ""
171
+
172
+ return StatusResult(
173
+ name=name,
174
+ exists=True,
175
+ running=running,
176
+ status=status,
177
+ image=image,
178
+ container_id=cid,
179
+ )
180
+
181
+
182
+ def print_status_text(result: StatusResult) -> None:
183
+ """Print status in human-readable format."""
184
+ if not result.exists:
185
+ print(f"Container '{result.name}' not found.")
186
+ return
187
+
188
+ running_str = "running" if result.running else "stopped"
189
+ print(f"Container: {result.name}")
190
+ print(f"Exists: yes")
191
+ print(f"Running: {running_str}")
192
+ print(f"Status: {result.status}")
193
+ print(f"Image: {result.image}")
194
+ if result.container_id:
195
+ print(f"ID: {result.container_id}")
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Stop command
200
+ # ---------------------------------------------------------------------------
201
+
202
+ def cmd_stop(
203
+ name_override: Optional[str] = None,
204
+ explicit_root: Optional[str] = None,
205
+ executor: Optional[DockerExecutor] = None,
206
+ label_override: Optional[str] = None,
207
+ remove: bool = True,
208
+ ) -> Dict[str, Any]:
209
+ """Stop the discovered container via ``docker stop`` (F2-C: then REMOVE).
210
+
211
+ Requires the container to exist. Idempotent: stopping an already-stopped
212
+ container returns success. The container is unregistered from the index
213
+ after stop (it is no longer an active target) and — since F2-C runs are
214
+ detached keep-alives — removed by default, else every stop litters.
215
+ """
216
+ exec_ = executor or RealDockerExecutor()
217
+ root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
218
+ name = discover_container(name_override=name_override,
219
+ explicit_root=root,
220
+ label_override=label_override,
221
+ executor=exec_)
222
+
223
+ # First check if container exists (same derived registry root)
224
+ status = cmd_status(name_override=name, explicit_root=root,
225
+ executor=executor)
226
+
227
+ if not status.exists:
228
+ raise CliError(
229
+ message=f"容器 '{name}' 不存在——无可停止。",
230
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
231
+ )
232
+
233
+ if status.running:
234
+ proc = exec_.run_captured(["stop", name], timeout=30.0)
235
+ if proc.exit_code != 0:
236
+ raise _classify_process_error(proc, name, "stop")
237
+ if remove:
238
+ # An already-stopped keep-alive container still needs its cleanup.
239
+ rm = exec_.run_captured(["rm", "-f", name], timeout=30.0)
240
+ if rm.exit_code != 0:
241
+ raise _classify_process_error(rm, name, "remove")
242
+
243
+ # Unregister from the multi-container index (no longer an active
244
+ # target). The registry root must go through _resolve_root — writing at
245
+ # the raw explicit_root would CREATE an empty containers.json there and
246
+ # shadow the real (.aisc / state-dir) one for every later lookup.
247
+ try:
248
+ from aisc.adapters.container_registry import unregister, _resolve_root
249
+ reg = None
250
+ if root:
251
+ reg = _resolve_root(None, root)
252
+ if reg is None:
253
+ # name/label override legs keep the legacy root resolution
254
+ from aisc.application.resources import locate_aisc_root
255
+ try:
256
+ reg = locate_aisc_root(explicit_root=explicit_root)
257
+ except Exception:
258
+ reg = None
259
+ if reg is not None:
260
+ unregister(reg, name)
261
+ except Exception:
262
+ pass
263
+
264
+ return {"name": name, "stopped": status.running,
265
+ "already_stopped": not status.running, "removed": remove}
266
+
267
+
268
+ def cmd_stop_all(
269
+ explicit_root: Optional[str] = None,
270
+ executor: Optional[DockerExecutor] = None,
271
+ ) -> Dict[str, Any]:
272
+ """F2-C ``aisc stop --all``: stop + remove every CLI-owned container.
273
+
274
+ Workbench-managed runtimes (owner=workbench) are deliberately untouched —
275
+ the GUI owns their lifecycle (leases, reconcile); the CLI's blast radius
276
+ stays on its own one-shot activations.
277
+ """
278
+ exec_ = executor or RealDockerExecutor()
279
+ from aisc.adapters.container_registry import list_containers, unregister
280
+ from aisc.application.data_root import DataRootResolver
281
+
282
+ # Every workspace registry under the data root (the family's registries
283
+ # are workspace-scoped; the CLI's blast radius excludes Workbench-owned
284
+ # runtimes wherever they live).
285
+ shared_workspaces = DataRootResolver().resolve_shared_root() / "workspaces"
286
+ stopped: List[Dict[str, Any]] = []
287
+ skipped = 0
288
+ registry_dirs = [
289
+ p / "runtime" for p in shared_workspaces.glob("*") if (p / "runtime" / "containers.json").is_file()
290
+ ] if shared_workspaces.is_dir() else []
291
+ for reg_dir in registry_dirs:
292
+ for cname, meta in list_containers(reg_dir).items():
293
+ meta = meta or {}
294
+ if meta.get("owner") == "workbench":
295
+ skipped += 1
296
+ continue
297
+ name = str(cname)
298
+ if not name:
299
+ continue
300
+ stop = exec_.run_captured(["stop", name], timeout=30.0)
301
+ rm = exec_.run_captured(["rm", "-f", name], timeout=30.0)
302
+ try:
303
+ unregister(reg_dir, name)
304
+ except Exception:
305
+ pass
306
+ stopped.append({
307
+ "name": name,
308
+ "workspace": meta.get("workspace", ""),
309
+ "exit_code": rm.exit_code if rm.exit_code != 0 else stop.exit_code,
310
+ })
311
+ return {"stopped": stopped, "skipped": skipped}
312
+
313
+
314
+ def print_stop_text(data: Dict[str, Any]) -> None:
315
+ """Print stop result in human-readable format.
316
+
317
+ F2-C stop = stop + REMOVE (detached keep-alives would otherwise litter);
318
+ the wording says both — English phrasing that only said "stopped" hid
319
+ the removal (manual test r2 note).
320
+ """
321
+ if data.get("already_stopped"):
322
+ print(f"容器 '{data['name']}' 此前已停止,现已移除。")
323
+ else:
324
+ print(f"容器 '{data['name']}' 已停止并移除。")
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Restart command
329
+ # ---------------------------------------------------------------------------
330
+
331
+ def cmd_restart(
332
+ name_override: Optional[str] = None,
333
+ explicit_root: Optional[str] = None,
334
+ executor: Optional[DockerExecutor] = None,
335
+ label_override: Optional[str] = None,
336
+ ) -> Dict[str, Any]:
337
+ """Restart the discovered container via ``docker restart``.
338
+
339
+ Requires the container to exist.
340
+ """
341
+ exec_ = executor or RealDockerExecutor()
342
+ root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
343
+ name = discover_container(name_override=name_override,
344
+ explicit_root=root,
345
+ label_override=label_override,
346
+ executor=exec_)
347
+
348
+ # First check if container exists
349
+ status = cmd_status(name_override=name, explicit_root=explicit_root,
350
+ executor=executor)
351
+
352
+ if not status.exists:
353
+ raise CliError(
354
+ message=f"Container '{name}' not found — cannot restart.",
355
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
356
+ )
357
+
358
+ argv = ["restart", name]
359
+ proc = exec_.run_captured(argv, timeout=30.0)
360
+
361
+ if proc.exit_code != 0:
362
+ raise _classify_process_error(proc, name, "restart")
363
+
364
+ return {"name": name, "restarted": True}
365
+
366
+
367
+ def print_restart_text(data: Dict[str, Any]) -> None:
368
+ """Print restart result in human-readable format."""
369
+ print(f"Container '{data['name']}' restarted.")
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Shell command
374
+ # ---------------------------------------------------------------------------
375
+
376
+ def _active_registry_root(explicit_root: Optional[str]) -> Optional[str]:
377
+ """F2-C default leg: the ACTIVE workspace's registry root (never cwd —
378
+ the cwd anchor trips the data-root guard from $HOME and reads the
379
+ wrong registry). Shared by the no-override paths of stop/shell/
380
+ status/restart/switch."""
381
+ if explicit_root:
382
+ return explicit_root
383
+ from aisc.cli.commands import runs as cli_runs
384
+
385
+ try:
386
+ active = cli_runs.get_active()
387
+ except Exception:
388
+ active = None
389
+ if active:
390
+ from aisc.application.data_root import workspace_state_dir
391
+
392
+ try:
393
+ return str(workspace_state_dir(Path(active)))
394
+ except Exception:
395
+ return None
396
+ return None
397
+
398
+
399
+ def cmd_shell(
400
+ name_override: Optional[str] = None,
401
+ explicit_root: Optional[str] = None,
402
+ executor: Optional[DockerExecutor] = None,
403
+ label_override: Optional[str] = None,
404
+ rest: Optional[List[str]] = None,
405
+ ) -> ProcessResult:
406
+ """Open an interactive shell via ``docker exec -it NAME bash``.
407
+
408
+ r2 #I: args after ``--`` pass through verbatim (``aisc shell -- ls -la``)
409
+ like the agent sugar — a non-interactive one-shot exec (no ``-it``).
410
+ Uses streaming executor for interactive terminal. Text-only.
411
+ Returns ProcessResult so caller can inspect exit_code / errors.
412
+ """
413
+ exec_ = executor or RealDockerExecutor()
414
+ root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
415
+ name = discover_container(name_override=name_override,
416
+ explicit_root=root,
417
+ label_override=label_override,
418
+ executor=exec_)
419
+
420
+ # Verify container exists and is running
421
+ status = cmd_status(name_override=name, explicit_root=root,
422
+ executor=executor)
423
+
424
+ if not status.exists:
425
+ raise CliError(
426
+ message=f"容器 '{name}' 不存在——无法打开 shell。",
427
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
428
+ )
429
+ if not status.running:
430
+ raise CliError(
431
+ message=f"容器 '{name}' 未在运行——无法打开 shell。",
432
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
433
+ )
434
+
435
+ passthrough = [a for a in (rest or []) if a != "--"]
436
+ if passthrough:
437
+ # one-shot, non-interactive: no -it (docker exec -t without a TTY
438
+ # dies), stream like the interactive case
439
+ argv = ["exec", name, *passthrough]
440
+ proc = exec_.run_streaming(argv)
441
+ if proc.command_not_found:
442
+ raise CliError(message="Docker CLI 不可用",
443
+ exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE")
444
+ return proc
445
+
446
+ # v2.1.7 S6 (Gate-S6/D10): interactive shells get the tutorial `help`
447
+ # via the exec environment (BASH_FUNC_* re-import) — see
448
+ # aisc.cli.tutorial.help_function_env. The plain `bash` argv is
449
+ # unchanged; nothing is written to the image or any profile.
450
+ argv = ["exec", "-it", name, "bash"]
451
+ proc = exec_.run_streaming(argv)
452
+
453
+ # Map transport errors — nonzero exit from docker exec is returned to caller
454
+ if proc.command_not_found or proc.timed_out:
455
+ raise _classify_process_error(proc, name, "exec")
456
+
457
+ return proc
458
+
459
+
460
+ # ---------------------------------------------------------------------------
461
+ # Switch command — scope-preserving Bash wrapper
462
+ # ---------------------------------------------------------------------------
463
+ #
464
+ # ``docker exec`` starts a new process that does **not** inherit
465
+ # ``entrypoint.sh`` dynamically exports runtime paths for PID 1. This
466
+ # wrapper reads the NUL-delimited /proc/1/environ
467
+ # **without eval** using a ``while IFS= read -r -d ''`` loop and a ``case``
468
+ # statement, so literal special characters (spaces, ``$``, ``;``, quotes,
469
+ # backticks, glob chars) in the values survive exactly.
470
+ #
471
+ # The environ source path is passed as the first positional argument
472
+ # (``$1``). In production this is always ``/proc/1/environ``; tests may
473
+ # substitute a temp file. The wrapper shifts past the source and the
474
+ # ``--`` guard, then ``exec "$@"`` with the target command. The provider
475
+ # / subcommand is always positional — never shell-interpolated.
476
+ #
477
+ # Fail-closed: if the source cannot be read or a required runtime path is
478
+ # absent or empty the wrapper exits 101. No home-directory fallback.
479
+ #
480
+ # .. code-block:: text
481
+ #
482
+ # docker exec -it NAME bash -c '
483
+ # while IFS= read -r -d """" entry; do
484
+ # case "$entry" in
485
+ # CLAUDE_CONFIG_DIR=*) ... ;;
486
+ # CC_SWITCH_CONFIG_DIR=*) ... ;;
487
+ # esac
488
+ # done < "$1"
489
+ # shift; shift # past source, past --
490
+ # [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${CC_SWITCH_CONFIG_DIR:-}" ] || exit 101
491
+ # export CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME
492
+ # exec "$@"
493
+ # ' aisc-scope /proc/1/environ -- cc-switch
494
+ _SCOPE_WRAPPER = "\n".join([
495
+ 'if [ ! -r "$1" ]; then',
496
+ " echo 'Error: Cannot read scope environment from PID 1' >&2",
497
+ ' exit 101',
498
+ 'fi',
499
+ 'unset CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME',
500
+ "while IFS= read -r -d '' entry; do",
501
+ ' case "$entry" in',
502
+ ' CLAUDE_CONFIG_DIR=*) CLAUDE_CONFIG_DIR=${entry#*=} ;;',
503
+ ' CC_SWITCH_CONFIG_DIR=*) CC_SWITCH_CONFIG_DIR=${entry#*=} ;;',
504
+ ' CODEX_CONFIG_DIR=*) CODEX_CONFIG_DIR=${entry#*=} ;;',
505
+ ' CODEX_HOME=*) CODEX_HOME=${entry#*=} ;;',
506
+ ' esac',
507
+ 'done < "$1"',
508
+ 'shift',
509
+ 'shift',
510
+ 'if [ -z "${CLAUDE_CONFIG_DIR:-}" ] || [ -z "${CC_SWITCH_CONFIG_DIR:-}" ]; then',
511
+ " echo 'Error: Cannot read scope environment from PID 1' >&2",
512
+ ' exit 101',
513
+ 'fi',
514
+ 'export CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME',
515
+ 'exec "$@"',
516
+ '', # trailing newline
517
+ ])
518
+
519
+ # Production environ source — always /proc/1/environ.
520
+ _SCOPE_ENV_SOURCE = "/proc/1/environ"
521
+
522
+
523
+ def _build_switch_argv(name: str, quick: Optional[str]) -> list:
524
+ """Build the ``docker exec`` argv for switch.
525
+
526
+ Wraps the target command in a Bash snippet that reads the AISC and
527
+ cc-switch runtime paths from PID 1's ``_SCOPE_ENV_SOURCE``
528
+ (a NUL-delimited environ file, normally ``/proc/1/environ``).
529
+
530
+ The source path and the ``--`` guard are passed as positional
531
+ arguments. The provider / subcommand follows as positional argv,
532
+ **never** shell-interpolated.
533
+
534
+ Returns a list suitable for ``DockerExecutor.run_streaming``.
535
+ """
536
+ if quick:
537
+ return ["exec", "-it", name, "bash", "-c", _SCOPE_WRAPPER,
538
+ "aisc-scope", _SCOPE_ENV_SOURCE, "--",
539
+ "cc-switch", "-a", "claude", "provider", "switch", quick]
540
+ else:
541
+ return ["exec", "-it", name, "bash", "-c", _SCOPE_WRAPPER,
542
+ "aisc-scope", _SCOPE_ENV_SOURCE, "--", "cc-switch"]
543
+
544
+
545
+ def _validate_provider_id(provider: str) -> None:
546
+ """Validate that *provider* is a non-empty positional token."""
547
+ if not provider or not provider.strip():
548
+ raise CliError(
549
+ message="Provider name required for --quick switch",
550
+ exit_code=2, error_code="AISC_ERR_USAGE",
551
+ )
552
+
553
+
554
+ def cmd_switch(
555
+ name_override: Optional[str] = None,
556
+ explicit_root: Optional[str] = None,
557
+ quick: Optional[str] = None,
558
+ executor: Optional[DockerExecutor] = None,
559
+ label_override: Optional[str] = None,
560
+ ) -> ProcessResult:
561
+ """Switch AI provider inside the container.
562
+
563
+ Default (no --quick): ``docker exec -it NAME cc-switch`` (full TUI).
564
+ Quick mode uses ``cc-switch -a claude provider switch PROVIDER``.
565
+
566
+ Uses streaming executor for interactive terminal. Text-only.
567
+ Returns ProcessResult so caller can inspect exit_code / errors.
568
+ """
569
+ exec_ = executor or RealDockerExecutor()
570
+
571
+ # Validate --quick provider early (before any Docker interaction)
572
+ if quick is not None:
573
+ _validate_provider_id(quick)
574
+ if not quick:
575
+ raise CliError(
576
+ message="Provider name required for --quick switch",
577
+ exit_code=2, error_code="AISC_ERR_USAGE",
578
+ )
579
+
580
+ root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
581
+ name = discover_container(name_override=name_override,
582
+ explicit_root=root,
583
+ label_override=label_override,
584
+ executor=exec_)
585
+
586
+ # Verify container exists and is running
587
+ status = cmd_status(name_override=name, explicit_root=root,
588
+ executor=executor)
589
+
590
+ if not status.exists:
591
+ raise CliError(
592
+ message=f"Container '{name}' not found — cannot switch provider.",
593
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
594
+ )
595
+ if not status.running:
596
+ raise CliError(
597
+ message=f"Container '{name}' is not running — cannot switch provider.",
598
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
599
+ )
600
+
601
+ # Build argv via the scope-preserving wrapper
602
+ argv = _build_switch_argv(name, quick)
603
+
604
+ proc = exec_.run_streaming(argv)
605
+
606
+ if proc.command_not_found or proc.timed_out:
607
+ raise _classify_process_error(proc, name, "exec")
608
+
609
+ return proc
610
+
611
+
612
+ def cmd_provider_set_key(
613
+ name_override: Optional[str] = None,
614
+ explicit_root: Optional[str] = None,
615
+ provider_id: Optional[str] = None,
616
+ agent: str = "claude",
617
+ executor: Optional[DockerExecutor] = None,
618
+ label_override: Optional[str] = None,
619
+ ) -> ProcessResult:
620
+ """Open cc-switch's interactive editor for a provider.
621
+
622
+ The compatibility-facing command remains ``aisc provider set-key``, but
623
+ credentials are entered only through ``cc-switch provider edit`` so they
624
+ never appear in shell history or process arguments.
625
+
626
+ Uses streaming executor for interactive terminal. Text-only.
627
+ Returns ProcessResult so caller can inspect exit_code / errors.
628
+ """
629
+ exec_ = executor or RealDockerExecutor()
630
+
631
+ # Validate provider_id
632
+ if not provider_id or not provider_id.strip():
633
+ raise CliError(
634
+ message="Provider ID required",
635
+ exit_code=2, error_code="AISC_ERR_USAGE",
636
+ )
637
+
638
+ root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
639
+ name = discover_container(name_override=name_override,
640
+ explicit_root=root,
641
+ label_override=label_override,
642
+ executor=exec_)
643
+
644
+ # Verify container exists and is running
645
+ status = cmd_status(name_override=name, explicit_root=root,
646
+ executor=executor)
647
+
648
+ if not status.exists:
649
+ raise CliError(
650
+ message=f"Container '{name}' not found — cannot set API key.",
651
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
652
+ )
653
+ if not status.running:
654
+ raise CliError(
655
+ message=f"Container '{name}' is not running — cannot set API key.",
656
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
657
+ )
658
+
659
+ # Build argv via the scope-preserving wrapper
660
+ argv = _build_provider_edit_argv(name, agent, provider_id)
661
+
662
+ proc = exec_.run_streaming(argv)
663
+
664
+ if proc.command_not_found or proc.timed_out:
665
+ raise _classify_process_error(proc, name, "exec")
666
+
667
+ return proc
668
+
669
+
670
+ def _build_provider_edit_argv(
671
+ name: str,
672
+ agent: str,
673
+ provider_id: str,
674
+ ) -> list:
675
+ """Build the ``docker exec`` argv for interactive provider editing.
676
+
677
+ Wraps the target command in the same scope-preserving Bash wrapper
678
+ used by switch. No credential value is accepted by this function.
679
+
680
+ Returns a list suitable for ``DockerExecutor.run_streaming``.
681
+ """
682
+ return ["exec", "-it", name, "bash", "-c", _SCOPE_WRAPPER,
683
+ "aisc-scope", _SCOPE_ENV_SOURCE, "--",
684
+ "cc-switch", "-a", agent, "provider", "edit", provider_id]
685
+
686
+
687
+ def print_switch_text(data: Dict[str, Any]) -> None:
688
+ """Print switch result summary."""
689
+ name = data.get("name", "")
690
+ provider = data.get("provider", "")
691
+ quick = data.get("quick", False)
692
+ if quick and provider:
693
+ print(f"Switched container '{name}' to provider '{provider}'.")
694
+ elif provider:
695
+ print(f"Switched container '{name}' to provider '{provider}'.")
696
+ else:
697
+ print(f"Provider switch completed for container '{name}'.")
698
+
699
+
700
+ # ---------------------------------------------------------------------------
701
+ # Ps command — list all registered containers
702
+ # ---------------------------------------------------------------------------
703
+
704
+ @dataclass
705
+ class PsRow:
706
+ """One row of the ``aisc ps`` table."""
707
+ name: str = ""
708
+ label: str = ""
709
+ status: str = ""
710
+ running: bool = False
711
+ image: str = ""
712
+ workspace: str = ""
713
+ active: bool = False
714
+
715
+
716
+ def cmd_ps(
717
+ name_override: Optional[str] = None,
718
+ explicit_root: Optional[str] = None,
719
+ executor: Optional[DockerExecutor] = None,
720
+ ) -> List[PsRow]:
721
+ """List every registered container machine-wide, with live docker status.
722
+
723
+ Manual test r1 #1 (F2-C): activations register under the WORKSPACE's
724
+ state dir (data-root ``workspaces/<h>/runtime``), so the old
725
+ cwd-anchored single registry never saw them — scan every workspace
726
+ registry instead (the same data face as ``aisc workspaces``). One
727
+ ``docker ps -a`` round trip carries status; a registered name docker
728
+ does not know reads as ``gone`` (stale entry — orphan hygiene). When
729
+ docker is unreachable nothing is guessed: every row shows ``?``.
730
+
731
+ *explicit_root* now means the DATA ROOT override (a dir containing
732
+ ``workspaces/``), matching the registry anchor; the machine-global
733
+ ACTIVE workspace (last ``aisc run``) gets ``active=True``.
734
+ """
735
+ from aisc.cli.commands.workspaces import docker_states, registry_entries
736
+
737
+ exec_ = executor or RealDockerExecutor()
738
+
739
+ entries, defaults = registry_entries(exec_, data_root=explicit_root)
740
+ states, docker_ok = docker_states(exec_)
741
+
742
+ rows: List[PsRow] = []
743
+ for nm, meta in entries:
744
+ meta = meta if isinstance(meta, dict) else {}
745
+ name = str(nm)
746
+ if not name:
747
+ continue
748
+ if not docker_ok:
749
+ status, running = "?", False
750
+ elif name not in states:
751
+ status, running = "gone", False
752
+ else:
753
+ status = states[name]
754
+ running = status.startswith("Up")
755
+ ws = str(meta.get("workspace", ""))
756
+ rows.append(PsRow(
757
+ name=name,
758
+ label=str(meta.get("label", "")),
759
+ image=str(meta.get("image", "")),
760
+ workspace=ws,
761
+ status=status,
762
+ running=running,
763
+ # r2 #B: star the registry DEFAULT pointer — what a bare
764
+ # `aisc claude` actually lands in — not "any container of the
765
+ # active workspace" (label slots live there too).
766
+ active=name in defaults,
767
+ ))
768
+
769
+ # active first, then running, then name — glanceable and stable
770
+ rows.sort(key=lambda r: (not r.active, not r.running, r.name))
771
+ return rows
772
+
773
+
774
+ def print_ps_text(rows: List[PsRow]) -> None:
775
+ """Print the ``aisc ps`` table (dead/gone rows get a cleanup hint)."""
776
+ if not rows:
777
+ print("尚无已注册容器——先 aisc run <路径> 激活工作区。")
778
+ return
779
+ print(f"{'NAME':<36} {'ACTIVE':<6} {'LABEL':<10} {'STATUS':<10} {'IMAGE':<24} WORKSPACE")
780
+ for r in rows:
781
+ label = r.label or "-"
782
+ star = "*" if r.active else ""
783
+ print(f"{r.name:<36} {star:<6} {label:<10} {r.status:<10} {r.image:<24} {r.workspace}")
784
+ # r2 #G: orphan hygiene — dead/gone rows exist, tell the user what to do
785
+ if any(not r.running for r in rows):
786
+ print("提示: 已停止(gone/Exited)的容器可 aisc stop --name <名> 清除,"
787
+ "或 aisc run --resume <别名|序号|路径> 重建。")