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
aisc/cli/main.py ADDED
@@ -0,0 +1,2926 @@
1
+ """Main CLI entry point — argument parsing, command dispatch, output formatting.
2
+
3
+ ``aisc version``, ``aisc doctor``, ``aisc build``, and ``aisc run`` commands.
4
+ ``python -m aisc`` and console_script ``aisc`` are both supported.
5
+
6
+ All docker operations are injected through a ``DockerExecutor``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
17
+
18
+ from aisc import __version__
19
+ from aisc.cli.output import (
20
+ JsonlEmitter,
21
+ build_envelope,
22
+ build_error,
23
+ emit_json,
24
+ emit_json_usage_error,
25
+ print_doctor_text,
26
+ )
27
+ from aisc.domain.models import CheckStatus, CliError, DoctorReport, VersionInfo
28
+ from aisc.domain.models import SessionAgent
29
+ from aisc.domain.models import RuntimeErrorCode
30
+ from aisc.domain.artifacts import ArtifactAction, ArtifactKind, ArtifactOpenWith
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Custom argument parser — JSON-aware error handling
35
+ # ---------------------------------------------------------------------------
36
+
37
+ class _AiscArgumentParser(argparse.ArgumentParser):
38
+ """ArgumentParser that respects ``--format json`` when reporting errors."""
39
+
40
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
41
+ kwargs.setdefault("allow_abbrev", False)
42
+ super().__init__(*args, **kwargs)
43
+ self._aisc_format: Optional[str] = None
44
+ self._aisc_command: str = "aisc"
45
+
46
+ def error(self, message: str) -> None: # type: ignore[override]
47
+ if self._aisc_format == "json":
48
+ emit_json_usage_error(
49
+ command=self._aisc_command or "aisc",
50
+ version=__version__,
51
+ message=message,
52
+ )
53
+ sys.exit(2)
54
+ else:
55
+ super().error(message)
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Parser setup
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def _add_global_args(p: argparse.ArgumentParser, *, is_subparser: bool = False) -> None:
63
+ """Add global options."""
64
+ default_val = argparse.SUPPRESS if is_subparser else "text"
65
+ p.add_argument(
66
+ "--format",
67
+ choices=["text", "json"],
68
+ default=default_val,
69
+ help="Output format (default: text)",
70
+ )
71
+ p.add_argument(
72
+ "--no-color",
73
+ action="store_true",
74
+ default=argparse.SUPPRESS if is_subparser else False,
75
+ help="Disable ANSI color output",
76
+ )
77
+ p.add_argument(
78
+ "--aisc-root",
79
+ type=str,
80
+ default=argparse.SUPPRESS if is_subparser else None,
81
+ help="Path to AISC repository root",
82
+ )
83
+ p.add_argument(
84
+ "--events",
85
+ action="store_true",
86
+ default=argparse.SUPPRESS if is_subparser else False,
87
+ help="Enable JSONL event stream output (build/run commands)",
88
+ )
89
+
90
+
91
+ def _build_parser() -> _AiscArgumentParser:
92
+ """Build the top-level argument parser."""
93
+ parser = _AiscArgumentParser(
94
+ prog="aisc",
95
+ description="AISC CLI — Super Claude workstation management",
96
+ )
97
+ _add_global_args(parser)
98
+
99
+ sub = parser.add_subparsers(dest="command", title="commands")
100
+
101
+ # --- version ---
102
+ vp = sub.add_parser("version", help="Show version information", allow_abbrev=False)
103
+ _add_global_args(vp, is_subparser=True)
104
+
105
+ # --- doctor ---
106
+ dp = sub.add_parser("doctor", help="Run environment diagnostics", allow_abbrev=False)
107
+ _add_global_args(dp, is_subparser=True)
108
+
109
+ # --- serve (2.1.10 R1): long-lived remote channel on stdio ---
110
+ svp = sub.add_parser(
111
+ "serve",
112
+ help="Serve remote callers: newline-delimited JSON frames on stdio",
113
+ allow_abbrev=False,
114
+ )
115
+ svp.add_argument(
116
+ "--stdio",
117
+ action="store_true",
118
+ help="speak the frame protocol on stdin/stdout (the only transport in this version)",
119
+ )
120
+ _add_global_args(svp, is_subparser=True)
121
+
122
+ # --- build ---
123
+ bp = sub.add_parser("build", help="Build Docker image", allow_abbrev=False)
124
+ _add_global_args(bp, is_subparser=True)
125
+ bp.add_argument("--tag", "-t", type=str, default="super-claude:latest",
126
+ help="Image tag (default: super-claude:latest)")
127
+ bp.add_argument("--no-cache", action="store_true", default=False,
128
+ help="Disable Docker build cache")
129
+ bp.add_argument("--pull", action="store_true", default=False,
130
+ help="Always pull the base image")
131
+ bp.add_argument("--dry-run", action="store_true", default=False,
132
+ help="Plan the build without executing")
133
+ # Stage 8b (CS-01/CS-02): cc-switch release resolution. `latest` resolves
134
+ # the newest stable upstream release live (fail closed, never a silent
135
+ # pin); an explicit vX.Y.Z is reproducible; --cc-switch-manifest builds
136
+ # fully offline from a previously written resolution receipt.
137
+ bp.add_argument("--cc-switch-version", type=str, default=argparse.SUPPRESS,
138
+ help="cc-switch version: 'latest' (default) or vX.Y.Z "
139
+ "(env: CC_SWITCH_VERSION)")
140
+ bp.add_argument("--cc-switch-channel", type=str, default=argparse.SUPPRESS,
141
+ choices=["stable"],
142
+ help="cc-switch release channel (default: stable; "
143
+ "env: CC_SWITCH_CHANNEL)")
144
+ bp.add_argument("--cc-switch-manifest", type=str, default=None, metavar="PATH",
145
+ help="Build from a resolver manifest file (offline / "
146
+ "reproducible; skips the live resolve)")
147
+ bp.add_argument("--require-pin", action="store_true", default=False,
148
+ help="Fail the build when the cc-switch version cannot be "
149
+ "resolved (default: an offline build continues WITHOUT "
150
+ "the pin — unverified, CN-mirror download)")
151
+
152
+ # --- run ---
153
+ # F2-C: run ACTIVATES a workspace (detached, then exit). Interactive
154
+ # surfaces live on their own verbs: shell / claude / codex.
155
+ rp = sub.add_parser("run", help="Activate a workspace (detached container)",
156
+ allow_abbrev=False)
157
+ _add_global_args(rp, is_subparser=True)
158
+ rp.add_argument("path", type=str, nargs="?", default=None,
159
+ help="Workspace directory to activate (e.g. ./ or /home/user/proj)")
160
+ rp.add_argument("--resume", type=str, default=None, metavar="N|PATH",
161
+ help="Re-activate from history (see `aisc runs`); explicit flags win")
162
+ rp.add_argument("--image", "-i", type=str, default="super-claude:latest",
163
+ help="Docker image (default: super-claude:latest)")
164
+ rp.add_argument("--name", type=str, default="super-claude-station",
165
+ help="Workspace alias — also the container name prefix; "
166
+ "resume later by it (aisc run --resume <别名>)")
167
+ rp.add_argument("--network", type=str, choices=["direct", "proxy"],
168
+ default=argparse.SUPPRESS,
169
+ help="Network mode: direct or proxy (default: direct)")
170
+ rp.add_argument("--profile", type=str, choices=["proxy"],
171
+ default=None,
172
+ help="Compatibility alias for --network proxy (prefer --network proxy)")
173
+ rp.add_argument("--dry-run", action="store_true", default=False,
174
+ help="Plan the activation without executing")
175
+ rp.add_argument("--label", type=str, default="",
176
+ help="Container label for multi-container addressing (optional)")
177
+
178
+ # --- F2-C: agent sugar + activation history ---
179
+ for _agent in ("claude", "codex"):
180
+ _ap = sub.add_parser(_agent, help=f"Open {_agent} in the active workspace",
181
+ allow_abbrev=False)
182
+ _add_global_args(_ap, is_subparser=True)
183
+ _ap.add_argument("--workspace", type=str, default=None,
184
+ help="Target a registered workspace instead of the active one")
185
+ _ap.add_argument("--name", type=str, default=None,
186
+ help="Container name (overrides registry discovery)")
187
+ _ap.add_argument("rest", nargs=argparse.REMAINDER, default=[],
188
+ help=f"Args passed to {_agent} verbatim (use -- first: aisc {_agent} -- -c)")
189
+
190
+ rsp = sub.add_parser("runs", help="Activation history (aisc run records)",
191
+ allow_abbrev=False)
192
+ _add_global_args(rsp, is_subparser=True)
193
+
194
+ wsp = sub.add_parser("workspaces", help="Manage running CLI workspaces",
195
+ allow_abbrev=False)
196
+ _add_global_args(wsp, is_subparser=True)
197
+ wsp.add_argument("--stop", action="store_true", default=False,
198
+ help="Stop & remove every RUNNING CLI-owned workspace")
199
+
200
+ # --- config ---
201
+ cp = sub.add_parser("config", help="Config management", allow_abbrev=False)
202
+ _add_global_args(cp, is_subparser=True)
203
+ csub = cp.add_subparsers(dest="config_command", title="config commands",
204
+ parser_class=_AiscArgumentParser)
205
+
206
+ cv = csub.add_parser("validate", help="Validate config files", allow_abbrev=False)
207
+ _add_global_args(cv, is_subparser=True)
208
+ cv.add_argument("--config", type=str, default=None,
209
+ help="Explicit user config file path")
210
+ cv.add_argument("--workspace", type=str, default=None,
211
+ help="Workspace root path")
212
+
213
+ ce = csub.add_parser("effective", help="Show effective config", allow_abbrev=False)
214
+ _add_global_args(ce, is_subparser=True)
215
+ ce.add_argument("--config", type=str, default=None,
216
+ help="Explicit user config file path")
217
+ ce.add_argument("--workspace", type=str, default=None,
218
+ help="Workspace root path")
219
+
220
+ cshow = csub.add_parser("show", help="Alias for 'config effective' (compatibility name)",
221
+ allow_abbrev=False)
222
+ _add_global_args(cshow, is_subparser=True)
223
+ cshow.add_argument("--config", type=str, default=None,
224
+ help="Explicit user config file path")
225
+ cshow.add_argument("--workspace", type=str, default=None,
226
+ help="Workspace root path")
227
+
228
+ # --- profile ---
229
+ prp = sub.add_parser("profile", help="Profile management", allow_abbrev=False)
230
+ _add_global_args(prp, is_subparser=True)
231
+ prsub = prp.add_subparsers(dest="profile_command", title="profile commands",
232
+ parser_class=_AiscArgumentParser)
233
+
234
+ prl = prsub.add_parser("list", help="List available profiles", allow_abbrev=False)
235
+ _add_global_args(prl, is_subparser=True)
236
+
237
+ prs = prsub.add_parser("show", help="Show profile details", allow_abbrev=False)
238
+ _add_global_args(prs, is_subparser=True)
239
+ prs.add_argument("name", type=str, nargs="?", default=None,
240
+ help="Profile name (default: safe)")
241
+
242
+ # --- status ---
243
+ stp = sub.add_parser("status", help="Show container status", allow_abbrev=False)
244
+ _add_global_args(stp, is_subparser=True)
245
+ stp.add_argument("--name", type=str, default=None,
246
+ help="Container name (overrides registry discovery)")
247
+ stp.add_argument("--label", type=str, default=None,
248
+ help="Target container by label")
249
+
250
+ # --- stop ---
251
+ spp = sub.add_parser("stop", help="Stop & remove the active workspace container",
252
+ allow_abbrev=False)
253
+ _add_global_args(spp, is_subparser=True)
254
+ spp.add_argument("--name", type=str, default=None,
255
+ help="Container name (overrides registry discovery)")
256
+ spp.add_argument("--label", type=str, default=None,
257
+ help="Target container by label")
258
+ spp.add_argument("--all", action="store_true", default=False,
259
+ help="Stop & remove EVERY CLI-owned container (Workbench runtimes untouched)")
260
+
261
+ # --- restart ---
262
+ rsp = sub.add_parser("restart", help="Restart the container", allow_abbrev=False)
263
+ _add_global_args(rsp, is_subparser=True)
264
+ rsp.add_argument("--name", type=str, default=None,
265
+ help="Container name (overrides registry discovery)")
266
+ rsp.add_argument("--label", type=str, default=None,
267
+ help="Target container by label")
268
+
269
+ # --- shell ---
270
+ shp = sub.add_parser("shell", help="Open a bash shell in the container", allow_abbrev=False)
271
+ _add_global_args(shp, is_subparser=True)
272
+ shp.add_argument("--name", type=str, default=None,
273
+ help="Container name (overrides registry discovery)")
274
+ shp.add_argument("--label", type=str, default=None,
275
+ help="Target container by label")
276
+ shp.add_argument("rest", nargs=argparse.REMAINDER, default=[],
277
+ help="One-shot command in the container (use -- first: aisc shell -- ls -la)")
278
+
279
+ # --- switch ---
280
+ swp = sub.add_parser("switch", help="Switch AI provider in the container", allow_abbrev=False)
281
+ _add_global_args(swp, is_subparser=True)
282
+ swp.add_argument("--name", type=str, default=None,
283
+ help="Container name (overrides registry discovery)")
284
+ swp.add_argument("--label", type=str, default=None,
285
+ help="Target container by label")
286
+ swp.add_argument("--quick", type=str, default=None,
287
+ help="Provider id or alias for quick switch (e.g. deepseek)")
288
+
289
+ # --- provider ---
290
+ prp = sub.add_parser("provider", help="Manage AI provider configuration", allow_abbrev=False)
291
+ _add_global_args(prp, is_subparser=True)
292
+ prsub = prp.add_subparsers(dest="provider_command", title="provider commands",
293
+ parser_class=_AiscArgumentParser)
294
+
295
+ prsk = prsub.add_parser(
296
+ "set-key",
297
+ help="Open the secure interactive editor for a provider",
298
+ allow_abbrev=False,
299
+ )
300
+ _add_global_args(prsk, is_subparser=True)
301
+ prsk.add_argument("provider_id", type=str,
302
+ help="Provider ID (e.g., deepseek, zhipu, kimi)")
303
+ prsk.add_argument("--name", type=str, default=None,
304
+ help="Container name (overrides registry discovery)")
305
+ prsk.add_argument("--label", type=str, default=None,
306
+ help="Target container by label")
307
+ prsk.add_argument("--agent", type=str, default="claude",
308
+ help="Target agent (default: claude)")
309
+
310
+ # provider current (Workbench S0.4): non-interactive, --format json
311
+ prpc = prsub.add_parser(
312
+ "current",
313
+ help="Show the current provider status for an agent (Workbench)",
314
+ allow_abbrev=False,
315
+ )
316
+ _add_global_args(prpc, is_subparser=True)
317
+ prpc.add_argument("--runtime-id", type=str, required=True,
318
+ help="Runtime ID (UUID v4)")
319
+ prpc.add_argument("--agent", type=str, required=True,
320
+ choices=["claude", "codex"],
321
+ help="Agent (claude|codex)")
322
+ prpc.add_argument("--workspace", type=str, default=None,
323
+ help="Workspace path (default: current directory)")
324
+
325
+ # --- cc-switch (Stage 8d: provider data plane, aisc.cc-switch-provider/v1) ---
326
+ csp = sub.add_parser(
327
+ "cc-switch", help="Manage cc-switch providers (list/add/edit/delete)",
328
+ allow_abbrev=False,
329
+ )
330
+ _add_global_args(csp, is_subparser=True)
331
+ cssub = csp.add_subparsers(dest="cc_switch_command", title="cc-switch commands",
332
+ parser_class=_AiscArgumentParser)
333
+
334
+ def _cc_switch_common(p):
335
+ _add_global_args(p, is_subparser=True)
336
+ p.add_argument("--runtime-id", type=str, required=True,
337
+ help="Runtime ID (UUID v4)")
338
+ p.add_argument("--agent", type=str, required=True,
339
+ choices=["claude", "codex"], help="Agent (claude|codex)")
340
+ p.add_argument("--workspace", type=str, default=None,
341
+ help="Workspace path (default: current directory)")
342
+
343
+ csl = cssub.add_parser("list", help="List providers (secret-free snapshot)",
344
+ allow_abbrev=False)
345
+ _cc_switch_common(csl)
346
+ csl.add_argument("--reveal-id", type=str, default=None, metavar="ID",
347
+ help="Additionally emit the FULL api_key of this provider "
348
+ "(edit-time explicit view, 2.1.11 P1)")
349
+
350
+ csa = cssub.add_parser("add", help="Add a provider (request JSON on stdin)",
351
+ allow_abbrev=False)
352
+ _cc_switch_common(csa)
353
+ # KI-7①: default None, NOT "simple" — the Workbench never passes --mode,
354
+ # and a truthy argparse default unconditionally clobbered the stdin
355
+ # document's "mode":"custom" (observed as "unknown preset provider").
356
+ csa.add_argument("--mode", choices=["simple", "custom"], default=None,
357
+ help="simple = preset provider + api key; custom = full "
358
+ "fields (the stdin request's mode wins unless set)")
359
+ csa.add_argument("--provider", type=str, default=None,
360
+ help="Preset provider id (simple mode, e.g. deepseek)")
361
+ csa.add_argument("--id", dest="new_id", type=str, default=None,
362
+ help="Provider id to create (default: preset id / name slug)")
363
+
364
+ cse = cssub.add_parser("edit", help="Edit a provider (patch JSON on stdin)",
365
+ allow_abbrev=False)
366
+ _cc_switch_common(cse)
367
+ cse.add_argument("provider_id", type=str, help="Provider ID to edit")
368
+
369
+ css = cssub.add_parser("switch", help="Activate a provider (make it current)",
370
+ allow_abbrev=False)
371
+ _cc_switch_common(css)
372
+ css.add_argument("provider_id", type=str, help="Provider ID to activate")
373
+
374
+ csd = cssub.add_parser("delete", help="Delete a provider", allow_abbrev=False)
375
+ _cc_switch_common(csd)
376
+ csd.add_argument("provider_id", type=str, help="Provider ID to delete")
377
+ csd.add_argument("--confirm", action="store_true", default=False,
378
+ help="Required confirmation flag")
379
+
380
+ # IDEA-5 (5c): remote model list for the mapping dropdown.
381
+ csf = cssub.add_parser("fetch-models",
382
+ help="Fetch the remote model list for a provider",
383
+ allow_abbrev=False)
384
+ _cc_switch_common(csf)
385
+ csf.add_argument("provider_id", type=str, nargs="?", default="",
386
+ help="Provider ID to query (empty = add-mode inline probe; "
387
+ "the stdin document then carries base_url)")
388
+
389
+ # --- network (IDEA-2: mihomo subscription data plane) ---
390
+ nwp = sub.add_parser(
391
+ "network", help="Network management (mihomo subscription for container TUN)",
392
+ allow_abbrev=False,
393
+ )
394
+ _add_global_args(nwp, is_subparser=True)
395
+ nwsub = nwp.add_subparsers(dest="network_command", title="network commands",
396
+ parser_class=_AiscArgumentParser, required=True)
397
+
398
+ nws = nwsub.add_parser(
399
+ "subscription", help="Manage the proxy subscription (IDEA-2)",
400
+ allow_abbrev=False,
401
+ )
402
+ _add_global_args(nws, is_subparser=True)
403
+ nwssub = nws.add_subparsers(dest="subscription_command",
404
+ title="subscription commands",
405
+ parser_class=_AiscArgumentParser, required=True)
406
+
407
+ nwi = nwssub.add_parser(
408
+ "import", help="Import a subscription (URL on stdin — a credential, "
409
+ "never argv)", allow_abbrev=False)
410
+ _add_global_args(nwi, is_subparser=True)
411
+
412
+ nwif = nwssub.add_parser(
413
+ "import-file", help="Import manually supplied subscription content "
414
+ "(full content on stdin; fallback for sources "
415
+ "that reject automated downloads)", allow_abbrev=False)
416
+ _add_global_args(nwif, is_subparser=True)
417
+
418
+ nwr = nwssub.add_parser(
419
+ "refresh", help="Re-fetch the stored subscription URL", allow_abbrev=False)
420
+ _add_global_args(nwr, is_subparser=True)
421
+
422
+ # 挂账①: persistence endpoint for the Rust (reqwest) downloader.
423
+ nwsd = nwssub.add_parser(
424
+ "store-downloaded",
425
+ help="Persist a Rust-side download ({url, content_b64, userinfo} JSON "
426
+ "on stdin — the fingerprint-wall-safe transport)",
427
+ allow_abbrev=False)
428
+ _add_global_args(nwsd, is_subparser=True)
429
+
430
+ nww = nwssub.add_parser(
431
+ "show", help="Show subscription status (secret-free)", allow_abbrev=False)
432
+ _add_global_args(nww, is_subparser=True)
433
+
434
+ nwc = nwssub.add_parser("clear", help="Remove the stored subscription",
435
+ allow_abbrev=False)
436
+ _add_global_args(nwc, is_subparser=True)
437
+ nwc.add_argument("--confirm", action="store_true", default=False,
438
+ help="Required confirmation flag")
439
+
440
+ # --- usage (IDEA-2: provider token usage aggregation) ---
441
+ usp = sub.add_parser(
442
+ "usage", help="Provider token usage statistics (all workspaces)",
443
+ allow_abbrev=False,
444
+ )
445
+ _add_global_args(usp, is_subparser=True)
446
+ ussub = usp.add_subparsers(dest="usage_command", title="usage commands",
447
+ parser_class=_AiscArgumentParser, required=True)
448
+ uso = ussub.add_parser(
449
+ "overview", help="Subscription status + per-provider token usage",
450
+ allow_abbrev=False)
451
+ _add_global_args(uso, is_subparser=True)
452
+ uso.add_argument("--range", dest="range", type=str, default="7d",
453
+ choices=["today", "7d", "30d"],
454
+ help="Time window (default: 7d)")
455
+ uso.add_argument("--workspace", type=str, default=None,
456
+ help="Limit to one workspace path (default: all)")
457
+
458
+ # --- logs (lifecycle-logging: the shared JSONL timeline) ---
459
+ lgp = sub.add_parser(
460
+ "logs", help="Lifecycle event log (full-flow debugging timeline)",
461
+ allow_abbrev=False,
462
+ )
463
+ _add_global_args(lgp, is_subparser=True)
464
+ lgsub = lgp.add_subparsers(dest="logs_command", title="logs commands",
465
+ parser_class=_AiscArgumentParser, required=True)
466
+ lgs = lgsub.add_parser(
467
+ "show", help="Show recent lifecycle events (secret-free by construction)",
468
+ allow_abbrev=False)
469
+ _add_global_args(lgs, is_subparser=True)
470
+ lgs.add_argument("--lines", type=int, default=200, metavar="N",
471
+ help="Number of recent events (default: 200; current file only)")
472
+ lgs.add_argument("--source", choices=["app", "cli", "ui", "all"],
473
+ default="all",
474
+ help="Filter by writer (default: all)")
475
+ lgt = lgsub.add_parser(
476
+ "path", help="Print the log file path (for scripts and the Workbench)",
477
+ allow_abbrev=False)
478
+ _add_global_args(lgt, is_subparser=True)
479
+
480
+ # --- ps ---
481
+ psp = sub.add_parser("ps", help="List all registered containers", allow_abbrev=False)
482
+ _add_global_args(psp, is_subparser=True)
483
+
484
+ # --- maintenance (docker-resource-lifecycle B) ---
485
+ mtp = sub.add_parser("maintenance", help="Installer-facing Docker lifecycle ops",
486
+ allow_abbrev=False)
487
+ _add_global_args(mtp, is_subparser=True)
488
+ mtsub = mtp.add_subparsers(dest="maintenance_command", title="maintenance commands",
489
+ parser_class=_AiscArgumentParser)
490
+
491
+ mts = mtsub.add_parser("docker-scan", help="Read-only ownership classification",
492
+ allow_abbrev=False)
493
+ _add_global_args(mts, is_subparser=True)
494
+ mts.add_argument("--context", choices=["first_install", "upgrade", "uninstall"],
495
+ default="upgrade",
496
+ help="Install context drives legacy-image evidence rules")
497
+ mts.add_argument("--old-image-id", action="append", default=[],
498
+ help="Upgrade-captured old image ID (temporary evidence; repeatable)")
499
+
500
+ mtc = mtsub.add_parser("docker-cleanup", help="Remove owned/legacy containers+images",
501
+ allow_abbrev=False)
502
+ _add_global_args(mtc, is_subparser=True)
503
+ mtc.add_argument("--context", choices=["first_install", "upgrade", "uninstall"],
504
+ default="uninstall")
505
+ mtc.add_argument("--old-image-id", action="append", default=[])
506
+
507
+ mtr = mtsub.add_parser("docker-rebuild", help="No-cache rebuild with old-ID handoff",
508
+ allow_abbrev=False)
509
+ _add_global_args(mtr, is_subparser=True)
510
+ mtr.add_argument("--root", required=True, help="Bundle root (contains Dockerfile)")
511
+ mtr.add_argument("--tag", default="super-claude:latest")
512
+ mtr.add_argument("--old-image-id", default="")
513
+ mtr.add_argument("--no-cache", action="store_true", default=True)
514
+ mtr.add_argument("--pull", action="store_true", default=False)
515
+
516
+ mtu = mtsub.add_parser("cache-usage", help="Read-only docker system df summary",
517
+ allow_abbrev=False)
518
+ _add_global_args(mtu, is_subparser=True)
519
+
520
+ mtcc = mtsub.add_parser("cache-cleanup",
521
+ help="Prune builder cache + dangling images (until-filtered)",
522
+ allow_abbrev=False)
523
+ _add_global_args(mtcc, is_subparser=True)
524
+ mtcc.add_argument("--min-age-hours", type=int, default=24,
525
+ help="Only clear cache entries older than this (default 24)")
526
+
527
+ # --- bundle (0.1.0 A3, guide 3.3.3) ---
528
+ bp = sub.add_parser("bundle", help="Runtime bundle store (fetch from GitHub Releases)",
529
+ allow_abbrev=False)
530
+ _add_global_args(bp, is_subparser=True)
531
+ bsub = bp.add_subparsers(dest="bundle_command", title="bundle commands",
532
+ parser_class=_AiscArgumentParser)
533
+ bf = bsub.add_parser("fetch", help="Download+verify+install the bundle for a version",
534
+ allow_abbrev=False)
535
+ _add_global_args(bf, is_subparser=True)
536
+ bf.add_argument("--version", default=None,
537
+ help="Version to fetch (default: this CLI's version; exact match)")
538
+ bf.add_argument("--from-file", default=None,
539
+ help="Offline path: install from a local release archive (requires --sha256)")
540
+ bf.add_argument("--sha256", default=None,
541
+ help="Expected sha256 of --from-file (same strength as the online API digest)")
542
+ bf.add_argument("--allow-mismatch", action="store_true", default=False,
543
+ help="Escape hatch: install even if the manifest gate rejects this CLI")
544
+ bl = bsub.add_parser("list", help="List installed bundles", allow_abbrev=False)
545
+ _add_global_args(bl, is_subparser=True)
546
+ brm = bsub.add_parser("remove", help="Remove an installed bundle (refuses the active one)",
547
+ allow_abbrev=False)
548
+ _add_global_args(brm, is_subparser=True)
549
+ brm.add_argument("version", help="Bundle version to remove")
550
+ bpth = bsub.add_parser("path", help="Print the bundles directory", allow_abbrev=False)
551
+ _add_global_args(bpth, is_subparser=True)
552
+
553
+ # --- update (0.1.0 A4, D-7) ---
554
+ up = sub.add_parser("update", help="Self-update the aisc CLI (frozen forms)",
555
+ allow_abbrev=False)
556
+ _add_global_args(up, is_subparser=True)
557
+ up.add_argument("--check", action="store_true", default=False,
558
+ help="Show the update plan without touching anything")
559
+ up.add_argument("--version", default=None,
560
+ help="Target version (default: latest final release)")
561
+ up.add_argument("--from-file", default=None,
562
+ help="Offline path: update from a local release archive (requires --sha256)")
563
+ up.add_argument("--sha256", default=None,
564
+ help="Expected sha256 of --from-file")
565
+ up.add_argument("--rebuild", action="store_true", default=False,
566
+ help="After replacing, run the installer-style no-cache image rebuild")
567
+ up.add_argument("--pin-tool", action="append", default=None, metavar="KEY=VALUE",
568
+ help="Pin a container tool version in the data-root user layer "
569
+ "(repeatable; CLAUDE_CODE_VERSION / CODEX_VERSION / CC_SWITCH_VERSION); "
570
+ "applies at the next image build and survives updates")
571
+
572
+ # --- runtime ---
573
+ rtp = sub.add_parser("runtime", help="Runtime control plane (Workbench Phase 0)", allow_abbrev=False)
574
+ _add_global_args(rtp, is_subparser=True)
575
+ rtsub = rtp.add_subparsers(dest="runtime_command", title="runtime commands",
576
+ parser_class=_AiscArgumentParser)
577
+
578
+ rtpf = rtsub.add_parser("preflight", help="Preflight checks for runtime start", allow_abbrev=False)
579
+ _add_global_args(rtpf, is_subparser=True)
580
+ rtpf.add_argument("--runtime-id", type=str, required=True,
581
+ help="Runtime ID (UUID v4, provided by Workbench)")
582
+ rtpf.add_argument("--workspace", type=str, default=None,
583
+ help="Workspace path (default: current directory)")
584
+ rtpf.add_argument("--image", type=str, default="super-claude:latest",
585
+ help="Docker image (default: super-claude:latest)")
586
+ rtpf.add_argument("--network", type=str, choices=["direct", "proxy"],
587
+ default="direct",
588
+ help="Network mode (default: direct)")
589
+ rtpf.add_argument("--scope", type=str, choices=["project", "temporary"],
590
+ default="project",
591
+ help="Runtime scope (default: project)")
592
+ rtpf.add_argument("--owner", type=str, default="workbench",
593
+ help="Owner identifier (default: workbench)")
594
+
595
+ # --- runtime start ---
596
+ rts = rtsub.add_parser("start", help="Start a Workbench runtime", allow_abbrev=False)
597
+ _add_global_args(rts, is_subparser=True)
598
+ rts.add_argument("--runtime-id", type=str, required=True,
599
+ help="Runtime ID (UUID v4, provided by Workbench)")
600
+ rts.add_argument("--workspace", type=str, default=None,
601
+ help="Workspace path (default: current directory)")
602
+ rts.add_argument("--image", type=str, default="super-claude:latest",
603
+ help="Docker image (default: super-claude:latest)")
604
+ rts.add_argument("--network", type=str, choices=["direct", "proxy"],
605
+ default="direct", help="Network mode (default: direct)")
606
+ rts.add_argument("--scope", type=str, choices=["project", "temporary"],
607
+ default="project", help="Runtime scope (default: project)")
608
+ rts.add_argument("--owner", type=str, default="workbench",
609
+ help="Owner identifier (default: workbench)")
610
+ rts.add_argument("--proxy-config", type=str, default=None,
611
+ help="Host path to mihomo config.yaml to mount for --network proxy "
612
+ "(S0.2: proxy caps/device are set; without this, TUN runs without a config)")
613
+ rts.add_argument("--host-mcp-url", type=str, default=None,
614
+ help="F2 (D-10): full host-tools MCP URL incl. token query; forwarded "
615
+ "into the container as AISC_HOST_MCP_URL for agent registration")
616
+ rts.add_argument("--max-memory", type=str, default=None,
617
+ help="PERF P8 (D-13): container memory limit (docker --memory, e.g. 3g)")
618
+ rts.add_argument("--max-cpus", type=float, default=None,
619
+ help="PERF P8 (D-13): container CPU limit (docker --cpus, e.g. 1.5)")
620
+
621
+ # --- runtime list ---
622
+ rtl = rtsub.add_parser("list", help="List runtimes with Docker reconciliation",
623
+ allow_abbrev=False)
624
+ _add_global_args(rtl, is_subparser=True)
625
+ rtl.add_argument("--workspace", type=str, default=None,
626
+ help="Workspace path (default: current directory)")
627
+ rtl.add_argument("--owner", type=str, default=None,
628
+ help="Filter by owner (e.g. workbench)")
629
+
630
+ # --- runtime inspect ---
631
+ rti = rtsub.add_parser("inspect", help="Show a single runtime", allow_abbrev=False)
632
+ _add_global_args(rti, is_subparser=True)
633
+ rti.add_argument("--runtime-id", type=str, required=True,
634
+ help="Runtime ID (UUID v4)")
635
+ rti.add_argument("--workspace", type=str, default=None,
636
+ help="Workspace path (default: current directory)")
637
+
638
+ # --- runtime status (PERF P1: merged inspect+services, one invocation) ---
639
+ rtstt = rtsub.add_parser("status", help="Snapshot + services in one call (poll path)",
640
+ allow_abbrev=False)
641
+ _add_global_args(rtstt, is_subparser=True)
642
+ rtstt.add_argument("--runtime-id", type=str, required=True,
643
+ help="Runtime ID (UUID v4)")
644
+ rtstt.add_argument("--workspace", type=str, default=None,
645
+ help="Workspace path (default: current directory)")
646
+
647
+ # --- runtime stop ---
648
+ rtst = rtsub.add_parser("stop", help="Stop a runtime (keep container + metadata)",
649
+ allow_abbrev=False)
650
+ _add_global_args(rtst, is_subparser=True)
651
+ rtst.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
652
+ rtst.add_argument("--workspace", type=str, default=None,
653
+ help="Workspace path (default: current directory)")
654
+ rtst.add_argument("--grace", type=int, default=10,
655
+ help="docker stop grace period in seconds (1..600; default 10)")
656
+
657
+ # --- runtime restart ---
658
+ rtr = rtsub.add_parser("restart", help="Restart a runtime with original config",
659
+ allow_abbrev=False)
660
+ _add_global_args(rtr, is_subparser=True)
661
+ rtr.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
662
+ rtr.add_argument("--workspace", type=str, default=None,
663
+ help="Workspace path (default: current directory)")
664
+
665
+ # --- runtime remove ---
666
+ rtrm = rtsub.add_parser("remove", help="Remove a runtime (container + registry)",
667
+ allow_abbrev=False)
668
+ _add_global_args(rtrm, is_subparser=True)
669
+ rtrm.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
670
+ rtrm.add_argument("--workspace", type=str, default=None,
671
+ help="Workspace path (default: current directory)")
672
+ rtrm.add_argument("--force", action="store_true", default=False,
673
+ help="Remove even if the runtime is running")
674
+
675
+ # --- runtime services (svc-2, aisc.runtime-services/v1) ---
676
+ # NOTE: the parent keeps --runtime-id/--workspace OPTIONAL and each
677
+ # subcommand redefines them required — Python 3.14 argparse rejects
678
+ # "services list --runtime-id X" when the parent marks the same option
679
+ # required (the subparser's parse no longer satisfies the parent's
680
+ # required check). The bare form is validated in _cmd_runtime dispatch.
681
+ rtsv = rtsub.add_parser("services", help="Web service gateway info + registered services",
682
+ allow_abbrev=False)
683
+ _add_global_args(rtsv, is_subparser=True)
684
+ rtsv.add_argument("--runtime-id", type=str, default=None,
685
+ help="Runtime ID (UUID v4)")
686
+ rtsv.add_argument("--workspace", type=str, default=None,
687
+ help="Workspace path (default: current directory)")
688
+ rtsvsub = rtsv.add_subparsers(dest="runtime_services_command",
689
+ title="runtime services commands",
690
+ parser_class=_AiscArgumentParser)
691
+
692
+ rtsvl = rtsvsub.add_parser("list", help="List registered services (default)",
693
+ allow_abbrev=False)
694
+ _add_global_args(rtsvl, is_subparser=True)
695
+ rtsvl.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
696
+ rtsvl.add_argument("--workspace", type=str, default=None,
697
+ help="Workspace path (default: current directory)")
698
+
699
+ rtsve = rtsvsub.add_parser("expose", help="Register a service port (ops/test entry)",
700
+ allow_abbrev=False)
701
+ _add_global_args(rtsve, is_subparser=True)
702
+ rtsve.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
703
+ rtsve.add_argument("--port", type=str, required=True,
704
+ help="Container service port (1024..65535)")
705
+ rtsve.add_argument("--name", type=str, default="",
706
+ help="Display label (safe short text)")
707
+ rtsve.add_argument("--workspace", type=str, default=None,
708
+ help="Workspace path (default: current directory)")
709
+
710
+ rtsvu = rtsvsub.add_parser("unexpose", help="Unregister a service port",
711
+ allow_abbrev=False)
712
+ _add_global_args(rtsvu, is_subparser=True)
713
+ rtsvu.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
714
+ rtsvu.add_argument("--port", type=str, required=True,
715
+ help="Container service port (1024..65535)")
716
+ rtsvu.add_argument("--workspace", type=str, default=None,
717
+ help="Workspace path (default: current directory)")
718
+
719
+ # --- runtime reconcile (runtime-lifecycle-ux Stage 1, 02 §3) ---
720
+ rtrc = rtsub.add_parser("reconcile",
721
+ help="Classify + auto-recycle stale runtimes for a workspace",
722
+ allow_abbrev=False)
723
+ _add_global_args(rtrc, is_subparser=True)
724
+ rtrc.add_argument("--workspace", type=str, default=None,
725
+ help="Workspace path (default: current directory)")
726
+ rtrc.add_argument("--instance-id", type=str, required=True,
727
+ help="This Workbench instance's UUID v4")
728
+ rtrc.add_argument("--workspace-key", type=str, default=None,
729
+ help="Optional cross-check: expected sha256 workspace key")
730
+
731
+ # --- runtime lease (runtime-lifecycle-ux Stage 1, 02 §2) ---
732
+ # Same py3.14 pattern as `services`: shared options on the group are
733
+ # optional, children re-require them; the bare form is usage-rejected
734
+ # in dispatch.
735
+ rtlg = rtsub.add_parser("lease", help="Workspace lease claim/heartbeat/release/inspect",
736
+ allow_abbrev=False)
737
+ _add_global_args(rtlg, is_subparser=True)
738
+ rtlg.add_argument("--workspace", type=str, default=None,
739
+ help="Workspace path (default: current directory)")
740
+ rtlg.add_argument("--instance-id", type=str, default=None,
741
+ help="This Workbench instance's UUID v4")
742
+ rtlgsub = rtlg.add_subparsers(dest="runtime_lease_command",
743
+ title="runtime lease commands",
744
+ parser_class=_AiscArgumentParser)
745
+ for _name, _help in (
746
+ ("claim", "Claim the workspace lease"),
747
+ ("heartbeat", "Refresh the lease last-seen timestamp"),
748
+ ("release", "Release the workspace lease"),
749
+ ("inspect", "Show the current lease"),
750
+ ):
751
+ _p = rtlgsub.add_parser(_name, help=_help, allow_abbrev=False)
752
+ _add_global_args(_p, is_subparser=True)
753
+ _req = _name in ("claim", "heartbeat", "release")
754
+ _p.add_argument("--workspace", type=str, default=None,
755
+ help="Workspace path (default: current directory)")
756
+ _p.add_argument("--instance-id", type=str, required=_req,
757
+ help="This Workbench instance's UUID v4")
758
+ _p.add_argument("--lease-id", type=str, default=None,
759
+ help="Lease ID (heartbeat/release match guard)")
760
+
761
+ # --- session ---
762
+ ssp = sub.add_parser("session", help="Session data plane (Workbench Phase 0)", allow_abbrev=False)
763
+ _add_global_args(ssp, is_subparser=True)
764
+ ssub = ssp.add_subparsers(dest="session_command", title="session commands",
765
+ parser_class=_AiscArgumentParser)
766
+
767
+ # session open
768
+ sso = ssub.add_parser("open", help="Open an interactive agent session", allow_abbrev=False)
769
+ _add_global_args(sso, is_subparser=True)
770
+ sso.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
771
+ sso.add_argument("--session-id", type=str, required=True, help="Session ID (UUID v4)")
772
+ sso.add_argument("--agent", type=str, required=True,
773
+ choices=list(SessionAgent.ALL),
774
+ help="Agent type (claude|codex|bash|cc-switch)")
775
+ sso.add_argument("--workspace", type=str, default=None,
776
+ help="Workspace path (default: current directory)")
777
+ sso.add_argument("--resume-id", type=str, default=None,
778
+ help="Provider conversation ID to resume (claude|codex only, v2.1.8 T4)")
779
+
780
+ # session list
781
+ ssl = ssub.add_parser("list", help="List sessions in a runtime", allow_abbrev=False)
782
+ _add_global_args(ssl, is_subparser=True)
783
+ ssl.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
784
+ ssl.add_argument("--workspace", type=str, default=None,
785
+ help="Workspace path (default: current directory)")
786
+
787
+ # session terminate
788
+ sst = ssub.add_parser("terminate", help="Terminate a session", allow_abbrev=False)
789
+ _add_global_args(sst, is_subparser=True)
790
+ sst.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
791
+ sst.add_argument("--session-id", type=str, required=True, help="Session ID (UUID v4)")
792
+ sst.add_argument("--workspace", type=str, default=None,
793
+ help="Workspace path (default: current directory)")
794
+ sst.add_argument("--grace", type=float, default=5.0,
795
+ help="Grace period in seconds before SIGKILL (default: 5.0)")
796
+
797
+ # --- conversation (v2.1.8 T3) ---
798
+ cvp = sub.add_parser("conversation",
799
+ help="Agent conversation discovery (Workbench v2.1.8)",
800
+ allow_abbrev=False)
801
+ _add_global_args(cvp, is_subparser=True)
802
+ cvsub = cvp.add_subparsers(dest="conversation_command",
803
+ title="conversation commands",
804
+ parser_class=_AiscArgumentParser)
805
+
806
+ # conversation list
807
+ cvl = cvsub.add_parser("list", help="List agent history conversations in a workspace",
808
+ allow_abbrev=False)
809
+ _add_global_args(cvl, is_subparser=True)
810
+ cvl.add_argument("--workspace", type=str, default=None,
811
+ help="Workspace path (default: current directory)")
812
+
813
+ # conversation preflight
814
+ cvp2 = cvsub.add_parser("preflight",
815
+ help="Validate a conversation is resumable (captured, JSON)",
816
+ allow_abbrev=False)
817
+ _add_global_args(cvp2, is_subparser=True)
818
+ cvp2.add_argument("--workspace", type=str, default=None,
819
+ help="Workspace path (default: current directory)")
820
+ cvp2.add_argument("--conversation-id", type=str, required=True,
821
+ help="Provider-native conversation ID (UUID)")
822
+ # No argparse choices: an unsupported agent must surface as
823
+ # AISC_ERR_CONVERSATION_INVALID_AGENT in the JSON envelope (design §2),
824
+ # not as an argparse usage error.
825
+ cvp2.add_argument("--agent", type=str, required=True,
826
+ help="Agent type (claude|codex)")
827
+
828
+ # conversation delete (v2.1.8 T4 手测反馈 #4)
829
+ cvd = cvsub.add_parser("delete", help="Delete a conversation's session file",
830
+ allow_abbrev=False)
831
+ _add_global_args(cvd, is_subparser=True)
832
+ cvd.add_argument("--workspace", type=str, default=None,
833
+ help="Workspace path (default: current directory)")
834
+ cvd.add_argument("--conversation-id", type=str, required=True,
835
+ help="Provider-native conversation ID (UUID)")
836
+ cvd.add_argument("--agent", type=str, required=True,
837
+ help="Agent type (claude|codex)")
838
+
839
+ # conversation rename (v2.1.8 T4 手测反馈 #2): Workbench display title
840
+ cvr = cvsub.add_parser("rename", help="Set a conversation's display title",
841
+ allow_abbrev=False)
842
+ _add_global_args(cvr, is_subparser=True)
843
+ cvr.add_argument("--workspace", type=str, default=None,
844
+ help="Workspace path (default: current directory)")
845
+ cvr.add_argument("--conversation-id", type=str, required=True,
846
+ help="Provider-native conversation ID (UUID)")
847
+ cvr.add_argument("--agent", type=str, required=True,
848
+ help="Agent type (claude|codex)")
849
+ cvr.add_argument("--title", type=str, required=True,
850
+ help="New display title (sanitized, ≤80 chars)")
851
+
852
+ # --- artifact (Stage 3, ART-02) ---
853
+ arp = sub.add_parser("artifact", help="Agent Artifact fact protocol (Stage 3)",
854
+ allow_abbrev=False)
855
+ _add_global_args(arp, is_subparser=True)
856
+ arsub = arp.add_subparsers(dest="artifact_command", title="artifact commands",
857
+ parser_class=_AiscArgumentParser)
858
+
859
+ arrec = arsub.add_parser("record", help="Record an agent artifact fact",
860
+ allow_abbrev=False)
861
+ _add_global_args(arrec, is_subparser=True)
862
+ # v2.1.9 T3a (R2): the Workbench injects AISC_RUNTIME_ID (docker create)
863
+ # and AISC_TERMINAL_SESSION_ID (session wrapper) into every agent
864
+ # process — use them as argparse defaults so agents don't need to know
865
+ # their IDs. Flag still wins when both are present; explicit check in
866
+ # the command layer reports a stable usage error when neither is given
867
+ # (instead of argparse's bare "required" wall of text).
868
+ arrec.add_argument("--runtime-id", type=str,
869
+ default=os.environ.get("AISC_RUNTIME_ID"),
870
+ help="Runtime ID (UUID v4; default: env AISC_RUNTIME_ID)")
871
+ arrec.add_argument("--session-id", type=str,
872
+ default=os.environ.get("AISC_TERMINAL_SESSION_ID"),
873
+ help="Session ID (UUID v4; default: env AISC_TERMINAL_SESSION_ID)")
874
+ arrec.add_argument("--agent", type=str,
875
+ default=os.environ.get("AISC_AGENT"),
876
+ required=os.environ.get("AISC_AGENT") is None,
877
+ choices=["claude", "codex", "bash", "cc-switch"],
878
+ help="Producer agent (default: env AISC_AGENT)")
879
+ arrec.add_argument("--path", type=str, required=True,
880
+ help="Workspace-relative path of the artifact")
881
+ arrec.add_argument("--action", type=str, choices=ArtifactAction.ALL, default="created",
882
+ help="created|modified|deleted|renamed")
883
+ arrec.add_argument("--kind", type=str, choices=ArtifactKind.ALL, default="deliverable",
884
+ help="deliverable|source_change|generated_output")
885
+ arrec.add_argument("--media-type", type=str, default=None,
886
+ help="media type, e.g. text/markdown")
887
+ arrec.add_argument("--label", type=str, default="", help="Human label (<=256 chars)")
888
+ arrec.add_argument("--open-with", type=str, choices=ArtifactOpenWith.ALL,
889
+ default="preview", help="preview|system|reveal|none")
890
+ arrec.add_argument("--previous-path", type=str, default=None,
891
+ help="previous relative path (required for renamed)")
892
+ arrec.add_argument("--workspace", type=str, default=None,
893
+ help="Workspace path (default: current directory)")
894
+
895
+ arlist = arsub.add_parser("list", help="List artifact records", allow_abbrev=False)
896
+ _add_global_args(arlist, is_subparser=True)
897
+ arlist.add_argument("--workspace", type=str, default=None,
898
+ help="Workspace path (default: current directory)")
899
+ arlist.add_argument("--session-id", type=str, default=None,
900
+ help="Filter by session id")
901
+ arlist.add_argument("--kind", type=str, choices=ArtifactKind.ALL, default=None,
902
+ help="Filter by kind")
903
+
904
+ arinsp = arsub.add_parser("inspect", help="Inspect one artifact by id",
905
+ allow_abbrev=False)
906
+ _add_global_args(arinsp, is_subparser=True)
907
+ arinsp.add_argument("--artifact-id", type=str, required=True, help="Artifact ID (UUID)")
908
+ arinsp.add_argument("--workspace", type=str, default=None,
909
+ help="Workspace path (default: current directory)")
910
+
911
+ arclear = arsub.add_parser("clear-session", help="Remove a session's registry",
912
+ allow_abbrev=False)
913
+ _add_global_args(arclear, is_subparser=True)
914
+ arclear.add_argument("--runtime-id", type=str, required=True, help="Runtime ID (UUID v4)")
915
+ arclear.add_argument("--session-id", type=str, required=True, help="Session ID (UUID v4)")
916
+ arclear.add_argument("--workspace", type=str, default=None,
917
+ help="Workspace path (default: current directory)")
918
+
919
+ # --- data-root (Stage 7, 7d) ---
920
+ drp = sub.add_parser("data-root", help="Data root diagnostics and legacy migration",
921
+ allow_abbrev=False)
922
+ _add_global_args(drp, is_subparser=True)
923
+ drsub = drp.add_subparsers(dest="data_root_command", title="data-root commands",
924
+ parser_class=_AiscArgumentParser)
925
+
926
+ drdoc = drsub.add_parser("doctor", help="Resolve + legacy findings + manifest state",
927
+ allow_abbrev=False)
928
+ _add_global_args(drdoc, is_subparser=True)
929
+ drdoc.add_argument("--workspace", type=str, default=None,
930
+ help="Workspace path (default: current directory)")
931
+
932
+ drmig = drsub.add_parser("migrate", help="Migrate legacy layout into the data root",
933
+ allow_abbrev=False)
934
+ _add_global_args(drmig, is_subparser=True)
935
+ drmig.add_argument("--workspace", type=str, default=None,
936
+ help="Workspace path (default: current directory)")
937
+ drmig.add_argument("--dry-run", action="store_true", default=False,
938
+ help="Report the plan without touching anything")
939
+ drmig.add_argument("--apply", action="store_true", default=False,
940
+ help="Execute the migration (default when --dry-run is absent)")
941
+ drmig.add_argument("--quarantine-unknown", action="store_true", default=False,
942
+ help="Move unknown files to the migration quarantine "
943
+ "(explicit consent; sources kept)")
944
+
945
+ drroll = drsub.add_parser("rollback", help="Undo one migration via its manifest",
946
+ allow_abbrev=False)
947
+ _add_global_args(drroll, is_subparser=True)
948
+ drroll.add_argument("--workspace", type=str, default=None,
949
+ help="Workspace path (default: current directory)")
950
+ drroll.add_argument("manifest", nargs="?", default=None,
951
+ help="Manifest path (default: this workspace's manifest)")
952
+
953
+ return parser
954
+
955
+
956
+ # ---------------------------------------------------------------------------
957
+ # JSON format / events detection from raw argv
958
+ # ---------------------------------------------------------------------------
959
+
960
+ def _detect_json_format(argv: List[str]) -> bool:
961
+ for i, arg in enumerate(argv):
962
+ if arg == "--format" and i + 1 < len(argv) and argv[i + 1] == "json":
963
+ return True
964
+ if arg == "--format=json":
965
+ return True
966
+ return False
967
+
968
+
969
+ def _detect_events(argv: List[str]) -> bool:
970
+ return "--events" in argv
971
+
972
+
973
+ def _detect_command(argv: List[str]) -> Optional[str]:
974
+ known = {"version", "doctor", "build", "run", "config", "profile",
975
+ "status", "stop", "restart", "shell", "switch", "provider",
976
+ "cc-switch", "network", "usage", "logs", "ps", "runtime", "session",
977
+ "artifact", "data-root", "maintenance"}
978
+ for arg in argv:
979
+ if arg in known:
980
+ return arg
981
+ return None
982
+
983
+
984
+ def _resolve_aisc_root_from_argv(argv: List[str]) -> Optional[str]:
985
+ """Resolve ``--aisc-root`` from raw *argv* with last-wins semantics.
986
+
987
+ Returns the last ``--aisc-root VALUE`` or ``--aisc-root=VALUE``,
988
+ or ``None`` if the flag is absent.
989
+ """
990
+ last = None
991
+ for i, arg in enumerate(argv):
992
+ if arg == "--aisc-root" and i + 1 < len(argv):
993
+ last = argv[i + 1]
994
+ elif arg.startswith("--aisc-root="):
995
+ last = arg.split("=", 1)[1]
996
+ return last
997
+
998
+
999
+ # ---------------------------------------------------------------------------
1000
+ # Arg post-processing: resolve last-wins --format
1001
+ # ---------------------------------------------------------------------------
1002
+
1003
+ def _resolve_format(args: argparse.Namespace, argv: List[str]) -> str:
1004
+ last = "text"
1005
+ for i, arg in enumerate(argv):
1006
+ if arg == "--format" and i + 1 < len(argv):
1007
+ val = argv[i + 1]
1008
+ if val in ("text", "json"):
1009
+ last = val
1010
+ elif arg.startswith("--format="):
1011
+ val = arg.split("=", 1)[1]
1012
+ if val in ("text", "json"):
1013
+ last = val
1014
+ return last
1015
+
1016
+
1017
+ # ---------------------------------------------------------------------------
1018
+ # Command implementations (existing, unchanged)
1019
+ # ---------------------------------------------------------------------------
1020
+
1021
+ def _cmd_version(args: argparse.Namespace) -> VersionInfo:
1022
+ from aisc.application.version import gather_version_info
1023
+ from aisc.application.resources import _RootSourceError
1024
+ try:
1025
+ info = gather_version_info(explicit_root=args.aisc_root, cwd=Path.cwd())
1026
+ except _RootSourceError as exc:
1027
+ raise CliError(message=str(exc), exit_code=1,
1028
+ error_code="AISC_ERR_GENERAL") from exc
1029
+ except Exception as exc:
1030
+ raise CliError(message=str(exc), exit_code=1,
1031
+ error_code="AISC_ERR_GENERAL") from exc
1032
+ return info
1033
+
1034
+
1035
+ def _cmd_doctor(
1036
+ args: argparse.Namespace,
1037
+ effective_format: str,
1038
+ ) -> Tuple[Dict[str, Any], DoctorReport]:
1039
+ from aisc.application.doctor import run_doctor
1040
+ from aisc.application.resources import locate_aisc_root, _RootSourceError
1041
+ root = None
1042
+ root_error: Optional[str] = None
1043
+ try:
1044
+ root = locate_aisc_root(explicit_root=args.aisc_root)
1045
+ except _RootSourceError as exc:
1046
+ root_error = str(exc)
1047
+ except Exception as exc:
1048
+ root_error = str(exc)
1049
+
1050
+ report = run_doctor(root=root, root_error=root_error)
1051
+
1052
+ # Check if Docker is missing and offer to install (interactive mode only)
1053
+ docker_missing = any(
1054
+ c.name == "docker-cli"
1055
+ and c.status == CheckStatus.FAIL
1056
+ and c.message == "Docker CLI not found"
1057
+ for c in report.checks
1058
+ )
1059
+
1060
+ interactive_text = (
1061
+ effective_format == "text"
1062
+ and sys.stdin.isatty()
1063
+ and sys.stdout.isatty()
1064
+ )
1065
+ if docker_missing and interactive_text:
1066
+ # Interactive mode - offer to install Docker
1067
+ from aisc.application.repair import install_docker_interactive
1068
+
1069
+ print("\n" + "=" * 60)
1070
+ print("Docker is required but not installed.")
1071
+ print("=" * 60)
1072
+
1073
+ success = install_docker_interactive()
1074
+
1075
+ if success:
1076
+ print("\n\nRe-running diagnostics...\n")
1077
+ # Re-run doctor to check if Docker is now available
1078
+ report = run_doctor(root=root, root_error=root_error)
1079
+
1080
+ data: Dict[str, Any] = {"host": report.to_dict(), "container": None}
1081
+ return data, report
1082
+
1083
+
1084
+ def _cmd_profile(
1085
+ args: argparse.Namespace,
1086
+ effective_format: str,
1087
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1088
+ """Dispatch profile list/show."""
1089
+ from aisc.cli.commands.profile import cmd_profile_list, cmd_profile_show
1090
+
1091
+ sub = getattr(args, "profile_command", None)
1092
+ if sub not in ("list", "show"):
1093
+ if effective_format == "json":
1094
+ emit_json_usage_error(
1095
+ command="profile", version=__version__,
1096
+ message="Unknown profile subcommand",
1097
+ )
1098
+ else:
1099
+ print("Error: Unknown profile subcommand", file=sys.stderr)
1100
+ sys.exit(2)
1101
+
1102
+ if sub == "list":
1103
+ result = cmd_profile_list()
1104
+ else:
1105
+ # show — optional NAME defaults to "safe"
1106
+ name = getattr(args, "name", None) or "safe"
1107
+ result = cmd_profile_show(name)
1108
+
1109
+ errors: List[Dict[str, Any]] = []
1110
+ if result.exit_code != 0:
1111
+ errors.append(build_error(result.error_code or "AISC_ERR_GENERAL",
1112
+ result.error_message))
1113
+
1114
+ return result.data, result.exit_code, errors
1115
+
1116
+
1117
+
1118
+
1119
+ # ---------------------------------------------------------------------------
1120
+ # Build / Run command dispatch — all docker through injected executor
1121
+ # ---------------------------------------------------------------------------
1122
+
1123
+ def _write_cc_switch_manifest(root: Path, resolved: Any) -> Optional[Path]:
1124
+ """Write the resolution receipt next to the build outputs (Stage 8b).
1125
+
1126
+ Best-effort: an unwritable cache warns and builds on (the receipt also
1127
+ lives in the image labels + BuildResult). Patched in tests to avoid
1128
+ touching the real filesystem.
1129
+
1130
+ Location: the shared data-root cache (NOT the aisc bundle dir — a file
1131
+ created inside the installed bundle survives the uninstaller's file list
1132
+ and trips the NSIS clean-uninstall gate; resolver artifacts belong with
1133
+ the resolver's metadata cache anyway)."""
1134
+ from aisc.application.data_root import shared_root
1135
+
1136
+ manifest_path = shared_root() / "cache" / "cc-switch" / "last-resolved.json"
1137
+ try:
1138
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
1139
+ from aisc.application.cc_switch_resolver import resolved_at_now
1140
+
1141
+ manifest_path.write_text(
1142
+ json.dumps(resolved.to_manifest(resolved_at=resolved_at_now()), indent=2),
1143
+ encoding="utf-8",
1144
+ )
1145
+ return manifest_path
1146
+ except OSError as exc:
1147
+ sys.stderr.write(f"⚠️ could not write cc-switch manifest ({exc}); "
1148
+ f"build continues\n")
1149
+ return None
1150
+
1151
+
1152
+ def _cmd_build(
1153
+ args: argparse.Namespace,
1154
+ emitter: Optional[JsonlEmitter],
1155
+ effective_format: str,
1156
+ ) -> Tuple[Optional[Dict[str, Any]], int, List[Dict[str, Any]]]:
1157
+ """Execute ``aisc build``. Returns (data, exit_code, errors)."""
1158
+ from aisc.cli.commands.build import plan_build, run_build, BuildResult
1159
+ from aisc.application.resources import locate_aisc_root, _RootSourceError
1160
+
1161
+ root = None
1162
+ try:
1163
+ root = locate_aisc_root(explicit_root=args.aisc_root)
1164
+ except _RootSourceError as exc:
1165
+ raise CliError(message=str(exc), exit_code=1,
1166
+ error_code="AISC_ERR_GENERAL") from exc
1167
+ if root is None:
1168
+ # A3 (guide 3.3.4): the pip install form has no repo and no frozen
1169
+ # bundle — its build needs `aisc bundle fetch` first. Keep the
1170
+ # source/frozen wording intact (repo users still get their hint).
1171
+ import aisc as _aisc_pkg
1172
+
1173
+ pip_form = (
1174
+ not getattr(sys, "frozen", False)
1175
+ and _aisc_pkg.__file__ is not None
1176
+ and "site-packages" in str(Path(_aisc_pkg.__file__).resolve())
1177
+ )
1178
+ if pip_form:
1179
+ raise CliError(
1180
+ message="AISC root not found: a pip/pipx install ships no build "
1181
+ "resources. Run `aisc bundle fetch` to download this "
1182
+ "version's bundle, or point --aisc-root at an extracted "
1183
+ "bundle directory.",
1184
+ exit_code=1, error_code="AISC_ERR_GENERAL",
1185
+ )
1186
+ raise CliError(
1187
+ message="AISC root not found. Use --aisc-root to specify a path, "
1188
+ "or run from within an AISC repository.",
1189
+ exit_code=1, error_code="AISC_ERR_GENERAL",
1190
+ )
1191
+
1192
+ # v2.1.7 hotfix (2026-08-28 field report): failures BEFORE the event
1193
+ # stream starts (root location, cc-switch resolution — a network-blocked
1194
+ # machine fails here in seconds) used to print a bare error envelope to
1195
+ # stdout. The Workbench stream parser saw no terminal event and showed a
1196
+ # generic "AISC CLI 返回错误" with zero diagnosis. In --events mode a
1197
+ # pre-run CliError now emits a TERMINAL build.failed carrying the real
1198
+ # code + message, then re-raises for the normal exit path.
1199
+ def _emit_prerun_failure(exc: CliError) -> None:
1200
+ if emitter is None:
1201
+ return
1202
+ emitter.emit_terminal("build.failed", exc.exit_code, extra_data={
1203
+ "image_tag": getattr(args, "tag", "super-claude:latest"),
1204
+ "docker_exit_code": None,
1205
+ "error_code": exc.error_code,
1206
+ "message": exc.message,
1207
+ "phase": "prerun",
1208
+ })
1209
+
1210
+ def _warn_unpinned_build(exc: ResolveError, emitter_: Optional[JsonlEmitter]) -> None:
1211
+ """S8b: announce the degraded unpinned build (fail-visible, never
1212
+ silent) — stderr for text mode, a build.warning event for the
1213
+ Workbench stream (additive event type; older frontends ignore it)."""
1214
+ warning = (
1215
+ "cc-switch 版本解析失败(网络受限):本次构建不带解析钉版,"
1216
+ "Dockerfile 将从国内镜像源下载 cc-switch(无 sha256 校验,"
1217
+ "不保证可复现)。配好网络后重试可恢复钉版;--require-pin 可将"
1218
+ "此情况设为致命错误。原因: " + exc.message
1219
+ )
1220
+ sys.stderr.write(f"⚠️ {warning}\n")
1221
+ sys.stderr.flush()
1222
+ if emitter_ is not None:
1223
+ emitter_.emit("build.warning", data={"message": warning})
1224
+
1225
+
1226
+ # Run wizard if in interactive text mode and no explicit flags provided
1227
+ tag = getattr(args, "tag", "super-claude:latest")
1228
+ no_cache = getattr(args, "no_cache", False)
1229
+ pull = getattr(args, "pull", False)
1230
+ dry_run = getattr(args, "dry_run", False)
1231
+
1232
+ # Check if user provided explicit build options
1233
+ tag_provided = any(arg in sys.argv for arg in ["--tag", "-t"])
1234
+ cache_provided = "--no-cache" in sys.argv
1235
+ pull_provided = "--pull" in sys.argv
1236
+
1237
+ # Run wizard if: text mode + interactive + no explicit options
1238
+ if (effective_format == "text" and emitter is None and
1239
+ not dry_run and not tag_provided and not cache_provided and not pull_provided):
1240
+ from aisc.cli.commands.wizard import run_build_wizard
1241
+ try:
1242
+ tag, no_cache, pull, proxy_config = run_build_wizard(
1243
+ default_tag=tag, aisc_root=root
1244
+ )
1245
+ except KeyboardInterrupt:
1246
+ raise CliError(message="Build cancelled by user", exit_code=130,
1247
+ error_code="AISC_ERR_CANCELLED")
1248
+
1249
+ # Stage 8b: resolve the cc-switch release BEFORE planning (D8-02 — the
1250
+ # Dockerfile only consumes resolved facts, never resolves on its own).
1251
+ import platform as _platform
1252
+ from aisc.domain.cc_switch_release import (
1253
+ CC_SWITCH_ERROR_NETWORK,
1254
+ CC_SWITCH_ERROR_RATE_LIMITED,
1255
+ ResolveError,
1256
+ normalize_arch,
1257
+ )
1258
+ from aisc.application.cc_switch_resolver import CcSwitchResolver
1259
+
1260
+ cs_version = getattr(args, "cc_switch_version", None) or os.environ.get("CC_SWITCH_VERSION", "latest")
1261
+ cs_channel = getattr(args, "cc_switch_channel", None) or os.environ.get("CC_SWITCH_CHANNEL", "stable")
1262
+ cs_manifest = getattr(args, "cc_switch_manifest", None)
1263
+ require_pin = bool(getattr(args, "require_pin", False)) or (
1264
+ os.environ.get("AISC_REQUIRE_PIN", "").strip().lower() not in ("", "0", "false")
1265
+ )
1266
+ try:
1267
+ cs_arch = normalize_arch(_platform.machine())
1268
+ except ResolveError:
1269
+ cs_arch = "x64" # validated again inside the Dockerfile (arch assert)
1270
+ resolver = CcSwitchResolver()
1271
+ resolved: Any = None
1272
+ try:
1273
+ resolved = resolver.resolve(
1274
+ channel=cs_channel,
1275
+ version=cs_version,
1276
+ arch=cs_arch,
1277
+ libc="musl",
1278
+ manifest_path=Path(cs_manifest) if cs_manifest else None,
1279
+ )
1280
+ except ResolveError as exc:
1281
+ # S8b (2026-08-28 user ruling): a NETWORK-SHAPED failure degrades to
1282
+ # an UNPINNED build instead of a fatal error — the Dockerfile's
1283
+ # no-pin branch downloads cc-switch from CN mirrors (unverified,
1284
+ # explicitly warned), the same semantics docker_rebuild has had
1285
+ # since v2.1.6. Everything else (bad manifest, version not found on
1286
+ # a LIVE api, invalid channel) and --require-pin stay fail-closed.
1287
+ unpinned = (
1288
+ exc.code in (CC_SWITCH_ERROR_NETWORK, CC_SWITCH_ERROR_RATE_LIMITED)
1289
+ and not require_pin
1290
+ )
1291
+ cli_error = CliError(
1292
+ message=f"cc-switch release resolution failed: {exc.message}",
1293
+ exit_code=1, error_code=exc.code,
1294
+ )
1295
+ if not unpinned:
1296
+ _emit_prerun_failure(cli_error)
1297
+ raise cli_error from exc
1298
+ _warn_unpinned_build(exc, emitter)
1299
+
1300
+ # Reproducibility receipt: always written next to the build outputs.
1301
+ manifest_path = (
1302
+ _write_cc_switch_manifest(root, resolved) if resolved is not None else None
1303
+ )
1304
+
1305
+ if resolved is not None and effective_format == "text" and emitter is None:
1306
+ sys.stderr.write(
1307
+ f"cc-switch: {resolved.tag} ({resolved.asset_name}, "
1308
+ f"sha256 {resolved.asset_sha256[:12]}…, source {resolved.source})\n"
1309
+ )
1310
+ sys.stderr.flush()
1311
+
1312
+ plan = plan_build(
1313
+ root=root,
1314
+ tag=tag,
1315
+ no_cache=no_cache,
1316
+ pull=pull,
1317
+ dry_run=dry_run,
1318
+ cc_switch=resolved,
1319
+ )
1320
+
1321
+ # text mode → streaming (real-time build log); json/events → captured
1322
+ is_streaming = (effective_format == "text" and emitter is None)
1323
+ result = run_build(
1324
+ plan,
1325
+ emitter=emitter,
1326
+ streaming=is_streaming,
1327
+ cc_switch_summary=resolved.to_manifest() if resolved is not None else None,
1328
+ cc_switch_manifest_path=str(manifest_path) if manifest_path else "",
1329
+ )
1330
+ return result.to_dict(), 0, []
1331
+
1332
+
1333
+ def _cmd_run(
1334
+ args: argparse.Namespace,
1335
+ emitter: Optional[JsonlEmitter],
1336
+ effective_format: str,
1337
+ aisc_root_arg: Optional[str],
1338
+ ) -> Tuple[Optional[Dict[str, Any]], int, List[Dict[str, Any]]]:
1339
+ """Execute ``aisc run`` — F2-C activation semantics: detached start,
1340
+ registry default pointer, history record, activation summary."""
1341
+ from aisc.cli.commands.run import plan_run, run_container
1342
+ from aisc.cli.commands import runs as cli_runs
1343
+ from aisc.application.resources import locate_aisc_root, _RootSourceError
1344
+
1345
+ aisc_root = None
1346
+ try:
1347
+ aisc_root = locate_aisc_root(explicit_root=aisc_root_arg)
1348
+ except _RootSourceError as exc:
1349
+ raise CliError(message=str(exc), exit_code=1,
1350
+ error_code="AISC_ERR_GENERAL") from exc
1351
+
1352
+ # --resume: adopt the record's config; explicit flags win
1353
+ resume = getattr(args, "resume", None)
1354
+ rec = None
1355
+ if resume:
1356
+ rec = cli_runs.require_resume(resume)
1357
+
1358
+ path = getattr(args, "path", None)
1359
+ if not path:
1360
+ if rec:
1361
+ path = rec["path"]
1362
+ else:
1363
+ raise CliError(
1364
+ message="aisc run needs a workspace path (e.g. `aisc run ./`) — "
1365
+ "or --resume N|PATH from `aisc runs`",
1366
+ exit_code=2, error_code="AISC_ERR_USAGE",
1367
+ )
1368
+ ws = Path(path).expanduser()
1369
+
1370
+ profile = getattr(args, "profile", None)
1371
+ network = getattr(args, "network", argparse.SUPPRESS)
1372
+ network_explicit = network is not argparse.SUPPRESS
1373
+ if network is argparse.SUPPRESS:
1374
+ network = (rec or {}).get("network", "direct") if rec else "direct"
1375
+ network_explicit = False
1376
+ if profile == "proxy":
1377
+ if network_explicit and network == "direct":
1378
+ raise CliError(
1379
+ message="--profile proxy conflicts with --network direct",
1380
+ exit_code=2, error_code="AISC_ERR_USAGE",
1381
+ )
1382
+ network = "proxy"
1383
+
1384
+ image = getattr(args, "image", "super-claude:latest")
1385
+ if rec and image == "super-claude:latest" and rec.get("image"):
1386
+ image = rec["image"]
1387
+ label = getattr(args, "label", "")
1388
+ if rec and not label and rec.get("label"):
1389
+ label = rec["label"]
1390
+
1391
+ raw_name = getattr(args, "name", "super-claude-station") or "super-claude-station"
1392
+ if rec and raw_name == "super-claude-station" and rec.get("alias"):
1393
+ raw_name = rec["alias"] # resume keeps the alias-named containers
1394
+ plan = plan_run(
1395
+ image=image,
1396
+ workspace=str(ws),
1397
+ name=raw_name,
1398
+ network=network,
1399
+ dry_run=getattr(args, "dry_run", False),
1400
+ interactive=False, # F2-C: activation is always detached (-d)
1401
+ non_interactive=False,
1402
+ label=label,
1403
+ keep_alive=True, # F2-C: no --rm — the workspace STAYS activated
1404
+ aisc_root=aisc_root,
1405
+ )
1406
+
1407
+ result = run_container(plan, emitter=emitter, capture=True,
1408
+ aisc_root=aisc_root)
1409
+
1410
+ if not plan.dry_run:
1411
+ abs_ws = str(Path(plan.workspace).resolve())
1412
+ alias = "" if raw_name == "super-claude-station" else raw_name
1413
+ if rec and not alias and rec.get("alias"):
1414
+ alias = rec["alias"] # --resume without a fresh --name keeps it
1415
+ cli_runs.record(abs_ws, plan.image, plan.network, plan.label, alias)
1416
+ cli_runs.set_active(abs_ws)
1417
+
1418
+ out = result.to_dict()
1419
+ out["workspace"] = plan.workspace
1420
+ out["commands"] = [
1421
+ "aisc claude", "aisc codex", "aisc switch", "aisc shell",
1422
+ "aisc status", "aisc stop", "aisc runs",
1423
+ ]
1424
+
1425
+ if effective_format == "text" and emitter is None:
1426
+ if out.get("reused"):
1427
+ print(f"ℹ 工作区已在运行中: {plan.workspace}")
1428
+ print(f" 容器 {out['reused']}(Up) · 复用现有容器,未新建")
1429
+ print(" 进入方式: aisc claude | aisc codex | aisc switch | aisc shell")
1430
+ print(" 如需重建: 先 aisc stop 再重新 aisc run")
1431
+ return out, 0, []
1432
+ print(f"💯 工作区已激活: {plan.workspace}")
1433
+ print(f" 容器 {plan.name}(detached) · 镜像 {plan.image} · 网络 {plan.network}")
1434
+ if out.get("replaced"):
1435
+ names = ", ".join(out["replaced"])
1436
+ print(f" ♻ 已替换同工作区旧容器: {names}")
1437
+ if plan.web_gateway_host_port and not plan.dry_run:
1438
+ print(f" 🌐 Web 服务网关: http://p<端口>.localhost:{plan.web_gateway_host_port}/ "
1439
+ f"(容器内: aisc-web-expose <端口>)")
1440
+ print(" 进入方式: aisc claude | aisc codex | aisc switch | aisc shell")
1441
+ print(" 停止: aisc stop · 历史: aisc runs")
1442
+
1443
+ return out, 0, []
1444
+
1445
+
1446
+ def _cmd_agent(
1447
+ args: argparse.Namespace,
1448
+ effective_format: str,
1449
+ ) -> Tuple[Optional[Dict[str, Any]], int, List[Dict[str, Any]]]:
1450
+ """Execute ``aisc claude`` / ``aisc codex`` (F2-C agent sugar)."""
1451
+ from aisc.cli.commands.agents import cmd_agent
1452
+
1453
+ rest = [a for a in (getattr(args, "rest", None) or [])]
1454
+ if rest and rest[0] == "--":
1455
+ rest = rest[1:]
1456
+
1457
+ capture = effective_format != "text"
1458
+ outcome = cmd_agent(
1459
+ str(args.command),
1460
+ rest,
1461
+ workspace=getattr(args, "workspace", None),
1462
+ name_override=getattr(args, "name", None),
1463
+ explicit_root=getattr(args, "aisc_root", None),
1464
+ capture=capture,
1465
+ )
1466
+ if capture:
1467
+ return dict(outcome), int(outcome.get("exit_code", 0) or 0), []
1468
+ # Text mode: the TTY was live; report the exit like shell does.
1469
+ return {"container": None, "agent": str(args.command), "args": rest,
1470
+ "exit_code": outcome.exit_code}, 0, []
1471
+
1472
+
1473
+ def _cmd_runs(
1474
+ args: argparse.Namespace,
1475
+ effective_format: str,
1476
+ ) -> Tuple[Optional[Dict[str, Any]], int, List[Dict[str, Any]]]:
1477
+ """Execute ``aisc runs`` — the CLI activation history."""
1478
+ from aisc.cli.commands import runs as cli_runs
1479
+
1480
+ items = cli_runs.list_runs()
1481
+ if effective_format == "text":
1482
+ if not items:
1483
+ print("暂无激活历史 — aisc run <路径> 开始")
1484
+ for i, r in enumerate(items, 1):
1485
+ alias = f" @{r['alias']}" if r.get("alias") else ""
1486
+ print(f" {i}. {r.get('path')} [{r.get('image', '')} · {r.get('network', '')}]"
1487
+ f" {r.get('last_used_at', '')}{alias}")
1488
+ if items:
1489
+ print("恢复: aisc run --resume <别名|序号|路径>(别名最稳,序号随列表变动)")
1490
+ return None, 0, []
1491
+ return {"runs": items}, 0, []
1492
+
1493
+
1494
+ def _cmd_workspaces(
1495
+ args: argparse.Namespace,
1496
+ effective_format: str,
1497
+ ) -> Tuple[Optional[Dict[str, Any]], int, List[Dict[str, Any]]]:
1498
+ """Execute ``aisc workspaces`` — the machine-wide management view."""
1499
+ from aisc.cli.commands.workspaces import (
1500
+ cmd_workspaces, cmd_workspaces_stop, print_workspaces_text,
1501
+ )
1502
+
1503
+ if getattr(args, "stop", False):
1504
+ out = cmd_workspaces_stop()
1505
+ if effective_format == "text":
1506
+ def _short(s):
1507
+ if s.get("alias"):
1508
+ return s["alias"]
1509
+ ws = s.get("workspace", "")
1510
+ return Path(ws).name or ws
1511
+ names = ", ".join(_short(s) for s in out["stopped"]) or "无"
1512
+ print(f"已批量停止 {len(out['stopped'])} 个运行中的工作区: {names}")
1513
+ return out, 0, []
1514
+
1515
+ rows = cmd_workspaces()
1516
+ if effective_format == "text":
1517
+ print_workspaces_text(rows)
1518
+ return {"workspaces": rows}, 0, []
1519
+
1520
+
1521
+ def _cmd_config(
1522
+ args: argparse.Namespace,
1523
+ effective_format: str,
1524
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1525
+ """Dispatch config validate/effective. ServiceResult owns exit codes."""
1526
+ from aisc.cli.commands.config import (
1527
+ cmd_config_validate, cmd_config_effective,
1528
+ )
1529
+
1530
+ sub = getattr(args, "config_command", None)
1531
+ if sub not in ("validate", "effective", "show"):
1532
+ if effective_format == "json":
1533
+ emit_json_usage_error(
1534
+ command="config", version=__version__,
1535
+ message="Unknown config subcommand",
1536
+ )
1537
+ else:
1538
+ print("Error: Unknown config subcommand", file=sys.stderr)
1539
+ sys.exit(2)
1540
+
1541
+ explicit_config = getattr(args, "config", None)
1542
+ workspace = getattr(args, "workspace", None)
1543
+
1544
+ if sub == "validate":
1545
+ result = cmd_config_validate(
1546
+ explicit_config=explicit_config, workspace=workspace,
1547
+ )
1548
+ else:
1549
+ # effective / show — same handler
1550
+ result = cmd_config_effective(
1551
+ explicit_config=explicit_config, workspace=workspace,
1552
+ )
1553
+
1554
+ # Build errors list — exactly one top-level error on failure
1555
+ errors: List[Dict[str, Any]] = []
1556
+ if result.exit_code != 0:
1557
+ errors.append(build_error(result.error_code or "AISC_ERR_GENERAL",
1558
+ result.error_message))
1559
+
1560
+ return result.data, result.exit_code, errors
1561
+
1562
+
1563
+ # ---------------------------------------------------------------------------
1564
+ # Container lifecycle command handlers
1565
+ # ---------------------------------------------------------------------------
1566
+
1567
+ def _cmd_status(
1568
+ args: argparse.Namespace,
1569
+ effective_format: str,
1570
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1571
+ """Execute ``aisc status``. Supports --format json."""
1572
+ from aisc.cli.commands.container import cmd_status
1573
+
1574
+ result = cmd_status(
1575
+ name_override=getattr(args, "name", None),
1576
+ explicit_root=getattr(args, "aisc_root", None),
1577
+ label_override=getattr(args, "label", None),
1578
+ )
1579
+ return result.to_dict(), 0, []
1580
+
1581
+
1582
+ def _cmd_stop(
1583
+ args: argparse.Namespace,
1584
+ effective_format: str,
1585
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1586
+ """Execute ``aisc stop`` (F2-C: stop+remove; --all sweeps CLI-owned)."""
1587
+ from aisc.cli.commands.container import cmd_stop, cmd_stop_all
1588
+
1589
+ if getattr(args, "all", False):
1590
+ data = cmd_stop_all(explicit_root=getattr(args, "aisc_root", None))
1591
+ if effective_format == "text":
1592
+ names = ", ".join(s["name"] for s in data.get("stopped", [])) or "无"
1593
+ print(f"已停止并移除: {names}(跳过 Workbench 管理的 {data.get('skipped', 0)} 个)")
1594
+ return data, 0, []
1595
+ data = cmd_stop(
1596
+ name_override=getattr(args, "name", None),
1597
+ explicit_root=getattr(args, "aisc_root", None),
1598
+ label_override=getattr(args, "label", None),
1599
+ )
1600
+ return data, 0, []
1601
+
1602
+
1603
+ def _cmd_restart(
1604
+ args: argparse.Namespace,
1605
+ effective_format: str,
1606
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1607
+ """Execute ``aisc restart``. Supports --format json."""
1608
+ from aisc.cli.commands.container import cmd_restart
1609
+
1610
+ data = cmd_restart(
1611
+ name_override=getattr(args, "name", None),
1612
+ explicit_root=getattr(args, "aisc_root", None),
1613
+ label_override=getattr(args, "label", None),
1614
+ )
1615
+ return data, 0, []
1616
+
1617
+
1618
+ def _cmd_shell(
1619
+ args: argparse.Namespace,
1620
+ effective_format: str,
1621
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1622
+ """Execute ``aisc shell``. Text-only interactive."""
1623
+ from aisc.cli.commands.container import cmd_shell, discover_container
1624
+
1625
+ if effective_format == "json":
1626
+ emit_json_usage_error(
1627
+ command="shell", version=__version__,
1628
+ message="shell 仅支持 text 输出——不支持 --format json",
1629
+ )
1630
+ sys.exit(2)
1631
+
1632
+ # Discover name once for use in returned data
1633
+ name_override = getattr(args, "name", None)
1634
+ label_override = getattr(args, "label", None)
1635
+ try:
1636
+ discovered_name = name_override or discover_container(
1637
+ name_override=None,
1638
+ explicit_root=getattr(args, "aisc_root", None),
1639
+ label_override=label_override,
1640
+ )
1641
+ except Exception:
1642
+ discovered_name = name_override or ""
1643
+
1644
+ proc = cmd_shell(
1645
+ name_override=name_override,
1646
+ explicit_root=getattr(args, "aisc_root", None),
1647
+ label_override=label_override,
1648
+ rest=getattr(args, "rest", None) or [],
1649
+ )
1650
+
1651
+ exit_code = proc.exit_code if proc.exit_code >= 0 else 1
1652
+ errors: List[Dict[str, Any]] = []
1653
+ if exit_code != 0:
1654
+ errors.append(build_error(
1655
+ "AISC_ERR_GENERAL",
1656
+ proc.stderr or f"docker exec exited with code {exit_code}",
1657
+ ))
1658
+
1659
+ return {"name": discovered_name, "exit_code": exit_code}, exit_code, errors
1660
+
1661
+
1662
+ def _cmd_switch(
1663
+ args: argparse.Namespace,
1664
+ effective_format: str,
1665
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1666
+ """Execute ``aisc switch``. Text-only interactive."""
1667
+ from aisc.cli.commands.container import cmd_switch, discover_container
1668
+
1669
+ if effective_format == "json":
1670
+ emit_json_usage_error(
1671
+ command="switch", version=__version__,
1672
+ message="switch 仅支持 text 输出——不支持 --format json",
1673
+ )
1674
+ sys.exit(2)
1675
+
1676
+ # Discover name once for use in returned data
1677
+ name_override = getattr(args, "name", None)
1678
+ label_override = getattr(args, "label", None)
1679
+ try:
1680
+ discovered_name = name_override or discover_container(
1681
+ name_override=None,
1682
+ explicit_root=getattr(args, "aisc_root", None),
1683
+ label_override=label_override,
1684
+ )
1685
+ except Exception:
1686
+ discovered_name = name_override or ""
1687
+
1688
+ quick = getattr(args, "quick", None)
1689
+
1690
+ proc = cmd_switch(
1691
+ name_override=name_override,
1692
+ explicit_root=getattr(args, "aisc_root", None),
1693
+ quick=quick,
1694
+ label_override=label_override,
1695
+ )
1696
+
1697
+ exit_code = proc.exit_code if proc.exit_code >= 0 else 1
1698
+ errors: List[Dict[str, Any]] = []
1699
+ if exit_code != 0:
1700
+ errors.append(build_error(
1701
+ "AISC_ERR_GENERAL",
1702
+ proc.stderr or f"docker exec exited with code {exit_code}",
1703
+ ))
1704
+
1705
+ data: Dict[str, Any] = {
1706
+ "name": discovered_name,
1707
+ "exit_code": exit_code,
1708
+ }
1709
+ if quick:
1710
+ data["provider"] = quick
1711
+ data["quick"] = True
1712
+ return data, exit_code, errors
1713
+
1714
+
1715
+ def _cmd_cc_switch(
1716
+ args: argparse.Namespace,
1717
+ effective_format: str,
1718
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1719
+ """Execute ``aisc cc-switch`` subcommands (Stage 8d data plane).
1720
+
1721
+ All four ops are non-interactive and support ``--format json``; secrets
1722
+ ride the stdin request document, never argv.
1723
+ """
1724
+ from aisc.cli.commands import cc_switch as cs_cmd
1725
+
1726
+ sub = getattr(args, "cc_switch_command", None)
1727
+ if sub == "list":
1728
+ data = cs_cmd.cmd_cc_switch_list(args)
1729
+ elif sub == "add":
1730
+ data = cs_cmd.cmd_cc_switch_add(args)
1731
+ elif sub == "edit":
1732
+ data = cs_cmd.cmd_cc_switch_edit(args)
1733
+ elif sub == "switch":
1734
+ data = cs_cmd.cmd_cc_switch_switch(args)
1735
+ elif sub == "delete":
1736
+ data = cs_cmd.cmd_cc_switch_delete(args)
1737
+ elif sub == "fetch-models":
1738
+ data = cs_cmd.cmd_cc_switch_fetch_models(args)
1739
+ else:
1740
+ raise CliError(message="unknown cc-switch subcommand",
1741
+ exit_code=2, error_code="AISC_ERR_USAGE")
1742
+
1743
+ if effective_format == "text":
1744
+ cs_cmd.print_cc_switch_text(data)
1745
+ return data, 0, []
1746
+
1747
+
1748
+ def _cmd_network(
1749
+ args: argparse.Namespace,
1750
+ effective_format: str,
1751
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1752
+ """Execute ``aisc network`` subcommands (IDEA-2 subscription data plane).
1753
+
1754
+ Non-interactive, ``--format json`` throughout; the subscription URL rides
1755
+ stdin for ``import`` (secrets never ride argv).
1756
+ """
1757
+ from aisc.cli.commands import network as nw_cmd
1758
+
1759
+ if getattr(args, "network_command", None) != "subscription":
1760
+ raise CliError(message="unknown network subcommand",
1761
+ exit_code=2, error_code="AISC_ERR_USAGE")
1762
+
1763
+ sub = getattr(args, "subscription_command", None)
1764
+ if sub == "import":
1765
+ data = nw_cmd.cmd_network_subscription_import(args)
1766
+ elif sub == "import-file":
1767
+ data = nw_cmd.cmd_network_subscription_import_file(args)
1768
+ elif sub == "store-downloaded":
1769
+ data = nw_cmd.cmd_network_subscription_store_downloaded(args)
1770
+ elif sub == "refresh":
1771
+ data = nw_cmd.cmd_network_subscription_refresh(args)
1772
+ elif sub == "show":
1773
+ data = nw_cmd.cmd_network_subscription_show(args)
1774
+ elif sub == "clear":
1775
+ data = nw_cmd.cmd_network_subscription_clear(args)
1776
+ else:
1777
+ raise CliError(message="unknown network subscription subcommand",
1778
+ exit_code=2, error_code="AISC_ERR_USAGE")
1779
+
1780
+ if effective_format == "text":
1781
+ nw_cmd.print_network_subscription_text(data)
1782
+ return data, 0, []
1783
+
1784
+
1785
+ def _cmd_usage(
1786
+ args: argparse.Namespace,
1787
+ effective_format: str,
1788
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1789
+ """Execute ``aisc usage`` subcommands (IDEA-2 usage data plane)."""
1790
+ from aisc.cli.commands import usage as usage_cmd
1791
+
1792
+ if getattr(args, "usage_command", None) != "overview":
1793
+ raise CliError(message="unknown usage subcommand",
1794
+ exit_code=2, error_code="AISC_ERR_USAGE")
1795
+ data = usage_cmd.cmd_usage_overview(args)
1796
+ if effective_format == "text":
1797
+ usage_cmd.print_usage_overview_text(data)
1798
+ return data, 0, []
1799
+
1800
+
1801
+ def _cmd_logs(
1802
+ args: argparse.Namespace,
1803
+ effective_format: str,
1804
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1805
+ """Execute ``aisc logs`` subcommands (lifecycle-logging P2)."""
1806
+ from aisc.cli.commands import logs as logs_cmd
1807
+
1808
+ sub = getattr(args, "logs_command", None)
1809
+ if sub == "show":
1810
+ data = logs_cmd.cmd_logs_show(args)
1811
+ is_show = True
1812
+ elif sub == "path":
1813
+ data = logs_cmd.cmd_logs_path(args)
1814
+ is_show = False
1815
+ else:
1816
+ raise CliError(message="unknown logs subcommand",
1817
+ exit_code=2, error_code="AISC_ERR_USAGE")
1818
+ if effective_format == "text":
1819
+ logs_cmd.print_logs_text(data, is_show=is_show)
1820
+ return data, 0, []
1821
+
1822
+
1823
+ def _cmd_provider(
1824
+ args: argparse.Namespace,
1825
+ effective_format: str,
1826
+ ) -> Tuple[Dict[str, Any], int, List[Dict[str, Any]]]:
1827
+ """Execute ``aisc provider`` subcommands.
1828
+
1829
+ ``current`` is non-interactive and supports ``--format json`` (S0.4).
1830
+ ``set-key`` is text-only interactive.
1831
+ """
1832
+ sub = getattr(args, "provider_command", None)
1833
+
1834
+ if sub == "current":
1835
+ from aisc.cli.commands.provider import cmd_provider_current
1836
+ data = cmd_provider_current(
1837
+ runtime_id=args.runtime_id,
1838
+ agent=args.agent,
1839
+ workspace=args.workspace,
1840
+ )
1841
+ return data, 0, []
1842
+
1843
+ # All other provider subcommands are text-only interactive.
1844
+ if effective_format == "json":
1845
+ emit_json_usage_error(
1846
+ command="provider", version=__version__,
1847
+ message="provider set-key 仅支持 text 输出——不支持 --format json",
1848
+ )
1849
+ sys.exit(2)
1850
+
1851
+ from aisc.cli.commands.container import cmd_provider_set_key, discover_container
1852
+
1853
+ if sub != "set-key":
1854
+ print("Error: Unknown provider subcommand. Use 'aisc provider set-key' "
1855
+ "or 'aisc provider current'.", file=sys.stderr)
1856
+ sys.exit(2)
1857
+
1858
+ # Discover name once for use in returned data
1859
+ name_override = getattr(args, "name", None)
1860
+ label_override = getattr(args, "label", None)
1861
+ try:
1862
+ discovered_name = name_override or discover_container(
1863
+ name_override=None,
1864
+ explicit_root=getattr(args, "aisc_root", None),
1865
+ label_override=label_override,
1866
+ )
1867
+ except Exception:
1868
+ discovered_name = name_override or ""
1869
+
1870
+ provider_id = getattr(args, "provider_id", None)
1871
+ agent = getattr(args, "agent", "claude")
1872
+
1873
+ proc = cmd_provider_set_key(
1874
+ name_override=name_override,
1875
+ explicit_root=getattr(args, "aisc_root", None),
1876
+ provider_id=provider_id,
1877
+ agent=agent,
1878
+ label_override=label_override,
1879
+ )
1880
+
1881
+ exit_code = proc.exit_code if proc.exit_code >= 0 else 1
1882
+ errors: List[Dict[str, Any]] = []
1883
+ if exit_code != 0:
1884
+ errors.append(build_error(
1885
+ "AISC_ERR_GENERAL",
1886
+ proc.stderr or f"docker exec exited with code {exit_code}",
1887
+ ))
1888
+
1889
+ data: Dict[str, Any] = {
1890
+ "name": discovered_name,
1891
+ "exit_code": exit_code,
1892
+ "provider_id": provider_id,
1893
+ "agent": agent,
1894
+ }
1895
+ return data, exit_code, errors
1896
+
1897
+
1898
+ def _cmd_ps(
1899
+ args: argparse.Namespace,
1900
+ effective_format: str,
1901
+ ) -> Tuple[List[Dict[str, Any]], int, List[Dict[str, Any]]]:
1902
+ """Execute ``aisc ps``. Supports --format json."""
1903
+ from aisc.cli.commands.container import cmd_ps
1904
+
1905
+ rows = cmd_ps(
1906
+ explicit_root=getattr(args, "aisc_root", None),
1907
+ )
1908
+ data = [{"name": r.name, "label": r.label, "status": r.status,
1909
+ "running": r.running, "image": r.image, "workspace": r.workspace,
1910
+ "active": r.active}
1911
+ for r in rows]
1912
+ return data, 0, []
1913
+
1914
+
1915
+ def _cmd_maintenance(
1916
+ args: argparse.Namespace,
1917
+ effective_format: str,
1918
+ ) -> Tuple[Any, int, List[Dict[str, Any]]]:
1919
+ """Execute ``aisc maintenance`` subcommands (docker-resource-lifecycle B).
1920
+
1921
+ Exit codes (02 §3): 0 all-ok/absent · 3 docker unavailable · 1 partial
1922
+ resource failures · 2 usage. stdout carries ONLY the JSON envelope;
1923
+ Docker noise rides stderr via run_captured.
1924
+ """
1925
+ from aisc.adapters.docker_ import RealDockerExecutor
1926
+ from aisc.application.docker_lifecycle import (
1927
+ docker_cleanup, docker_rebuild, docker_scan,
1928
+ )
1929
+ from aisc.domain.models import CliError
1930
+
1931
+ sub = args.maintenance_command
1932
+ executor = RealDockerExecutor()
1933
+ if sub == "docker-scan":
1934
+ data = docker_scan(executor, context=args.context,
1935
+ old_image_ids=args.old_image_id)
1936
+ if effective_format != "json":
1937
+ from aisc.application.docker_lifecycle import render_scan_text
1938
+ print(render_scan_text(data))
1939
+ return None, 0, []
1940
+ return data, 0, []
1941
+ if sub == "docker-cleanup":
1942
+ try:
1943
+ data = docker_cleanup(executor, context=args.context,
1944
+ old_image_ids=args.old_image_id)
1945
+ except CliError as exc:
1946
+ raise
1947
+ failed = bool(data["containers"]["failed"] or data["images"]["failed"])
1948
+ return data, (1 if failed else 0), []
1949
+ if sub == "docker-rebuild":
1950
+ data = docker_rebuild(executor, root=args.root, tag=args.tag,
1951
+ old_image_id=args.old_image_id,
1952
+ no_cache=args.no_cache, pull=args.pull)
1953
+ return data, (1 if data.get("failed") else 0), []
1954
+ if sub == "cache-usage":
1955
+ from aisc.application.docker_lifecycle import cache_usage
1956
+ return cache_usage(executor), 0, []
1957
+ if sub == "cache-cleanup":
1958
+ from aisc.application.docker_lifecycle import docker_cache_cleanup
1959
+ data = docker_cache_cleanup(executor, min_age_hours=args.min_age_hours)
1960
+ return data, (1 if data.get("warnings") else 0), []
1961
+ return None, 2, [build_error("AISC_ERR_USAGE", f"Unknown maintenance subcommand: {sub}")]
1962
+
1963
+
1964
+ def _cmd_bundle(
1965
+ args: argparse.Namespace,
1966
+ effective_format: str,
1967
+ ) -> Tuple[Any, int, List[Dict[str, Any]]]:
1968
+ """Execute ``aisc bundle`` subcommands. Supports --format json."""
1969
+ from aisc.cli.commands.bundle import (
1970
+ cmd_bundle_fetch,
1971
+ cmd_bundle_list,
1972
+ cmd_bundle_path,
1973
+ cmd_bundle_remove,
1974
+ print_bundle_text,
1975
+ )
1976
+
1977
+ sub = args.bundle_command
1978
+ if sub == "fetch":
1979
+ data = cmd_bundle_fetch(version=args.version, from_file=args.from_file,
1980
+ sha256=args.sha256, allow_mismatch=args.allow_mismatch)
1981
+ elif sub == "list":
1982
+ data = cmd_bundle_list()
1983
+ elif sub == "remove":
1984
+ data = cmd_bundle_remove(args.version)
1985
+ elif sub == "path":
1986
+ data = cmd_bundle_path()
1987
+ else:
1988
+ return None, 2, [build_error("AISC_ERR_USAGE", f"Unknown bundle subcommand: {sub}")]
1989
+ if effective_format != "json":
1990
+ print_bundle_text(sub, data)
1991
+ return None, 0, []
1992
+ return data, 0, []
1993
+
1994
+
1995
+ def _cmd_update(
1996
+ args: argparse.Namespace,
1997
+ effective_format: str,
1998
+ ) -> Tuple[Any, int, List[Dict[str, Any]]]:
1999
+ """Execute ``aisc update``. Supports --format json."""
2000
+ from aisc.cli.commands.update import cmd_update, cmd_update_check, print_update_text
2001
+
2002
+ if args.check:
2003
+ data = cmd_update_check(version=args.version)
2004
+ print_update_text("check", data) if effective_format != "json" else None
2005
+ return (data, 0, []) if effective_format == "json" else (None, 0, [])
2006
+ data = cmd_update(version=args.version, from_file=args.from_file,
2007
+ sha256=args.sha256, rebuild=args.rebuild,
2008
+ pin_tool=args.pin_tool)
2009
+ if effective_format != "json":
2010
+ print_update_text("update", data)
2011
+ return None, 0, []
2012
+ exit_code = 0 if data.get("status") in ("updated", "up-to-date") else 1
2013
+ return data, exit_code, []
2014
+
2015
+
2016
+ def _cmd_runtime(
2017
+ args: argparse.Namespace,
2018
+ effective_format: str,
2019
+ ) -> Tuple[Any, int, List[Dict[str, Any]]]:
2020
+ """Execute ``aisc runtime`` subcommands. Supports --format json."""
2021
+ from aisc.cli.commands.runtime import (
2022
+ cmd_runtime_preflight,
2023
+ cmd_runtime_start,
2024
+ cmd_runtime_list,
2025
+ cmd_runtime_inspect,
2026
+ cmd_runtime_status,
2027
+ cmd_runtime_stop,
2028
+ cmd_runtime_restart,
2029
+ cmd_runtime_remove,
2030
+ cmd_runtime_services,
2031
+ cmd_runtime_services_expose,
2032
+ cmd_runtime_services_unexpose,
2033
+ cmd_runtime_reconcile,
2034
+ cmd_runtime_lease,
2035
+ )
2036
+
2037
+ sub = args.runtime_command
2038
+
2039
+ if sub == "preflight":
2040
+ result = cmd_runtime_preflight(
2041
+ runtime_id=args.runtime_id,
2042
+ workspace=args.workspace,
2043
+ image=args.image,
2044
+ network=args.network,
2045
+ scope=args.scope,
2046
+ owner=args.owner,
2047
+ format=effective_format,
2048
+ )
2049
+ if "error" in result:
2050
+ return None, result["exit_code"], [result["error"]]
2051
+ return result, 0, []
2052
+ elif sub == "start":
2053
+ data = cmd_runtime_start(
2054
+ runtime_id=args.runtime_id,
2055
+ workspace=args.workspace,
2056
+ image=args.image,
2057
+ network=args.network,
2058
+ scope=args.scope,
2059
+ owner=args.owner,
2060
+ proxy_config=args.proxy_config,
2061
+ host_mcp_url=getattr(args, "host_mcp_url", None),
2062
+ max_memory=getattr(args, "max_memory", None),
2063
+ max_cpus=getattr(args, "max_cpus", None),
2064
+ )
2065
+ return data, 0, []
2066
+ elif sub == "list":
2067
+ data = cmd_runtime_list(
2068
+ workspace=args.workspace,
2069
+ owner=args.owner,
2070
+ )
2071
+ return data, 0, []
2072
+ elif sub == "inspect":
2073
+ data = cmd_runtime_inspect(
2074
+ runtime_id=args.runtime_id,
2075
+ workspace=args.workspace,
2076
+ )
2077
+ return data, 0, []
2078
+ elif sub == "status":
2079
+ data = cmd_runtime_status(
2080
+ runtime_id=args.runtime_id,
2081
+ workspace=args.workspace,
2082
+ )
2083
+ return data, 0, []
2084
+ elif sub == "stop":
2085
+ data = cmd_runtime_stop(
2086
+ runtime_id=args.runtime_id,
2087
+ workspace=args.workspace,
2088
+ grace_seconds=args.grace,
2089
+ )
2090
+ return data, 0, []
2091
+ elif sub == "restart":
2092
+ data = cmd_runtime_restart(
2093
+ runtime_id=args.runtime_id,
2094
+ workspace=args.workspace,
2095
+ )
2096
+ return data, 0, []
2097
+ elif sub == "remove":
2098
+ data = cmd_runtime_remove(
2099
+ runtime_id=args.runtime_id,
2100
+ workspace=args.workspace,
2101
+ force=args.force,
2102
+ )
2103
+ return data, 0, []
2104
+ elif sub == "services":
2105
+ sub2 = getattr(args, "runtime_services_command", None)
2106
+ if not getattr(args, "runtime_id", None):
2107
+ return None, 2, [build_error(
2108
+ "AISC_ERR_USAGE",
2109
+ "runtime services requires --runtime-id",
2110
+ )]
2111
+ if sub2 == "expose":
2112
+ data = cmd_runtime_services_expose(
2113
+ runtime_id=args.runtime_id,
2114
+ port=args.port,
2115
+ name=args.name,
2116
+ workspace=args.workspace,
2117
+ )
2118
+ elif sub2 == "unexpose":
2119
+ data = cmd_runtime_services_unexpose(
2120
+ runtime_id=args.runtime_id,
2121
+ port=args.port,
2122
+ workspace=args.workspace,
2123
+ )
2124
+ else: # none or "list"
2125
+ data = cmd_runtime_services(
2126
+ runtime_id=args.runtime_id,
2127
+ workspace=args.workspace,
2128
+ )
2129
+ return data, 0, []
2130
+ elif sub == "reconcile":
2131
+ data = cmd_runtime_reconcile(
2132
+ workspace=args.workspace,
2133
+ instance_id=args.instance_id,
2134
+ workspace_key=args.workspace_key,
2135
+ )
2136
+ return data, 0, []
2137
+ elif sub == "lease":
2138
+ action = getattr(args, "runtime_lease_command", None) or "inspect"
2139
+ if action != "inspect" and not getattr(args, "instance_id", None):
2140
+ return None, 2, [build_error(
2141
+ "AISC_ERR_USAGE",
2142
+ f"runtime lease {action} requires --instance-id",
2143
+ )]
2144
+ data = cmd_runtime_lease(
2145
+ action=action,
2146
+ workspace=args.workspace,
2147
+ instance_id=getattr(args, "instance_id", "") or "",
2148
+ lease_id=getattr(args, "lease_id", None),
2149
+ )
2150
+ return data, 0, []
2151
+ else:
2152
+ # Unknown runtime subcommand
2153
+ return None, 2, [build_error(
2154
+ "AISC_ERR_USAGE",
2155
+ f"Unknown runtime subcommand: {sub}"
2156
+ )]
2157
+
2158
+
2159
+
2160
+
2161
+ def _cmd_session(
2162
+ args: argparse.Namespace,
2163
+ effective_format: str,
2164
+ ) -> Tuple[Any, int, List[Dict[str, Any]]]:
2165
+ """Execute ``aisc session`` subcommands. Supports --format json.
2166
+
2167
+ ``session open`` is interactive: it inherits stdio via ``docker exec -it``
2168
+ and returns the agent exit code. ``session list`` and ``session terminate``
2169
+ are non-interactive and return JSON-serializable data.
2170
+ """
2171
+ from aisc.cli.commands.session import (
2172
+ cmd_session_open,
2173
+ cmd_session_list,
2174
+ cmd_session_terminate,
2175
+ )
2176
+
2177
+ sub = args.session_command
2178
+
2179
+ if sub == "open":
2180
+ if effective_format == "json":
2181
+ emit_json_usage_error(
2182
+ command="session", version=__version__,
2183
+ message="session open 仅支持 text 输出——不支持 --format json",
2184
+ )
2185
+ sys.exit(2)
2186
+ data, exit_code = cmd_session_open(
2187
+ runtime_id=args.runtime_id,
2188
+ session_id=args.session_id,
2189
+ agent=args.agent,
2190
+ workspace=args.workspace,
2191
+ resume_conversation_id=args.resume_id,
2192
+ )
2193
+ errors: List[Dict[str, Any]] = []
2194
+ if exit_code != 0 and data.get("error"):
2195
+ errors.append(build_error(
2196
+ RuntimeErrorCode.SESSION_FAILED,
2197
+ data["error"],
2198
+ ))
2199
+ return data, exit_code, errors
2200
+ elif sub == "list":
2201
+ data = cmd_session_list(
2202
+ runtime_id=args.runtime_id,
2203
+ workspace=args.workspace,
2204
+ )
2205
+ return data, 0, []
2206
+ elif sub == "terminate":
2207
+ data = cmd_session_terminate(
2208
+ runtime_id=args.runtime_id,
2209
+ session_id=args.session_id,
2210
+ workspace=args.workspace,
2211
+ grace_seconds=args.grace,
2212
+ )
2213
+ return data, 0, []
2214
+ else:
2215
+ return None, 2, [build_error(
2216
+ "AISC_ERR_USAGE",
2217
+ f"Unknown session subcommand: {sub}"
2218
+ )]
2219
+
2220
+
2221
+ def _cmd_conversation(
2222
+ args: argparse.Namespace,
2223
+ effective_format: str,
2224
+ ) -> Tuple[Any, int, List[Dict[str, Any]]]:
2225
+ """Execute ``aisc conversation`` subcommands (v2.1.8 T3).
2226
+
2227
+ Both subcommands are captured/read-only and return JSON-serializable
2228
+ data under the aisc.cli/v1 envelope. CliErrors from the application
2229
+ layer (AISC_ERR_CONVERSATION_*) propagate to the unified terminal.
2230
+ """
2231
+ from aisc.cli.commands.conversation import (
2232
+ cmd_conversation_delete,
2233
+ cmd_conversation_list,
2234
+ cmd_conversation_preflight,
2235
+ cmd_conversation_rename,
2236
+ )
2237
+
2238
+ sub = args.conversation_command
2239
+
2240
+ if sub == "list":
2241
+ data = cmd_conversation_list(workspace=args.workspace)
2242
+ return data, 0, []
2243
+ elif sub == "preflight":
2244
+ data = cmd_conversation_preflight(
2245
+ workspace=args.workspace,
2246
+ conversation_id=args.conversation_id,
2247
+ agent=args.agent,
2248
+ )
2249
+ return data, 0, []
2250
+ elif sub == "delete":
2251
+ data = cmd_conversation_delete(
2252
+ workspace=args.workspace,
2253
+ conversation_id=args.conversation_id,
2254
+ agent=args.agent,
2255
+ )
2256
+ return data, 0, []
2257
+ elif sub == "rename":
2258
+ data = cmd_conversation_rename(
2259
+ workspace=args.workspace,
2260
+ conversation_id=args.conversation_id,
2261
+ agent=args.agent,
2262
+ title=args.title,
2263
+ )
2264
+ return data, 0, []
2265
+ else:
2266
+ return None, 2, [build_error(
2267
+ "AISC_ERR_USAGE",
2268
+ f"Unknown conversation subcommand: {sub}"
2269
+ )]
2270
+
2271
+
2272
+ def _cmd_artifact(
2273
+ args: argparse.Namespace,
2274
+ effective_format: str,
2275
+ ) -> Tuple[Any, int, List[Dict[str, Any]]]:
2276
+ """Execute ``aisc artifact`` subcommands. Supports --format json.
2277
+
2278
+ ``record``/``list``/``inspect``/``clear-session`` are non-interactive and
2279
+ return JSON-serializable data under the aisc.cli/v1 envelope.
2280
+ """
2281
+ from aisc.cli.commands.artifact import (
2282
+ cmd_artifact_record,
2283
+ cmd_artifact_list,
2284
+ cmd_artifact_inspect,
2285
+ cmd_artifact_clear_session,
2286
+ )
2287
+
2288
+ sub = args.artifact_command
2289
+
2290
+ if sub == "record":
2291
+ # T3a: env defaults mean argparse no longer enforces these — report
2292
+ # a stable usage error when neither flag nor env provided them.
2293
+ missing = [n for n, v in (
2294
+ ("--runtime-id", args.runtime_id),
2295
+ ("--session-id", args.session_id),
2296
+ ) if not v]
2297
+ if missing:
2298
+ raise CliError(
2299
+ message=(
2300
+ "missing required arguments: "
2301
+ + ", ".join(missing)
2302
+ + " (or set AISC_RUNTIME_ID / AISC_TERMINAL_SESSION_ID — "
2303
+ "the Workbench injects both into agent sessions)"
2304
+ ),
2305
+ exit_code=2,
2306
+ error_code="AISC_ERR_USAGE",
2307
+ )
2308
+ data = cmd_artifact_record(
2309
+ runtime_id=args.runtime_id,
2310
+ session_id=args.session_id,
2311
+ agent=args.agent,
2312
+ path=args.path,
2313
+ action=args.action,
2314
+ kind=args.kind,
2315
+ media_type=args.media_type,
2316
+ label=args.label,
2317
+ open_with=args.open_with,
2318
+ previous_path=args.previous_path,
2319
+ workspace=args.workspace,
2320
+ )
2321
+ return data, 0, []
2322
+ elif sub == "list":
2323
+ data = cmd_artifact_list(
2324
+ workspace=args.workspace,
2325
+ session_id=args.session_id,
2326
+ kind=args.kind,
2327
+ )
2328
+ return data, 0, []
2329
+ elif sub == "inspect":
2330
+ data = cmd_artifact_inspect(
2331
+ artifact_id=args.artifact_id,
2332
+ workspace=args.workspace,
2333
+ )
2334
+ return data, 0, []
2335
+ elif sub == "clear-session":
2336
+ data = cmd_artifact_clear_session(
2337
+ runtime_id=args.runtime_id,
2338
+ session_id=args.session_id,
2339
+ workspace=args.workspace,
2340
+ )
2341
+ return data, 0, []
2342
+ else:
2343
+ return None, 2, [build_error(
2344
+ "AISC_ERR_USAGE",
2345
+ f"Unknown artifact subcommand: {sub}"
2346
+ )]
2347
+
2348
+
2349
+ def _cmd_data_root(
2350
+ args: argparse.Namespace,
2351
+ effective_format: str,
2352
+ ) -> Tuple[Any, int, List[Dict[str, Any]]]:
2353
+ """Execute ``aisc data-root`` subcommands (Stage 7, 7d).
2354
+
2355
+ doctor/migrate --dry-run/migrate --apply/rollback are non-interactive;
2356
+ conflicts and unconsented unknowns raise CliError (stable code, non-zero
2357
+ exit) instead of guessing.
2358
+ """
2359
+ from aisc.cli.commands.data_root import (
2360
+ cmd_data_root_doctor,
2361
+ cmd_data_root_migrate,
2362
+ cmd_data_root_rollback,
2363
+ )
2364
+
2365
+ sub = args.data_root_command
2366
+ if sub == "doctor":
2367
+ return cmd_data_root_doctor(workspace=args.workspace), 0, []
2368
+ elif sub == "migrate":
2369
+ return cmd_data_root_migrate(
2370
+ workspace=args.workspace,
2371
+ dry_run=args.dry_run,
2372
+ quarantine_unknown=args.quarantine_unknown,
2373
+ ), 0, []
2374
+ # --apply is accepted for explicitness; applying is the default action
2375
+ # when --dry-run is absent (03-ux-flow contract spelling).
2376
+ elif sub == "rollback":
2377
+ return cmd_data_root_rollback(
2378
+ workspace=args.workspace, manifest=args.manifest,
2379
+ ), 0, []
2380
+ return None, 2, [build_error(
2381
+ "AISC_ERR_USAGE",
2382
+ f"Unknown data-root subcommand: {sub}"
2383
+ )]
2384
+
2385
+ # ---------------------------------------------------------------------------
2386
+ # Main entry point
2387
+ # ---------------------------------------------------------------------------
2388
+
2389
+ def _log_cli_exit(
2390
+ command: str,
2391
+ exit_code: int,
2392
+ started: float,
2393
+ run_id: Optional[str],
2394
+ *,
2395
+ error_code: Optional[str] = None,
2396
+ ) -> None:
2397
+ """Lifecycle log line for a CLI invocation (lifecycle-logging P1).
2398
+
2399
+ One line per process exit at the three main() terminals — the same
2400
+ ``run_id`` the envelope carries (env-injected by the Workbench), so
2401
+ app-side op events and cli exits align on one timeline. Best-effort:
2402
+ applog never raises.
2403
+ """
2404
+ import time as _time
2405
+
2406
+ from aisc.applog import append_event
2407
+
2408
+ level = "info" if exit_code == 0 else ("warn" if exit_code == 130 else "error")
2409
+ append_event(
2410
+ "cli_exit", level=level, source="cli", run_id=run_id,
2411
+ command=command or "aisc", exit_code=exit_code,
2412
+ duration_ms=int((_time.monotonic() - started) * 1000),
2413
+ error_code=error_code,
2414
+ )
2415
+
2416
+
2417
+ def main(argv: Optional[Sequence[str]] = None) -> None:
2418
+ """Main CLI entry point."""
2419
+ import time as _time
2420
+
2421
+ started = _time.monotonic()
2422
+ # Same id the envelope will carry (env-injected by the Workbench;
2423
+ # self-generated for standalone CLI use) — keeps the log timeline and
2424
+ # envelope.meta.run_id identical.
2425
+ _run_id = os.environ.get("AISC_RUN_ID") or str(__import__("uuid").uuid4())
2426
+ # Never let stdout's locale encoding (GBK on zh-CN Windows, cp1252 on
2427
+ # en-US) crash the CLI with UnicodeEncodeError. Protocol JSON is pure
2428
+ # ASCII by construction (ensure_ascii=True in emit_json/JsonlEmitter);
2429
+ # any other unencodable output (wizard emoji, non-ASCII messages)
2430
+ # degrades to '?' instead of aborting mid-command.
2431
+ try:
2432
+ sys.stdout.reconfigure(errors="replace")
2433
+ except (AttributeError, ValueError):
2434
+ pass # non-TextIOWrapper (PyInstaller wrapper) - protocol sites are ASCII anyway
2435
+ # Help localization (manual test ask 2026-09-09): follow the OS locale —
2436
+ # Chinese environments get Chinese help via output-layer translation
2437
+ # (see aisc.cli.help_i18n); every other locale keeps the English source.
2438
+ # MUST run before _build_parser(): argparse group titles ('options' 等)
2439
+ # resolve _() at construction time, not render time.
2440
+ from aisc.cli.help_i18n import maybe_install as _maybe_i18n
2441
+ _maybe_i18n()
2442
+
2443
+ parser = _build_parser()
2444
+
2445
+ # Shell completion (manual test ask 2026-09-09): argcomplete follows the
2446
+ # argparse tree automatically — subcommands, flags, choices all covered,
2447
+ # zero per-command maintenance. No-op outside a completion environment
2448
+ # (no _ARGCOMPLETE env); optional import so argcomplete-less installs
2449
+ # only lose completion, never the CLI.
2450
+ try:
2451
+ import argcomplete
2452
+ argcomplete.autocomplete(parser)
2453
+ except ImportError:
2454
+ pass
2455
+
2456
+ args_list = list(argv) if argv is not None else sys.argv[1:]
2457
+
2458
+ # Pre-detect format/events/command for error messages
2459
+ json_requested = _detect_json_format(args_list)
2460
+ events_requested = _detect_events(args_list)
2461
+ cmd_hint = _detect_command(args_list) or "aisc"
2462
+ parser._aisc_format = "json" if json_requested else None
2463
+ parser._aisc_command = cmd_hint
2464
+
2465
+ # Propagate json format to config subparser for unknown-subcommand errors
2466
+ if "config" in args_list:
2467
+ try:
2468
+ cfg_parser = [a for a in parser._subparsers._group_actions
2469
+ if a.dest == "command"][0].choices["config"]
2470
+ cfg_parser._aisc_format = "json" if json_requested else None
2471
+ cfg_parser._aisc_command = "config"
2472
+ except (AttributeError, IndexError, KeyError):
2473
+ pass
2474
+
2475
+ # Propagate json format to profile subparser for unknown-subcommand errors
2476
+ if "profile" in args_list:
2477
+ try:
2478
+ pf_parser = [a for a in parser._subparsers._group_actions
2479
+ if a.dest == "command"][0].choices["profile"]
2480
+ pf_parser._aisc_format = "json" if json_requested else None
2481
+ pf_parser._aisc_command = "profile"
2482
+ except (AttributeError, IndexError, KeyError):
2483
+ pass
2484
+
2485
+ # Propagate JSON format to provider subparser for parse-time errors.
2486
+ if "provider" in args_list:
2487
+ try:
2488
+ provider_parser = [a for a in parser._subparsers._group_actions
2489
+ if a.dest == "command"][0].choices["provider"]
2490
+ provider_parser._aisc_format = "json" if json_requested else None
2491
+ provider_parser._aisc_command = "provider"
2492
+ # Also propagate to provider current subparser (S0.4, supports --format json)
2493
+ if "current" in args_list:
2494
+ try:
2495
+ current_parser = [a for a in provider_parser._subparsers._group_actions
2496
+ if a.dest == "provider_command"][0].choices["current"]
2497
+ current_parser._aisc_format = "json" if json_requested else None
2498
+ current_parser._aisc_command = "provider current"
2499
+ except (AttributeError, IndexError, KeyError):
2500
+ pass
2501
+ except (AttributeError, IndexError, KeyError):
2502
+ pass
2503
+
2504
+ # Propagate JSON format to runtime subparser for parse-time errors.
2505
+ if "runtime" in args_list:
2506
+ try:
2507
+ runtime_parser = [a for a in parser._subparsers._group_actions
2508
+ if a.dest == "command"][0].choices["runtime"]
2509
+ runtime_parser._aisc_format = "json" if json_requested else None
2510
+ runtime_parser._aisc_command = "runtime"
2511
+
2512
+ # Also propagate to every runtime sub-subparser (CLI-A02/A05: any
2513
+ # command that supports --format json must emit a JSON usage error,
2514
+ # not fall back to argparse text — matches the session propagation).
2515
+ for _sub in ("preflight", "start", "list", "inspect", "stop",
2516
+ "restart", "remove", "reconcile", "lease"):
2517
+ if _sub in args_list:
2518
+ try:
2519
+ _sp = [a for a in runtime_parser._subparsers._group_actions
2520
+ if a.dest == "runtime_command"][0].choices[_sub]
2521
+ _sp._aisc_format = "json" if json_requested else None
2522
+ _sp._aisc_command = f"runtime {_sub}"
2523
+ except (AttributeError, IndexError, KeyError):
2524
+ pass
2525
+ except (AttributeError, IndexError, KeyError):
2526
+ pass
2527
+
2528
+ # Propagate JSON format to session subparser for parse-time errors.
2529
+ if "session" in args_list:
2530
+ try:
2531
+ session_parser = [a for a in parser._subparsers._group_actions
2532
+ if a.dest == "command"][0].choices["session"]
2533
+ session_parser._aisc_format = "json" if json_requested else None
2534
+ session_parser._aisc_command = "session"
2535
+ # Also propagate to session sub-subparsers (list/terminate support --format json).
2536
+ for _sub in ("open", "list", "terminate"):
2537
+ if _sub in args_list:
2538
+ try:
2539
+ _sp = [a for a in session_parser._subparsers._group_actions
2540
+ if a.dest == "session_command"][0].choices[_sub]
2541
+ _sp._aisc_format = "json" if json_requested else None
2542
+ _sp._aisc_command = f"session {_sub}"
2543
+ except (AttributeError, IndexError, KeyError):
2544
+ pass
2545
+ except (AttributeError, IndexError, KeyError):
2546
+ pass
2547
+
2548
+ # Propagate JSON format to artifact subparser for parse-time errors.
2549
+ if "artifact" in args_list:
2550
+ try:
2551
+ artifact_parser = [a for a in parser._subparsers._group_actions
2552
+ if a.dest == "command"][0].choices["artifact"]
2553
+ artifact_parser._aisc_format = "json" if json_requested else None
2554
+ artifact_parser._aisc_command = "artifact"
2555
+ for _sub in ("record", "list", "inspect", "clear-session"):
2556
+ if _sub in args_list:
2557
+ try:
2558
+ _sp = [a for a in artifact_parser._subparsers._group_actions
2559
+ if a.dest == "artifact_command"][0].choices[_sub]
2560
+ _sp._aisc_format = "json" if json_requested else None
2561
+ _sp._aisc_command = f"artifact {_sub}"
2562
+ except (AttributeError, IndexError, KeyError):
2563
+ pass
2564
+ except (AttributeError, IndexError, KeyError):
2565
+ pass
2566
+
2567
+ # Propagate JSON format to data-root subparser for parse-time errors.
2568
+ if "data-root" in args_list:
2569
+ try:
2570
+ data_root_parser = [a for a in parser._subparsers._group_actions
2571
+ if a.dest == "command"][0].choices["data-root"]
2572
+ data_root_parser._aisc_format = "json" if json_requested else None
2573
+ data_root_parser._aisc_command = "data-root"
2574
+ for _sub in ("doctor", "migrate", "rollback"):
2575
+ if _sub in args_list:
2576
+ try:
2577
+ _sp = [a for a in data_root_parser._subparsers._group_actions
2578
+ if a.dest == "data_root_command"][0].choices[_sub]
2579
+ _sp._aisc_format = "json" if json_requested else None
2580
+ _sp._aisc_command = f"data-root {_sub}"
2581
+ except (AttributeError, IndexError, KeyError):
2582
+ pass
2583
+ except (AttributeError, IndexError, KeyError):
2584
+ pass
2585
+
2586
+ try:
2587
+ args = parser.parse_args(args_list)
2588
+ except SystemExit:
2589
+ raise
2590
+
2591
+ # Resolve --aisc-root from raw argv with last-wins semantics.
2592
+ # This decouples root resolution from argparse's subparser defaults
2593
+ # and ensures --aisc-root works both before and after any command.
2594
+ resolved_root = _resolve_aisc_root_from_argv(args_list)
2595
+ if resolved_root is not None:
2596
+ args.aisc_root = resolved_root
2597
+
2598
+ # Resolve effective format
2599
+ effective_format = _resolve_format(args, args_list)
2600
+ parser._aisc_format = effective_format
2601
+ if getattr(args, "command", None):
2602
+ parser._aisc_command = args.command
2603
+
2604
+ # --events flag
2605
+ args_events = getattr(args, "events", False)
2606
+ if args_events is argparse.SUPPRESS:
2607
+ args_events = False
2608
+
2609
+ # --format json and --events are mutually exclusive
2610
+ if effective_format == "json" and args_events:
2611
+ emit_json_usage_error(
2612
+ command=args.command or cmd_hint, version=__version__,
2613
+ error_code="AISC_ERR_USAGE",
2614
+ message="--format json and --events are mutually exclusive",
2615
+ )
2616
+ sys.exit(2)
2617
+
2618
+ # Require a command
2619
+ if not getattr(args, "command", None):
2620
+ if effective_format == "json":
2621
+ emit_json_usage_error(command=cmd_hint, version=__version__,
2622
+ message="No command specified")
2623
+ sys.exit(2)
2624
+ else:
2625
+ parser.print_help()
2626
+ sys.exit(2)
2627
+
2628
+ # --- Bare grouped commands → print group help, exit 0 ---
2629
+ _grouped_dests: Dict[str, str] = {
2630
+ "config": "config_command",
2631
+ "profile": "profile_command",
2632
+ "provider": "provider_command",
2633
+ "runtime": "runtime_command",
2634
+ "session": "session_command",
2635
+ "artifact": "artifact_command",
2636
+ "conversation": "conversation_command",
2637
+ }
2638
+ if args.command in _grouped_dests:
2639
+ if getattr(args, _grouped_dests[args.command], None) is None:
2640
+ # Bare group — print the group's own help. Use the actual
2641
+ # argparse subparser so that future options stay synchronised
2642
+ # across all sub-subcommands. No JSON envelope; always text
2643
+ # help and exit 0.
2644
+ try:
2645
+ group_choices = [
2646
+ a for a in parser._subparsers._group_actions
2647
+ if a.dest == "command"
2648
+ ][0].choices
2649
+ group_parser_obj = group_choices.get(args.command)
2650
+ if group_parser_obj is not None:
2651
+ group_parser_obj.print_help()
2652
+ sys.exit(0)
2653
+ except (AttributeError, IndexError, KeyError):
2654
+ pass
2655
+ # Fallback (should not be reached)
2656
+ parser.print_help()
2657
+ sys.exit(0)
2658
+
2659
+ use_color = sys.stdout.isatty() and not getattr(args, "no_color", False)
2660
+ aisc_root = getattr(args, "aisc_root", None)
2661
+ # Raw-argv resolver may have set this to a valid string; keep it as-is.
2662
+ # If the raw-argv resolver set it, argparse.SUPPRESS is irrelevant.
2663
+ if aisc_root is argparse.SUPPRESS:
2664
+ aisc_root = None
2665
+
2666
+ # Set up JSONL emitter
2667
+ emitter: Optional[JsonlEmitter] = None
2668
+ if args_events and args.command in ("build", "run"):
2669
+ emitter = JsonlEmitter(command=args.command)
2670
+ elif args_events and args.command in ("version", "doctor", "config", "profile",
2671
+ "status", "stop", "restart", "shell", "switch",
2672
+ "provider", "ps", "session", "artifact",
2673
+ "data-root"):
2674
+ if effective_format == "json":
2675
+ emit_json_usage_error(
2676
+ command=args.command, version=__version__,
2677
+ message=f"--events is not supported for '{args.command}' command",
2678
+ )
2679
+ sys.exit(2)
2680
+ else:
2681
+ print(f"Error: --events is not supported for '{args.command}' command",
2682
+ file=sys.stderr)
2683
+ sys.exit(2)
2684
+
2685
+ # Execute command
2686
+ data: Any = None
2687
+ report: Optional[DoctorReport] = None
2688
+ exit_code = 0
2689
+ errors: List[Dict[str, Any]] = []
2690
+ version_info: Optional[VersionInfo] = None
2691
+
2692
+ try:
2693
+ # 2.1.10 R1: serve owns its stdout completely (frame protocol) and
2694
+ # never participates in the envelope/text dispatch below. A missing
2695
+ # --stdio raises CliError here, which the shared handler reports.
2696
+ if args.command == "serve":
2697
+ from aisc.cli.commands.serve import cmd_serve
2698
+ sys.exit(cmd_serve(args))
2699
+
2700
+ if args.command == "version":
2701
+ version_info = _cmd_version(args)
2702
+ data = version_info.to_dict()
2703
+ elif args.command == "doctor":
2704
+ data, report = _cmd_doctor(args, effective_format)
2705
+ exit_code = report.exit_code
2706
+ if report.error_code:
2707
+ errors.append(build_error(report.error_code, report.error_message or ""))
2708
+ elif args.command == "build":
2709
+ data, exit_code, errors = _cmd_build(args, emitter, effective_format)
2710
+ elif args.command == "run":
2711
+ data, exit_code, errors = _cmd_run(args, emitter, effective_format, aisc_root)
2712
+ elif args.command in ("claude", "codex"):
2713
+ data, exit_code, errors = _cmd_agent(args, effective_format)
2714
+ elif args.command == "runs":
2715
+ data, exit_code, errors = _cmd_runs(args, effective_format)
2716
+ elif args.command == "workspaces":
2717
+ data, exit_code, errors = _cmd_workspaces(args, effective_format)
2718
+ elif args.command == "config":
2719
+ data, exit_code, errors = _cmd_config(args, effective_format)
2720
+ elif args.command == "profile":
2721
+ data, exit_code, errors = _cmd_profile(args, effective_format)
2722
+ elif args.command == "status":
2723
+ data, exit_code, errors = _cmd_status(args, effective_format)
2724
+ elif args.command == "stop":
2725
+ data, exit_code, errors = _cmd_stop(args, effective_format)
2726
+ elif args.command == "restart":
2727
+ data, exit_code, errors = _cmd_restart(args, effective_format)
2728
+ elif args.command == "shell":
2729
+ data, exit_code, errors = _cmd_shell(args, effective_format)
2730
+ elif args.command == "switch":
2731
+ data, exit_code, errors = _cmd_switch(args, effective_format)
2732
+ elif args.command == "provider":
2733
+ data, exit_code, errors = _cmd_provider(args, effective_format)
2734
+ elif args.command == "cc-switch":
2735
+ data, exit_code, errors = _cmd_cc_switch(args, effective_format)
2736
+ elif args.command == "network":
2737
+ data, exit_code, errors = _cmd_network(args, effective_format)
2738
+ elif args.command == "usage":
2739
+ data, exit_code, errors = _cmd_usage(args, effective_format)
2740
+ elif args.command == "logs":
2741
+ data, exit_code, errors = _cmd_logs(args, effective_format)
2742
+ elif args.command == "ps":
2743
+ data, exit_code, errors = _cmd_ps(args, effective_format)
2744
+ elif args.command == "maintenance":
2745
+ data, exit_code, errors = _cmd_maintenance(args, effective_format)
2746
+ elif args.command == "bundle":
2747
+ data, exit_code, errors = _cmd_bundle(args, effective_format)
2748
+ elif args.command == "update":
2749
+ data, exit_code, errors = _cmd_update(args, effective_format)
2750
+ elif args.command == "runtime":
2751
+ data, exit_code, errors = _cmd_runtime(args, effective_format)
2752
+ elif args.command == "session":
2753
+ data, exit_code, errors = _cmd_session(args, effective_format)
2754
+ elif args.command == "conversation":
2755
+ data, exit_code, errors = _cmd_conversation(args, effective_format)
2756
+ elif args.command == "artifact":
2757
+ data, exit_code, errors = _cmd_artifact(args, effective_format)
2758
+ elif args.command == "data-root":
2759
+ data, exit_code, errors = _cmd_data_root(args, effective_format)
2760
+ else:
2761
+ if effective_format == "json":
2762
+ emit_json_usage_error(
2763
+ command=args.command, version=__version__,
2764
+ message=f"Unknown command: {args.command}",
2765
+ )
2766
+ else:
2767
+ parser.error(f"Unknown command: {args.command}")
2768
+ sys.exit(2)
2769
+ return
2770
+
2771
+ except CliError as exc:
2772
+ _log_cli_exit(args.command, exc.exit_code, started, _run_id,
2773
+ error_code=exc.error_code)
2774
+ # --- unified terminal: main owns the single terminal event ---
2775
+ if emitter is not None and not emitter.terminated:
2776
+ cmd = args.command or "aisc"
2777
+ term_type = f"{cmd}.failed"
2778
+ term_data = dict(exc.data or {})
2779
+ term_data["exit_code"] = exc.exit_code
2780
+ term_data["error_code"] = exc.error_code
2781
+ term_data["message"] = exc.message
2782
+ emitter.emit(term_type, term_data, terminal=True)
2783
+ sys.exit(exc.exit_code)
2784
+
2785
+ # JSON envelope — use exc.data if present
2786
+ if effective_format == "json":
2787
+ envelope = build_envelope(
2788
+ command=args.command,
2789
+ exit_code=exc.exit_code,
2790
+ version=__version__,
2791
+ data=exc.data, # structured outcome, not null
2792
+ errors=[build_error(exc.error_code, exc.message, exc.hint)],
2793
+ run_id=_run_id,
2794
+ )
2795
+ emit_json(envelope)
2796
+ else:
2797
+ print(f"Error: {exc.message}", file=sys.stderr)
2798
+ sys.exit(exc.exit_code)
2799
+ return
2800
+
2801
+ except KeyboardInterrupt:
2802
+ _log_cli_exit(args.command, 130, started, _run_id)
2803
+ if emitter is not None and not emitter.terminated:
2804
+ cmd = args.command or "aisc"
2805
+ emitter.emit(f"{cmd}.cancelled", {"exit_code": 130}, terminal=True)
2806
+ sys.exit(130)
2807
+ print("\nCancelled.", file=sys.stderr)
2808
+ sys.exit(130)
2809
+
2810
+ # --- success: terminal event if events mode ---
2811
+ _log_cli_exit(args.command, exit_code, started, _run_id)
2812
+ if emitter is not None and not emitter.terminated:
2813
+ cmd = args.command or "aisc"
2814
+ term_data = dict(data or {})
2815
+ term_data["exit_code"] = exit_code
2816
+ emitter.emit(f"{cmd}.complete", term_data, terminal=True)
2817
+ sys.exit(exit_code)
2818
+
2819
+ # Output for non-events mode
2820
+ if effective_format == "json":
2821
+ envelope = build_envelope(
2822
+ command=args.command, exit_code=exit_code,
2823
+ version=__version__, data=data, errors=errors,
2824
+ run_id=_run_id,
2825
+ )
2826
+ emit_json(envelope)
2827
+ else:
2828
+ if args.command == "version":
2829
+ assert version_info is not None
2830
+ print(version_info.to_text())
2831
+ elif args.command == "doctor":
2832
+ assert report is not None
2833
+ print_doctor_text(report, use_color=use_color)
2834
+ elif args.command in ("build", "run"):
2835
+ from aisc.adapters.docker_ import format_argv_display
2836
+ if isinstance(data, dict) and data.get("dry_run"):
2837
+ label = "Build" if args.command == "build" else "Run"
2838
+ print(f"{label} plan (dry-run):")
2839
+ argv_list = data.get("docker_argv", [])
2840
+ print(f" docker {format_argv_display(argv_list)}")
2841
+ elif isinstance(data, dict) and data.get("executed"):
2842
+ if args.command == "build":
2843
+ print(f"Build succeeded: {data.get('image_tag', '')}")
2844
+ # run: the activation summary (_cmd_run) already spoke —
2845
+ # "Container finished." was the old foreground-run wording;
2846
+ # F2-C detached keep-alives never finish here
2847
+ elif args.command == "data-root":
2848
+ from aisc.cli.commands.data_root import print_data_root_text
2849
+ print_data_root_text(data)
2850
+ elif args.command == "config":
2851
+ from aisc.cli.commands.config import print_validate_text, print_effective_text
2852
+ from aisc.application.config_service import ServiceResult
2853
+ sub = getattr(args, "config_command", "validate")
2854
+ if sub == "validate":
2855
+ sr = ServiceResult(valid=data.get("valid", True), exit_code=exit_code,
2856
+ error_code=errors[0]["code"] if errors else "",
2857
+ error_message=errors[0]["message"] if errors else "",
2858
+ data=data)
2859
+ print_validate_text(sr)
2860
+ else:
2861
+ sr = ServiceResult(valid=data.get("valid", True), exit_code=exit_code,
2862
+ error_code=errors[0]["code"] if errors else "",
2863
+ error_message=errors[0]["message"] if errors else "",
2864
+ data=data)
2865
+ print_effective_text(sr)
2866
+ elif args.command == "profile":
2867
+ from aisc.cli.commands.profile import print_profile_list_text, print_profile_show_text
2868
+ sub = getattr(args, "profile_command", "list")
2869
+ if sub == "show":
2870
+ print_profile_show_text(data)
2871
+ else:
2872
+ print_profile_list_text(data)
2873
+ elif args.command == "status":
2874
+ from aisc.cli.commands.container import print_status_text, StatusResult
2875
+ sr = StatusResult(
2876
+ name=data.get("name", ""),
2877
+ exists=data.get("exists", False),
2878
+ running=data.get("running", False),
2879
+ status=data.get("status", ""),
2880
+ image=data.get("image", ""),
2881
+ container_id=data.get("container_id", ""),
2882
+ )
2883
+ print_status_text(sr)
2884
+ elif args.command == "stop":
2885
+ # --all already printed its batch summary in _cmd_stop — feeding
2886
+ # the batch dict to print_stop_text was a KeyError crash (r2 #1)
2887
+ if not getattr(args, "all", False):
2888
+ from aisc.cli.commands.container import print_stop_text
2889
+ print_stop_text(data if isinstance(data, dict) else {})
2890
+ elif args.command == "restart":
2891
+ from aisc.cli.commands.container import print_restart_text
2892
+ print_restart_text(data if isinstance(data, dict) else {})
2893
+ elif args.command == "ps":
2894
+ from aisc.cli.commands.container import print_ps_text, PsRow
2895
+ ps_rows = [PsRow(
2896
+ name=r.get("name", ""), label=r.get("label", ""),
2897
+ status=r.get("status", ""), running=r.get("running", False),
2898
+ image=r.get("image", ""), workspace=r.get("workspace", ""),
2899
+ active=r.get("active", False),
2900
+ ) for r in (data if isinstance(data, list) else [])]
2901
+ print_ps_text(ps_rows)
2902
+ elif args.command in ("shell", "switch"):
2903
+ # interactive output printed directly by _cmd_*
2904
+ pass
2905
+ elif args.command == "provider":
2906
+ if getattr(args, "provider_command", "") == "current":
2907
+ from aisc.cli.commands.provider import print_provider_current_text
2908
+ print_provider_current_text(data)
2909
+ # set-key: interactive, output already printed by _cmd_*
2910
+ elif args.command == "runtime":
2911
+ from aisc.cli.commands.runtime import print_runtime_text
2912
+ print_runtime_text(getattr(args, "runtime_command", ""), data, errors)
2913
+ # maintenance text output is printed in dispatch (scan lines are
2914
+ # consumed by installers; cleanup/rebuild print nothing extra).
2915
+ elif args.command == "session":
2916
+ from aisc.cli.commands.session import print_session_text
2917
+ print_session_text(getattr(args, "session_command", ""), data, errors)
2918
+ elif args.command == "artifact":
2919
+ from aisc.cli.commands.artifact import print_artifact_text
2920
+ print_artifact_text(getattr(args, "artifact_command", ""), data, errors)
2921
+
2922
+ sys.exit(exit_code)
2923
+
2924
+
2925
+ if __name__ == "__main__":
2926
+ main()