aisc-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,550 @@
1
+ """``aisc serve`` — the long-lived remote channel (2.1.10 R1/R2, D-7/D-8).
2
+
3
+ The Workbench (or any client) reaches a remote machine's aisc CLI over SSH
4
+ by spawning ``ssh <alias> aisc serve --stdio``: the serve process then speaks
5
+ newline-delimited JSON frames on stdio — the same "authenticated transport
6
+ is the SSH session" model as VS Code Remote-SSH. There is deliberately NO
7
+ token and NO listening socket in stdio mode (D-7); a future TCP mode must
8
+ re-open that decision.
9
+
10
+ Frame protocol v1.1 (docs/plans/2.1.10-dev-plans/r2-remote-sessions.md §2):
11
+
12
+ serve -> client {"type":"ready","serve_protocol":1,"cli_version":...}
13
+ client -> serve {"id":"u1","op":"version","args":[...]}
14
+ serve -> client {"id":"u1","type":"result","ok":true,"envelope":{...}}
15
+ serve -> client {"type":"log","level":"info","line":"..."}
16
+
17
+ R2 PTY stream frames (D-8):
18
+ client -> serve {"id":"u2","op":"session.open","args":{...}}
19
+ serve -> client result frame, then stream frames for that sid:
20
+ client -> serve {"type":"pty.input","sid":...,"data":"<b64>"}
21
+ client -> serve {"type":"pty.resize","sid":...,"cols":80,"rows":24}
22
+ client -> serve {"type":"pty.kill","sid":...}
23
+ serve -> client {"type":"pty.output","sid":...,"data":"<b64>"}
24
+ serve -> client {"type":"pty.exit","sid":...,"exit_code":0}
25
+
26
+ Lifecycle: stdin EOF, SIGINT or SIGTERM ends the loop (in-flight request is
27
+ allowed up to 3s to finish, every live PTY is killed). Malformed lines and
28
+ unknown ops answer with an error result frame — the process never dies from
29
+ bad input.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import base64
36
+ import contextlib
37
+ import io
38
+ import json
39
+ import os
40
+ import signal
41
+ import sys
42
+ import threading
43
+ from typing import Any, Callable, Dict, List, Optional, Tuple
44
+
45
+ from aisc.cli.output import build_envelope
46
+ from aisc.domain.models import CliError
47
+
48
+ #: Wire protocol version. Human labels v1.1 (PTY) / v1.2 (fs.*) were additive
49
+ #: while the field stayed 1; v1.3 (D-10: generic `cli` op + ready `home`)
50
+ #: BUMPS the field to 3 and drops cross-version compatibility by ruling —
51
+ #: both ends ship from one repo, a mismatch is a hard client-side error
52
+ #: ("upgrade the remote aisc"), never a silent per-op-ssh fallback.
53
+ SERVE_PROTOCOL = 3
54
+
55
+ #: Grace period for an in-flight op when the loop is asked to stop (s).
56
+ SHUTDOWN_GRACE_SECONDS = 3.0
57
+
58
+ #: Initial pty size for session.open when the client did not send one.
59
+ DEFAULT_COLS = 80
60
+ DEFAULT_ROWS = 24
61
+
62
+
63
+ def _frame(line: str) -> str:
64
+ # 2.1.11 step2 (field forensics): the wire is JSON-lines read by a Rust
65
+ # client with a STRICT UTF-8 reader. `ensure_ascii=False` wrote real CJK
66
+ # through the locale encoder — GBK on zh-CN Windows — and the resident
67
+ # reader died on the first invalid byte ("stream did not contain valid
68
+ # UTF-8"), tearing the session down (doctor's "Not Linux —" message was
69
+ # the trigger). ASCII-escape everything: pure-ASCII frames are immune to
70
+ # any locale, and \uXXXX escapes parse identically on every client (old
71
+ # and new interop in both directions). CJK-heavy frames grow ~3x —
72
+ # irrelevant at control-plane sizes. (One-shot envelope writers kept
73
+ # ensure_ascii=True all along; serve was the outlier.)
74
+ return json.dumps(line, ensure_ascii=True, separators=(",", ":"))
75
+
76
+
77
+ # -- PTY registry (R2, D-8) -----------------------------------------------------
78
+
79
+
80
+ class PtyEntry:
81
+ """One live exec TTY owned by this serve process."""
82
+
83
+ def __init__(self, sid: str, handle: Any, runtime: "ServeRuntime") -> None:
84
+ self.sid = sid
85
+ self.handle = handle
86
+ self.runtime = runtime
87
+ self.waiter = threading.Thread(target=self._wait, daemon=True)
88
+
89
+ def start(self) -> None:
90
+ self.waiter.start()
91
+
92
+ def _wait(self) -> None:
93
+ try:
94
+ code = self.handle.wait_exit()
95
+ except Exception as exc: # noqa: BLE001 — exit frame carries the failure
96
+ self.runtime._emit({"type": "pty.exit", "sid": self.sid,
97
+ "error": f"{type(exc).__name__}: {exc}"})
98
+ return
99
+ self.runtime._emit({"type": "pty.exit", "sid": self.sid,
100
+ "exit_code": code})
101
+ self.runtime._forget_pty(self.sid)
102
+
103
+
104
+ class ServeRuntime:
105
+ """Everything the loop + pump threads share: the serialized stdout
106
+ writer (frames from the main loop AND from every PTY pump thread ride
107
+ one lock) and the live-PTY registry."""
108
+
109
+ def __init__(self, stdout: Any) -> None:
110
+ self._stdout = stdout
111
+ self._write_lock = threading.Lock()
112
+ self._ptys: Dict[str, PtyEntry] = {}
113
+ self._pty_lock = threading.Lock()
114
+ # R3 (D-9): lazy-created watchdog registry; fs.watch fails cleanly
115
+ # where watchdog is unavailable.
116
+ from aisc.cli.commands.serve_fs import WatchRegistry
117
+
118
+ self.fs_watches = WatchRegistry(self._emit)
119
+
120
+ def _emit(self, frame: Dict[str, Any]) -> None:
121
+ line = _frame(frame) + "\n"
122
+ with self._write_lock:
123
+ self._stdout.write(line)
124
+ self._stdout.flush()
125
+
126
+ def _on_output(self, sid: str) -> Callable[[bytes], None]:
127
+ def push(chunk: bytes) -> None:
128
+ self._emit({
129
+ "type": "pty.output",
130
+ "sid": sid,
131
+ "data": base64.b64encode(chunk).decode("ascii"),
132
+ })
133
+ return push
134
+
135
+ def register_pty(self, sid: str, handle: Any) -> None:
136
+ entry = PtyEntry(sid, handle, self)
137
+ with self._pty_lock:
138
+ self._ptys[sid] = entry
139
+ entry.start()
140
+
141
+ def _forget_pty(self, sid: str) -> None:
142
+ with self._pty_lock:
143
+ self._ptys.pop(sid, None)
144
+
145
+ def pty(self, sid: str) -> Optional[Any]:
146
+ with self._pty_lock:
147
+ return self._ptys.get(sid)
148
+
149
+ def pty_input(self, sid: str, data_b64: str) -> Optional[str]:
150
+ entry = self.pty(sid)
151
+ if entry is None:
152
+ return f"unknown sid {sid!r}"
153
+ try:
154
+ entry.handle.write(base64.b64decode(data_b64))
155
+ except Exception as exc: # noqa: BLE001
156
+ return f"{type(exc).__name__}: {exc}"
157
+ return None
158
+
159
+ def pty_resize(self, sid: str, cols: int, rows: int) -> Optional[str]:
160
+ entry = self.pty(sid)
161
+ if entry is None:
162
+ return f"unknown sid {sid!r}"
163
+ try:
164
+ entry.handle.resize((int(cols), int(rows)))
165
+ except Exception as exc: # noqa: BLE001
166
+ return f"{type(exc).__name__}: {exc}"
167
+ return None
168
+
169
+ def pty_kill(self, sid: str) -> None:
170
+ entry = self.pty(sid)
171
+ if entry is not None:
172
+ entry.handle.kill()
173
+ self._forget_pty(sid)
174
+
175
+ def kill_all(self) -> None:
176
+ with self._pty_lock:
177
+ entries = list(self._ptys.values())
178
+ self._ptys.clear()
179
+ for entry in entries:
180
+ try:
181
+ entry.handle.kill()
182
+ except Exception: # noqa: BLE001
183
+ pass
184
+
185
+
186
+ # -- op dispatch ----------------------------------------------------------------
187
+ #
188
+ # Ops map straight onto the same application-layer code paths the one-shot
189
+ # CLI commands use (never argparse); each returns (data, exit_code, errors).
190
+ # serve ALWAYS answers with an envelope — stdout carries nothing else.
191
+
192
+
193
+ def _op_version(args: argparse.Namespace, payload: Dict[str, Any],
194
+ runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
195
+ from aisc.cli.main import _cmd_version
196
+
197
+ return _cmd_version(args).to_dict(), 0, []
198
+
199
+
200
+ def _op_doctor(args: argparse.Namespace, payload: Dict[str, Any],
201
+ runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
202
+ from aisc.cli.main import _cmd_doctor
203
+
204
+ data, report = _cmd_doctor(args, effective_format="json")
205
+ # doctor's own exit code (0 ok / 3 docker-unavailable) rides the envelope
206
+ # exactly as the one-shot CLI reports it.
207
+ return data, report.exit_code, []
208
+
209
+
210
+ def _op_ps(args: argparse.Namespace, payload: Dict[str, Any],
211
+ runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
212
+ from aisc.cli.main import _cmd_ps
213
+
214
+ return _cmd_ps(args, effective_format="json")
215
+
216
+
217
+ #: Commands that must never run inside serve's `cli` op (D-10): interactive /
218
+ #: TUI / streaming / self-referential — they would block the serial op loop
219
+ #: or capture the transport itself. PTY entry rides its own session.open op;
220
+ #: build stays on its per-op ssh event stream.
221
+ _SERVE_CLI_DENY = frozenset({
222
+ "serve", # recursion
223
+ "run", # interactive exec
224
+ "shell", # docker exec -it
225
+ "switch", # TUI
226
+ "build", # long streaming op (own channel)
227
+ "wizard", # interactive
228
+ "session", # streams ride session.open / pty.* frames
229
+ })
230
+
231
+ #: 2.1.11 P1 手测 r6: the bare `cc-switch` IS a TUI, but the workbench
232
+ #: Provider tab drives the six ENVELOPE subcommands over serve for remote
233
+ #: workspaces — denying the whole command made provider management
234
+ #: remote-impossible ('cc-switch' cannot run over serve). Allowlist mirrors
235
+ #: application/cc_switch_provider._OPS; anything else (bare TUI, future
236
+ #: subcommands) stays denied — default-deny keeps the serial op loop safe.
237
+ _SERVE_CC_SWITCH_OPS = frozenset({
238
+ "list", "add", "edit", "switch", "delete", "fetch-models",
239
+ })
240
+
241
+
242
+ def _op_cli(args: argparse.Namespace, payload: Dict[str, Any],
243
+ runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
244
+ """D-10 generic control-plane op: run one non-interactive CLI argv
245
+ in-process and return its envelope's (data, exit_code, errors).
246
+
247
+ Reuses the one-shot CLI's own dispatch (``main``) under captured
248
+ stdio — one op covers every envelope command, zero per-command
249
+ registration. Frame transport is unaffected by the capture: the loop
250
+ and PTY drains write through the ``ServeRuntime``'s cached stdout
251
+ handle, not the rebound ``sys.stdout``.
252
+ """
253
+ from aisc.cli.main import main as _cli_main, _detect_json_format
254
+
255
+ argv = [str(a) for a in (payload.get("argv") or [])]
256
+ if not argv:
257
+ raise CliError(message="cli op requires a non-empty argv",
258
+ exit_code=2, error_code="AISC_ERR_USAGE")
259
+ if argv[0] in _SERVE_CLI_DENY or (
260
+ argv[0] == "cc-switch"
261
+ and (len(argv) < 2 or argv[1] not in _SERVE_CC_SWITCH_OPS)
262
+ ):
263
+ raise CliError(
264
+ message=f"'{argv[0]}' cannot run over serve (interactive, "
265
+ f"streaming or self-referential)",
266
+ exit_code=2, error_code="AISC_ERR_USAGE",
267
+ hint="PTY sessions ride session.open; build keeps its ssh stream")
268
+ if "--events" in argv or "-h" in argv or "--help" in argv:
269
+ raise CliError(message="cli op rejects --events/-h/--help argv",
270
+ exit_code=2, error_code="AISC_ERR_USAGE")
271
+ # The op contract is the JSON envelope — force it for callers that
272
+ # omitted --format (same detector the one-shot CLI uses pre-parse).
273
+ if not _detect_json_format(argv):
274
+ argv = [*argv, "--format", "json"]
275
+
276
+ stdin_text = payload.get("stdin")
277
+ run_id = payload.get("run_id")
278
+ prev_run_id = os.environ.get("AISC_RUN_ID")
279
+ if isinstance(run_id, str) and run_id:
280
+ os.environ["AISC_RUN_ID"] = run_id
281
+
282
+ buf = io.StringIO()
283
+ code = 0
284
+ prev_stdin = sys.stdin
285
+ sys.stdin = io.StringIO(stdin_text if isinstance(stdin_text, str) else "")
286
+ try:
287
+ with contextlib.redirect_stdout(buf):
288
+ try:
289
+ _cli_main(argv)
290
+ except SystemExit as exc: # argparse errors print + exit
291
+ code = exc.code if isinstance(exc.code, int) else (0 if not exc.code else 2)
292
+ finally:
293
+ sys.stdin = prev_stdin
294
+ if prev_run_id is None:
295
+ os.environ.pop("AISC_RUN_ID", None)
296
+ else:
297
+ os.environ["AISC_RUN_ID"] = prev_run_id
298
+
299
+ text = buf.getvalue().strip()
300
+ if text:
301
+ try:
302
+ env = json.loads(text)
303
+ meta = env.get("meta") or {}
304
+ errors = list(env.get("errors") or [])
305
+ return (env.get("data"), int(meta.get("exit_code", code)), errors)
306
+ except ValueError:
307
+ pass
308
+ raise CliError(
309
+ message=f"cli op got non-envelope output: {text[:200]!r}",
310
+ exit_code=code or 2, error_code="AISC_ERR_SERVE_CLI_OUTPUT")
311
+
312
+
313
+ def _op_session_open(args: argparse.Namespace, payload: Dict[str, Any],
314
+ runtime: ServeRuntime) -> Tuple[Any, int, List[Dict[str, Any]]]:
315
+ """R2 (D-8): open one exec TTY as a stream; result frame reports the
316
+ exec establishment, then pty.output/exit stream frames follow."""
317
+ from aisc.adapters.docker_ import RealDockerExecutor
318
+ from aisc.adapters.docker_gateway import create_docker_gateway
319
+ from aisc.application.session import build_session_exec
320
+ from aisc.cli.commands.session import _resolve_workspace_and_registry
321
+
322
+ runtime_id = str(payload.get("runtime_id") or "")
323
+ session_id = str(payload.get("session_id") or "")
324
+ agent = str(payload.get("agent") or "")
325
+ workspace = payload.get("workspace")
326
+ resume = payload.get("resume_conversation_id")
327
+
328
+ # Resolution/validation speaks the DockerExecutor interface (run_captured
329
+ # etc. — RealDockerExecutor, docker CLI); the PTY stream is SDK-only
330
+ # (exec_resize, G-02). Two objects, two contracts.
331
+ executor = RealDockerExecutor()
332
+ gateway = create_docker_gateway("auto")
333
+ registry_root = _resolve_workspace_and_registry(
334
+ workspace if isinstance(workspace, str) and workspace else None
335
+ )[1]
336
+
337
+ container, docker_argv, env = build_session_exec(
338
+ runtime_id=runtime_id,
339
+ session_id=session_id,
340
+ agent=agent,
341
+ executor=executor,
342
+ registry_root=registry_root,
343
+ resume_conversation_id=resume if isinstance(resume, str) else None,
344
+ )
345
+
346
+ cols = int(payload.get("cols") or DEFAULT_COLS)
347
+ rows = int(payload.get("rows") or DEFAULT_ROWS)
348
+ handle = gateway.open_pty_stream(
349
+ container, docker_argv, env=env,
350
+ on_output=runtime._on_output(session_id), initial_size=(cols, rows),
351
+ )
352
+ runtime.register_pty(session_id, handle)
353
+ return {"session_id": session_id, "agent": agent, "container": container}, 0, []
354
+
355
+
356
+ OPS: Dict[str, Callable[..., Tuple[Any, int, List[Dict[str, Any]]]]] = {
357
+ "version": _op_version,
358
+ "doctor": _op_doctor,
359
+ "ps": _op_ps,
360
+ "session.open": _op_session_open,
361
+ "cli": _op_cli,
362
+ }
363
+
364
+ # R3 (D-9): the remote-authoritative file plane rides the same serve session.
365
+ from aisc.cli.commands.serve_fs import OPS as _FS_OPS # noqa: E402
366
+
367
+ OPS.update(_FS_OPS)
368
+
369
+ #: Ops that may not run while another op is executing — R1 is strictly
370
+ #: serial; the ``id`` field already exists so a concurrent executor can be
371
+ #: introduced later without a protocol change.
372
+
373
+
374
+ def _run_op(op: str, payload: Dict[str, Any], runtime: ServeRuntime) -> Dict[str, Any]:
375
+ """Execute one op and wrap it in a result frame (never raises)."""
376
+ ns = argparse.Namespace(aisc_root=None)
377
+ handler = OPS.get(op)
378
+ if handler is None:
379
+ return {
380
+ "id": None,
381
+ "type": "result",
382
+ "ok": False,
383
+ "error": f"unknown op {op!r} (known: {', '.join(sorted(OPS))})",
384
+ }
385
+
386
+ def _command_name() -> str:
387
+ """2.1.11 step2 (field fix): the ``cli`` op is a TRANSPARENT
388
+ passthrough — the envelope's command identity is the INNER command
389
+ (argv[0]), never the op name. Consumers validate ``meta.command``
390
+ (doctor/cache expect "doctor", "ps", ...); the transport must not
391
+ rewrite command identity. Symptom before: the doctor dialog died
392
+ with "unexpected command: cli"."""
393
+ if op == "cli":
394
+ argv = [str(a) for a in (payload.get("argv") or [])]
395
+ if argv:
396
+ return argv[0]
397
+ return op
398
+
399
+ try:
400
+ # Uniform handler signature (ns, payload, runtime) — R2's stream ops
401
+ # need the payload dict and the PTY registry; plain ops ignore them.
402
+ data, exit_code, errors = handler(ns, payload, runtime)
403
+ envelope = build_envelope(command=_command_name(), exit_code=exit_code,
404
+ version=_cli_version(), data=data, errors=errors)
405
+ return {"id": None, "type": "result", "ok": True, "envelope": envelope}
406
+ except CliError as exc:
407
+ envelope = build_envelope(
408
+ command=_command_name(),
409
+ exit_code=exc.exit_code,
410
+ version=_cli_version(),
411
+ errors=[{
412
+ "code": exc.error_code,
413
+ "message": exc.message,
414
+ **({"hint": exc.hint} if exc.hint else {}),
415
+ }],
416
+ )
417
+ return {"id": None, "type": "result", "ok": True, "envelope": envelope}
418
+ except NotImplementedError as exc:
419
+ # PTY transport on a CLI-only backend: an honest refusal, not a crash.
420
+ return {
421
+ "id": None,
422
+ "type": "result",
423
+ "ok": False,
424
+ "error": str(exc),
425
+ }
426
+ except Exception as exc: # noqa: BLE001 — serve must survive bad ops
427
+ return {
428
+ "id": None,
429
+ "type": "result",
430
+ "ok": False,
431
+ "error": f"{type(exc).__name__}: {exc}",
432
+ }
433
+
434
+
435
+ def _cli_version() -> str:
436
+ from aisc import __version__
437
+
438
+ return __version__
439
+
440
+
441
+ def cmd_serve(args: argparse.Namespace) -> int:
442
+ """Entry point. Only ``--stdio`` is supported — the TCP transport needs
443
+ its own security ruling (D-7) and does not exist."""
444
+ if not getattr(args, "stdio", False):
445
+ from aisc.domain.models import CliError as _CE
446
+
447
+ raise _CE(
448
+ message="serve requires --stdio (the only transport in this version)",
449
+ exit_code=2,
450
+ error_code="AISC_ERR_USAGE",
451
+ hint="Remote callers spawn: ssh <host> aisc serve --stdio",
452
+ )
453
+ # 2.1.11 step2: the wire contract is UTF-8 JSON-lines in BOTH
454
+ # directions. stdout is ASCII-safe via _frame's escaping, but the CLIENT
455
+ # writes unescaped UTF-8 (serde_json does not ASCII-escape) — a CJK
456
+ # workspace path in argv would hit a GBK stdin decoder on zh-CN Windows.
457
+ # Reconfigure the real handles where the wrapper allows it (PyInstaller
458
+ # wrappers may not expose reconfigure; the try/except is deliberate).
459
+ for handle in (sys.stdin, sys.stdout):
460
+ try:
461
+ handle.reconfigure(encoding="utf-8") # type: ignore[union-attr]
462
+ except (AttributeError, ValueError, OSError):
463
+ pass
464
+ return _serve_loop(sys.stdin, sys.stdout)
465
+
466
+
467
+ def _serve_loop(stdin: Any, stdout: Any) -> int:
468
+ from aisc import __version__
469
+
470
+ stop = {"flag": False}
471
+ runtime = ServeRuntime(stdout)
472
+
473
+ def _stop(_sig: int, _frm: Any = None) -> None:
474
+ stop["flag"] = True
475
+
476
+ for sig in (signal.SIGINT, signal.SIGTERM):
477
+ try:
478
+ signal.signal(sig, _stop)
479
+ except ValueError:
480
+ pass # non-main thread (tests): signals unavailable, EOF still ends us
481
+
482
+ # Ready banner — the client's version-pairing handshake (VS Code's
483
+ # commit-match equivalent): it can refuse the connection before any op.
484
+ # `home` (v1.3) anchors the remote-side directory browser (F2-B).
485
+ runtime._emit({
486
+ "type": "ready",
487
+ "serve_protocol": SERVE_PROTOCOL,
488
+ "cli_version": __version__,
489
+ "home": os.path.expanduser("~"),
490
+ })
491
+
492
+ while not stop["flag"]:
493
+ line = stdin.readline()
494
+ if not line: # EOF — the ssh session went away
495
+ break
496
+ line = line.strip()
497
+ if not line:
498
+ continue
499
+ frame_in: Dict[str, Any]
500
+ try:
501
+ frame_in = json.loads(line)
502
+ if not isinstance(frame_in, dict):
503
+ raise ValueError("frame must be a JSON object")
504
+ except ValueError as exc:
505
+ runtime._emit({"id": None, "type": "result", "ok": False,
506
+ "error": f"bad frame: {exc}"})
507
+ continue
508
+
509
+ ftype = frame_in.get("type")
510
+
511
+ # Stream-control frames (R2): fast, no result frame.
512
+ if ftype == "pty.input":
513
+ err = runtime.pty_input(str(frame_in.get("sid") or ""),
514
+ str(frame_in.get("data") or ""))
515
+ if err:
516
+ runtime._emit({"type": "log", "level": "error", "line": err})
517
+ continue
518
+ if ftype == "pty.resize":
519
+ err = runtime.pty_resize(str(frame_in.get("sid") or ""),
520
+ frame_in.get("cols") or DEFAULT_COLS,
521
+ frame_in.get("rows") or DEFAULT_ROWS)
522
+ if err:
523
+ runtime._emit({"type": "log", "level": "error", "line": err})
524
+ continue
525
+ if ftype == "pty.kill":
526
+ runtime.pty_kill(str(frame_in.get("sid") or ""))
527
+ continue
528
+
529
+ # Request frame: {"id", "op", "args"} — args is the payload dict.
530
+ op = frame_in.get("op")
531
+ if not isinstance(op, str):
532
+ runtime._emit({"id": frame_in.get("id"), "type": "result", "ok": False,
533
+ "error": "frame requires a string 'op'"})
534
+ continue
535
+ payload = frame_in.get("args")
536
+ if not isinstance(payload, dict):
537
+ payload = {}
538
+ response = _run_op(op, payload, runtime)
539
+ response["id"] = frame_in.get("id")
540
+ runtime._emit(response)
541
+
542
+ runtime.kill_all()
543
+ runtime.fs_watches.shutdown()
544
+ return 0
545
+
546
+
547
+ def print_serve_text(subcommand: str, data: Any, errors: list) -> None: # pragma: no cover
548
+ """Text rendering for serve is intentionally minimal: serve is a machine
549
+ protocol (RFC-adjacent); the interactive CLI should not reach here."""
550
+ print(json.dumps({"data": data, "errors": errors}, ensure_ascii=False))