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,450 @@
1
+ """Runtime commands implementation.
2
+
3
+ Implements aisc runtime subcommands: preflight, start, stop, list, inspect, etc.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ from aisc.adapters.docker_ import DockerExecutor
13
+ from aisc.adapters.docker_sdk_backed import default_executor
14
+ from aisc.domain.models import CliError, RuntimeErrorCode, RuntimeSnapshot, RuntimeExitCode
15
+ from aisc.application.runtime import (
16
+ iso_now,
17
+ compute_config_fingerprint,
18
+ validate_uuid_v4,
19
+ preflight_runtime,
20
+ start_runtime,
21
+ list_runtimes,
22
+ inspect_runtime,
23
+ stop_runtime,
24
+ restart_runtime,
25
+ remove_runtime,
26
+ )
27
+
28
+
29
+ def cmd_runtime_preflight(
30
+ runtime_id: str,
31
+ workspace: Optional[str] = None,
32
+ image: str = "super-claude:latest",
33
+ network: str = "direct",
34
+ scope: str = "project",
35
+ owner: str = "workbench",
36
+ format: str = "text",
37
+ executor: Optional[DockerExecutor] = None,
38
+ ) -> Dict[str, Any]:
39
+ """Perform read-only preflight checks for runtime startup.
40
+
41
+ Per docs/gui-planning/05-cli-gui-contract.md §5.1.
42
+
43
+ Args:
44
+ runtime_id: UUID v4 provided by Workbench
45
+ workspace: Workspace path (defaults to cwd)
46
+ image: Docker image name
47
+ network: Network mode (direct/proxy)
48
+ scope: Scope mode (project/temporary)
49
+ owner: Owner identifier
50
+ format: Output format (text/json)
51
+ executor: Docker executor (for testing)
52
+
53
+ Returns:
54
+ Dict with preflight result payload
55
+ """
56
+
57
+ # Validate runtime_id
58
+ if not validate_uuid_v4(runtime_id):
59
+ return {
60
+ "error": {
61
+ "code": RuntimeErrorCode.INVALID_RUNTIME_ID,
62
+ "message": f"Invalid runtime ID format (must be UUID v4): {runtime_id}",
63
+ },
64
+ "exit_code": RuntimeExitCode.INVALID_RUNTIME_ID,
65
+ }
66
+
67
+ # Validate scope
68
+ if scope not in ("project", "temporary"):
69
+ return {
70
+ "error": {
71
+ "code": RuntimeErrorCode.SCOPE_INVALID,
72
+ "message": f"Invalid scope (must be project|temporary): {scope}",
73
+ },
74
+ "exit_code": RuntimeExitCode.USAGE_ERROR,
75
+ }
76
+
77
+ # Resolve workspace
78
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
79
+
80
+ # PERF P4 (D-13): SDK-backed default executor (AISC_DOCKER_EXECUTOR=cli rollback)
81
+ exec_ = executor or default_executor()
82
+
83
+ # Execute preflight (Stage 7: registry root = data-root state dir)
84
+ from aisc.application.data_root import workspace_state_dir
85
+
86
+ registry_root = workspace_state_dir(ws_path)
87
+ result = preflight_runtime(
88
+ runtime_id=runtime_id,
89
+ workspace=str(ws_path),
90
+ image=image,
91
+ network=network,
92
+ scope=scope,
93
+ owner=owner,
94
+ executor=exec_,
95
+ registry_root=registry_root,
96
+ )
97
+
98
+ # Convert to dict for JSON output
99
+ return {
100
+ "spec": result.spec,
101
+ "checks": [
102
+ {
103
+ "id": c.id,
104
+ "status": c.status,
105
+ "error_code": c.error_code,
106
+ "detail": c.detail,
107
+ }
108
+ for c in result.checks
109
+ ],
110
+ "can_start": result.can_start,
111
+ "recommended_action": result.recommended_action,
112
+ "matching_runtime_id": result.matching_runtime_id,
113
+ "conflicts": result.conflicts,
114
+ "observed_at": result.observed_at,
115
+ }
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Runtime lifecycle commands (§5.2-5.5)
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def _resolve_workspace_and_registry(workspace: Optional[str]) -> tuple:
123
+ """Return (canonical_workspace_str, registry_root_path).
124
+
125
+ Stage 7: registry root is the data-root state dir (DATA-01); legacy
126
+ ``<workspace>/.aisc`` state is adopted on first use.
127
+ """
128
+ from aisc.application.data_root import workspace_state_dir
129
+
130
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
131
+ return str(ws_path), workspace_state_dir(ws_path)
132
+
133
+
134
+ def cmd_runtime_start(
135
+ runtime_id: str,
136
+ workspace: Optional[str] = None,
137
+ image: str = "super-claude:latest",
138
+ network: str = "direct",
139
+ scope: str = "project",
140
+ owner: str = "workbench",
141
+ proxy_config: Optional[str] = None,
142
+ host_mcp_url: Optional[str] = None,
143
+ max_memory: Optional[str] = None,
144
+ max_cpus: Optional[float] = None,
145
+ executor: Optional[DockerExecutor] = None,
146
+ ) -> Dict[str, Any]:
147
+ """Execute ``aisc runtime start`` per contract §5.2."""
148
+ exec_ = executor or default_executor()
149
+ ws, reg_root = _resolve_workspace_and_registry(workspace)
150
+ result = start_runtime(
151
+ runtime_id=runtime_id,
152
+ workspace=ws,
153
+ image=image,
154
+ network=network,
155
+ scope=scope,
156
+ owner=owner,
157
+ executor=exec_,
158
+ registry_root=reg_root,
159
+ proxy_config=proxy_config,
160
+ host_mcp_url=host_mcp_url,
161
+ max_memory=max_memory,
162
+ max_cpus=max_cpus,
163
+ )
164
+ return result.to_dict()
165
+
166
+
167
+ def cmd_runtime_list(
168
+ workspace: Optional[str] = None,
169
+ owner: Optional[str] = None,
170
+ executor: Optional[DockerExecutor] = None,
171
+ ) -> Dict[str, Any]:
172
+ """Execute ``aisc runtime list`` per contract §5.3."""
173
+ exec_ = executor or default_executor()
174
+ ws, reg_root = _resolve_workspace_and_registry(workspace)
175
+ snapshots = list_runtimes(executor=exec_, registry_root=reg_root, owner=owner, workspace=ws)
176
+ return {"runtimes": [s.to_dict() for s in snapshots], "observed_at": iso_now()}
177
+
178
+
179
+ def cmd_runtime_inspect(
180
+ runtime_id: str,
181
+ workspace: Optional[str] = None,
182
+ executor: Optional[DockerExecutor] = None,
183
+ ) -> Dict[str, Any]:
184
+ """Execute ``aisc runtime inspect`` per contract §5.4."""
185
+ exec_ = executor or default_executor()
186
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
187
+ snapshot = inspect_runtime(runtime_id=runtime_id, executor=exec_, registry_root=reg_root)
188
+ return snapshot.to_dict()
189
+
190
+
191
+ def cmd_runtime_status(
192
+ runtime_id: str,
193
+ workspace: Optional[str] = None,
194
+ executor: Optional[DockerExecutor] = None,
195
+ ) -> Dict[str, Any]:
196
+ """Execute ``aisc runtime status`` (PERF P1, contract §5.4b): inspect +
197
+ services merged in ONE invocation — the 5s poll tick used to pay the
198
+ ~750ms sidecar spawn twice. ``snapshot`` is authoritative; ``services``
199
+ is best-effort under a 3s deadline (timeout/failure -> None)."""
200
+ from aisc.application.runtime import runtime_status
201
+
202
+ exec_ = executor or default_executor()
203
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
204
+ return runtime_status(
205
+ runtime_id=runtime_id, executor=exec_, registry_root=reg_root
206
+ )
207
+
208
+
209
+ def cmd_runtime_stop(
210
+ runtime_id: str,
211
+ workspace: Optional[str] = None,
212
+ executor: Optional[DockerExecutor] = None,
213
+ grace_seconds: int = 10,
214
+ ) -> Dict[str, Any]:
215
+ """Execute ``aisc runtime stop`` per contract §5.5.
216
+
217
+ ``--grace`` is validated here (command layer): out-of-range values are a
218
+ usage error and must never reach Docker (05 §3.1)."""
219
+ if not isinstance(grace_seconds, int) or not 1 <= grace_seconds <= 600:
220
+ raise CliError(
221
+ message=f"Invalid --grace {grace_seconds!r}: must be an integer in 1..600",
222
+ exit_code=2,
223
+ error_code="AISC_ERR_USAGE",
224
+ )
225
+ exec_ = executor or default_executor()
226
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
227
+ snapshot = stop_runtime(
228
+ runtime_id=runtime_id, executor=exec_, registry_root=reg_root,
229
+ grace_seconds=grace_seconds,
230
+ )
231
+ return snapshot.to_dict()
232
+
233
+
234
+ def cmd_runtime_restart(
235
+ runtime_id: str,
236
+ workspace: Optional[str] = None,
237
+ executor: Optional[DockerExecutor] = None,
238
+ ) -> Dict[str, Any]:
239
+ """Execute ``aisc runtime restart`` per contract §5.5."""
240
+ exec_ = executor or default_executor()
241
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
242
+ snapshot = restart_runtime(runtime_id=runtime_id, executor=exec_, registry_root=reg_root)
243
+ return snapshot.to_dict()
244
+
245
+
246
+ def cmd_runtime_remove(
247
+ runtime_id: str,
248
+ workspace: Optional[str] = None,
249
+ force: bool = False,
250
+ executor: Optional[DockerExecutor] = None,
251
+ ) -> Dict[str, Any]:
252
+ """Execute ``aisc runtime remove`` per contract §5.5."""
253
+ exec_ = executor or default_executor()
254
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
255
+ snapshot = remove_runtime(
256
+ runtime_id=runtime_id, executor=exec_, registry_root=reg_root, force=force
257
+ )
258
+ return snapshot.to_dict()
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # svc-2: `aisc runtime services` (aisc.runtime-services/v1)
263
+ # ---------------------------------------------------------------------------
264
+
265
+ def cmd_runtime_services(
266
+ runtime_id: str,
267
+ workspace: Optional[str] = None,
268
+ executor: Optional[DockerExecutor] = None,
269
+ ) -> Dict[str, Any]:
270
+ """Execute ``aisc runtime services`` — gateway info + registered services."""
271
+ from aisc.application.web_gateway import runtime_services
272
+
273
+ exec_ = executor or default_executor()
274
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
275
+ return runtime_services(
276
+ runtime_id=runtime_id, executor=exec_, registry_root=reg_root
277
+ ).to_dict()
278
+
279
+
280
+ def cmd_runtime_services_expose(
281
+ runtime_id: str,
282
+ port: str,
283
+ name: str = "",
284
+ workspace: Optional[str] = None,
285
+ executor: Optional[DockerExecutor] = None,
286
+ ) -> Dict[str, Any]:
287
+ """Execute ``aisc runtime services expose`` (ops/test entry; agents use
288
+ the in-container helper — both write the same manifest)."""
289
+ from aisc.application.web_gateway import expose_runtime_service
290
+
291
+ exec_ = executor or default_executor()
292
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
293
+ return expose_runtime_service(
294
+ runtime_id=runtime_id, port_text=port, name=name,
295
+ executor=exec_, registry_root=reg_root,
296
+ ).to_dict()
297
+
298
+
299
+ def cmd_runtime_services_unexpose(
300
+ runtime_id: str,
301
+ port: str,
302
+ workspace: Optional[str] = None,
303
+ executor: Optional[DockerExecutor] = None,
304
+ ) -> Dict[str, Any]:
305
+ """Execute ``aisc runtime services unexpose``."""
306
+ from aisc.application.web_gateway import unexpose_runtime_service
307
+
308
+ exec_ = executor or default_executor()
309
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
310
+ return unexpose_runtime_service(
311
+ runtime_id=runtime_id, port_text=port,
312
+ executor=exec_, registry_root=reg_root,
313
+ ).to_dict()
314
+
315
+
316
+ def cmd_runtime_reconcile(
317
+ workspace: Optional[str] = None,
318
+ instance_id: str = "",
319
+ workspace_key: Optional[str] = None,
320
+ executor: Optional[DockerExecutor] = None,
321
+ ) -> Dict[str, Any]:
322
+ """Execute ``aisc runtime reconcile`` (runtime-lifecycle-ux 02 §3).
323
+
324
+ One idempotent classification+cleanup pass. The command succeeds
325
+ (exit 0) even when ``can_proceed`` is false — a blocked classification
326
+ is a valid answer, not a transport error.
327
+ """
328
+ from aisc.application.workspace_reconcile import reconcile_workspace
329
+
330
+ exec_ = executor or default_executor()
331
+ ws, reg_root = _resolve_workspace_and_registry(workspace)
332
+ payload = reconcile_workspace(ws, instance_id, exec_, registry_root=reg_root)
333
+ if workspace_key and payload.get("workspace_key") != workspace_key:
334
+ raise CliError(
335
+ message=(
336
+ "workspace key mismatch: caller-supplied key does not match "
337
+ "the canonical workspace (wrong workspace path?)"
338
+ ),
339
+ exit_code=RuntimeExitCode.USAGE_ERROR,
340
+ error_code=RuntimeErrorCode.WORKSPACE_INVALID,
341
+ data={"expected": workspace_key, "actual": payload.get("workspace_key")},
342
+ )
343
+ return payload
344
+
345
+
346
+ def cmd_runtime_lease(
347
+ action: str,
348
+ workspace: Optional[str] = None,
349
+ instance_id: str = "",
350
+ lease_id: Optional[str] = None,
351
+ ) -> Dict[str, Any]:
352
+ """Execute ``aisc runtime lease {claim,heartbeat,release,inspect}``.
353
+
354
+ The lease rides the data-root layout (02 §2.3); the Rust heartbeat
355
+ task (D-RUNTIME-12) calls ``heartbeat`` through this command on its
356
+ interval. ``claim`` conflicts raise AISC_ERR_ACTIVE_WORKSPACE_LEASE;
357
+ ``heartbeat`` after a takeover raises AISC_ERR_RUNTIME_LEASE_CONFLICT.
358
+ """
359
+ import uuid as _uuid
360
+
361
+ from aisc.adapters.data_root_store import DataRootStore
362
+ from aisc.adapters.workspace_lease_store import WorkspaceLeaseStore
363
+ from aisc.application.data_root import DataRootResolver
364
+ from aisc.application.runtime import workspace_key_for
365
+
366
+ ws = str(Path(workspace or os.getcwd()).resolve())
367
+ resolved = DataRootResolver().resolve(Path(ws))
368
+ store = DataRootStore(resolved)
369
+ store.prepare()
370
+ leases = WorkspaceLeaseStore(store, workspace_key_for(ws))
371
+
372
+ if action == "inspect":
373
+ lease = leases.inspect()
374
+ payload = lease.to_dict() if lease else {"lease": None}
375
+ return {"action": "inspect", "workspace_key": workspace_key_for(ws), **payload}
376
+ if action == "claim":
377
+ if not instance_id:
378
+ raise CliError(
379
+ message="lease claim requires --instance-id",
380
+ exit_code=RuntimeExitCode.USAGE_ERROR,
381
+ error_code=RuntimeErrorCode.RUNTIME_LEASE_CONFLICT,
382
+ )
383
+ lease, outcome = leases.claim(instance_id, lease_id=lease_id)
384
+ return {
385
+ "action": "claim", "outcome": outcome,
386
+ "workspace_key": workspace_key_for(ws), **lease.to_dict(),
387
+ }
388
+ if action == "heartbeat":
389
+ lease = leases.heartbeat(instance_id, lease_id or "")
390
+ return {
391
+ "action": "heartbeat",
392
+ "workspace_key": workspace_key_for(ws),
393
+ **(lease.to_dict() if lease else {"lease": None}),
394
+ }
395
+ if action == "release":
396
+ released = leases.release(instance_id, lease_id or "")
397
+ return {
398
+ "action": "release", "released": released,
399
+ "workspace_key": workspace_key_for(ws),
400
+ }
401
+ raise CliError(
402
+ message=f"unknown lease action: {action}",
403
+ exit_code=RuntimeExitCode.USAGE_ERROR,
404
+ error_code=RuntimeErrorCode.RUNTIME_LEASE_CONFLICT,
405
+ )
406
+
407
+
408
+ def print_runtime_text(subcommand: str, data: Any, errors: list) -> None:
409
+ """Minimal human-readable output for ``aisc runtime`` text mode."""
410
+ if errors:
411
+ for e in errors:
412
+ print(f"Error: {e.get('message', '')}")
413
+ return
414
+ if not isinstance(data, dict):
415
+ return
416
+ if subcommand == "preflight":
417
+ print(f"can_start: {data.get('can_start')}")
418
+ print(f"recommended_action: {data.get('recommended_action')}")
419
+ for c in data.get("checks", []):
420
+ print(f" {c.get('id')}: {c.get('status')}")
421
+ elif subcommand == "start":
422
+ cfg = data.get("config", {})
423
+ print(f"runtime {data.get('runtime_id', '')}")
424
+ print(f" container: {data.get('container_name', '')} ({data.get('container_id', '')})")
425
+ print(f" state: {data.get('state', '')} ready: {data.get('ready', '')} "
426
+ f"reused: {data.get('reused', '')}")
427
+ print(f" image: {cfg.get('image', '')} network: {cfg.get('network', '')} "
428
+ f"scope: {cfg.get('scope', '')}")
429
+ elif subcommand == "list":
430
+ runtimes = data.get("runtimes", [])
431
+ if not runtimes:
432
+ print("(no runtimes)")
433
+ for r in runtimes:
434
+ print(f"{r.get('runtime_id', '')} {r.get('container_name', '')} "
435
+ f"state={r.get('state', '')} registry={r.get('registry_state', '')}")
436
+ elif subcommand in ("inspect", "stop", "restart", "remove"):
437
+ print(f"runtime {data.get('runtime_id', '')}")
438
+ print(f" container: {data.get('container_name', '')}")
439
+ print(f" state: {data.get('state', '')} registry: {data.get('registry_state', '')}")
440
+ elif subcommand == "services":
441
+ gateway = data.get("gateway", {})
442
+ print(f"gateway: {gateway.get('state', '')} "
443
+ f"host: {gateway.get('host', '')}:{gateway.get('host_port', '')}")
444
+ if gateway.get("reason"):
445
+ print(f" reason: {gateway.get('reason')}")
446
+ services = data.get("services", [])
447
+ if not services:
448
+ print("(no services registered; in-container: aisc-web-expose <port>)")
449
+ for s in services:
450
+ print(f" {s.get('port')} {s.get('name') or '-'} {s.get('url')}")