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,369 @@
1
+ """serve fs.* ops — the remote-authoritative file plane (2.1.10 R3, D-9).
2
+
3
+ Every path is interpreted ON THE MACHINE THE SERVE PROCESS RUNS ON. The
4
+ Workbench never keeps a local copy: browsing, preview and writes all ride
5
+ these ops over the serve channel; watch events ride the reserved ``event``
6
+ frame.
7
+
8
+ Containment: paths are normalized (``..`` resolved) and rejected unless
9
+ inside the op's own root — the F1 browse-pinning lesson, server-side now.
10
+ watchdog is imported lazily; without it ``fs.watch`` degrades to a clean
11
+ "unsupported" error and the Workbench falls back to list polling (D-9).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import posixpath
18
+ import threading
19
+ import time
20
+ from typing import Any, Callable, Dict, List, Optional, Tuple
21
+
22
+ from aisc.domain.models import CliError
23
+
24
+ #: Page size aligned with the Workbench's Explorer cursor protocol (Rust
25
+ #: LIST_PAGE = 200).
26
+ LIST_PAGE = 200
27
+
28
+ #: Read budget aligned with the Workbench preview budget (512 KiB).
29
+ READ_BUDGET = 512 * 1024
30
+
31
+ #: Server-side ignore set — the equivalent of the Rust DEFAULT_IGNORE +
32
+ #: temp-file filter, so remote trees look exactly like local ones.
33
+ IGNORE_NAMES = {
34
+ ".git", ".aisc", ".claude", ".codex", ".cc-switch", ".local",
35
+ ".mcp.json", "node_modules", "target", "build", "dist", "tmp", "temp",
36
+ "__pycache__", ".venv", "venv",
37
+ }
38
+
39
+
40
+ def _is_temp_name(name: str) -> bool:
41
+ lower = name.lower()
42
+ return (
43
+ ".tmp." in lower
44
+ or lower.endswith((".tmp", ".temp", "~", ".swp", ".swo", ".aisc-tmp"))
45
+ or lower.startswith((".#", "~$"))
46
+ )
47
+
48
+
49
+ def _ignored(name: str) -> bool:
50
+ return name in IGNORE_NAMES or _is_temp_name(name)
51
+
52
+
53
+ def _resolve(root: str, rel: str) -> str:
54
+ """Resolve a ROOT-RELATIVE path (the Explorer's relativePath concept)
55
+ to a normalized absolute path inside *root*. Absolute-looking input and
56
+ `..` escapes are rejected — containment is server-side."""
57
+ root = posixpath.normpath(root)
58
+ rel = rel or ""
59
+ if rel.startswith("/") or rel.startswith("~"):
60
+ # Absolute/home paths are NOT rooted — reject before stripping so
61
+ # "/etc/passwd" can never silently become "<root>/etc/passwd".
62
+ raise CliError(message=f"path must be relative to the workspace root: {rel!r}",
63
+ exit_code=2, error_code="AISC_ERR_FS_PATH")
64
+ rel = rel.strip("/")
65
+ if not rel:
66
+ return root
67
+ joined = posixpath.normpath(posixpath.join(root, rel))
68
+ if joined != root and not joined.startswith(root.rstrip("/") + "/"):
69
+ raise CliError(message=f"path escapes the workspace root: {rel!r}",
70
+ exit_code=2, error_code="AISC_ERR_FS_CONTAINMENT")
71
+ return joined
72
+
73
+
74
+ def _rel(root: str, abs_path: str) -> str:
75
+ root = posixpath.normpath(root).rstrip("/")
76
+ rel = abs_path[len(root):].lstrip("/")
77
+ return rel
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # ops (uniform serve signature: (ns, payload, runtime) -> (data, exit, errs))
82
+
83
+
84
+ def op_fs_list(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
85
+ root = str(payload.get("root") or "/")
86
+ target = _resolve(root, str(payload.get("path") or ""))
87
+ if not os.path.isdir(target):
88
+ raise CliError(message=f"not a directory: {payload.get('path') or '/'}",
89
+ exit_code=1, error_code="AISC_ERR_FS_NOT_DIR")
90
+ try:
91
+ names = sorted(os.listdir(target))
92
+ except OSError as exc:
93
+ raise CliError(message=f"listdir failed: {exc}", exit_code=1,
94
+ error_code="AISC_ERR_FS_IO") from exc
95
+ offset = int(payload.get("offset") or 0)
96
+ page = names[offset:offset + LIST_PAGE]
97
+ entries = []
98
+ for name in page:
99
+ if _ignored(name):
100
+ continue
101
+ full = os.path.join(target, name)
102
+ try:
103
+ st = os.stat(full, follow_symlinks=False)
104
+ kind = "dir" if os.path.isdir(full) else "file"
105
+ entries.append({"name": name, "kind": kind, "size": st.st_size,
106
+ "modified": int(st.st_mtime)})
107
+ except OSError:
108
+ entries.append({"name": name, "kind": "file", "size": 0, "modified": 0})
109
+ data: Dict[str, Any] = {"entries": entries}
110
+ if offset + LIST_PAGE < len(names):
111
+ data["nextOffset"] = offset + LIST_PAGE
112
+ return data, 0, []
113
+
114
+
115
+ def op_fs_read(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
116
+ import base64
117
+
118
+ root = str(payload.get("root") or "/")
119
+ target = _resolve(root, str(payload.get("path") or ""))
120
+ budget = int(payload.get("maxBytes") or READ_BUDGET)
121
+ try:
122
+ st = os.stat(target)
123
+ except OSError as exc:
124
+ raise CliError(message=f"stat failed: {exc}", exit_code=1,
125
+ error_code="AISC_ERR_FS_IO") from exc
126
+ if os.path.isdir(target):
127
+ raise CliError(message="path is a directory", exit_code=1,
128
+ error_code="AISC_ERR_FS_IS_DIR")
129
+ truncated = st.st_size > budget
130
+ with open(target, "rb") as f:
131
+ blob = f.read(budget)
132
+ return {
133
+ "base64": base64.b64encode(blob).decode("ascii"),
134
+ "size": st.st_size,
135
+ "truncated": truncated,
136
+ "modified": int(st.st_mtime),
137
+ }, 0, []
138
+
139
+
140
+ def op_fs_write(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
141
+ import base64
142
+
143
+ root = str(payload.get("root") or "/")
144
+ target = _resolve(root, str(payload.get("path") or ""))
145
+ blob = base64.b64decode(str(payload.get("base64") or ""))
146
+ atomic = _atomic_writer(target, blob)
147
+ if atomic is not None:
148
+ raise atomic
149
+ return {"bytes": len(blob)}, 0, []
150
+
151
+
152
+ def _atomic_writer(target: str, blob: bytes) -> Optional[CliError]:
153
+ try:
154
+ tmp = target + ".aisc-tmp"
155
+ with open(tmp, "wb") as f:
156
+ f.write(blob)
157
+ os.replace(tmp, target)
158
+ return None
159
+ except OSError as exc:
160
+ try:
161
+ os.unlink(target + ".aisc-tmp")
162
+ except OSError:
163
+ pass
164
+ return CliError(message=f"write failed: {exc}", exit_code=1,
165
+ error_code="AISC_ERR_FS_IO")
166
+
167
+
168
+ def op_fs_mkdir(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
169
+ root = str(payload.get("root") or "/")
170
+ target = _resolve(root, str(payload.get("path") or ""))
171
+ try:
172
+ os.makedirs(target, exist_ok=True)
173
+ except OSError as exc:
174
+ raise CliError(message=f"mkdir failed: {exc}", exit_code=1,
175
+ error_code="AISC_ERR_FS_IO") from exc
176
+ return {}, 0, []
177
+
178
+
179
+ def op_fs_rename(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
180
+ root = str(payload.get("root") or "/")
181
+ src = _resolve(root, str(payload.get("from") or ""))
182
+ dst = _resolve(root, str(payload.get("to") or ""))
183
+ try:
184
+ os.replace(src, dst)
185
+ except OSError as exc:
186
+ raise CliError(message=f"rename failed: {exc}", exit_code=1,
187
+ error_code="AISC_ERR_FS_IO") from exc
188
+ return {}, 0, []
189
+
190
+
191
+ def op_fs_delete(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
192
+ root = str(payload.get("root") or "/")
193
+ target = _resolve(root, str(payload.get("path") or ""))
194
+ if target == posixpath.normpath(root):
195
+ raise CliError(message="refusing to delete the workspace root",
196
+ exit_code=2, error_code="AISC_ERR_FS_CONTAINMENT")
197
+ try:
198
+ if os.path.isdir(target):
199
+ import shutil
200
+
201
+ shutil.rmtree(target)
202
+ else:
203
+ os.unlink(target)
204
+ except OSError as exc:
205
+ raise CliError(message=f"delete failed: {exc}", exit_code=1,
206
+ error_code="AISC_ERR_FS_IO") from exc
207
+ return {}, 0, []
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # watch (watchdog, lazy) — events ride the reserved `event` frame
212
+
213
+
214
+ class WatchRegistry:
215
+ """One watchdog observer per serve process; per-root handlers batch
216
+ events (100ms debounce) into a single fs.change frame per burst."""
217
+
218
+ def __init__(self, emit: Callable[[Dict[str, Any]], None]) -> None:
219
+ self._emit_cb = emit
220
+ self._lock = threading.Lock()
221
+ self._observer: Any = None
222
+ self._roots: Dict[str, Any] = {}
223
+ self._pending: Dict[str, Dict[str, Tuple[str, str]]] = {} # root -> {abspath: (change, kind)}
224
+ self._flush_timer: Optional[threading.Timer] = None
225
+
226
+ def _emit(self, frame: Dict[str, Any]) -> None:
227
+ try:
228
+ self._emit_cb(frame)
229
+ except Exception: # noqa: BLE001 — emit must never kill the watcher
230
+ pass
231
+
232
+ def available(self) -> bool:
233
+ try:
234
+ import watchdog # noqa: F401
235
+
236
+ return True
237
+ except ImportError:
238
+ return False
239
+
240
+ def watch(self, root: str) -> None:
241
+ try:
242
+ from watchdog.events import FileSystemEventHandler
243
+ from watchdog.observers import Observer
244
+ except ImportError as exc:
245
+ raise CliError(message="fs.watch unsupported: watchdog unavailable",
246
+ exit_code=3, error_code="AISC_ERR_FS_WATCH_UNSUPPORTED") from exc
247
+ root = posixpath.normpath(root)
248
+ if not os.path.isdir(root):
249
+ raise CliError(message=f"not a directory: {root}",
250
+ exit_code=1, error_code="AISC_ERR_FS_NOT_DIR")
251
+ with self._lock:
252
+ if root in self._roots:
253
+ return
254
+ if self._observer is None:
255
+ self._observer = Observer()
256
+ self._observer.start()
257
+
258
+ reg = self
259
+
260
+ # watchdog event_type -> the Workbench change vocabulary
261
+ # (notify's classification). moved: from-half reports deleted,
262
+ # to-half reports created — the same semantics as the local
263
+ # watcher's rename handling (Stage 11).
264
+ def _change_type(event: Any) -> str:
265
+ et = getattr(event, "event_type", "")
266
+ return {
267
+ "created": "created",
268
+ "deleted": "deleted",
269
+ "modified": "modified",
270
+ "moved": "created",
271
+ "closed": "modified",
272
+ }.get(et, "modified")
273
+
274
+ class Handler(FileSystemEventHandler):
275
+ def on_any_event(self, event: Any) -> None:
276
+ path = getattr(event, "dest_path", None) or event.src_path
277
+ if not path:
278
+ return
279
+ name = os.path.basename(str(path))
280
+ if _ignored(name):
281
+ return
282
+ kind = "dir" if event.is_directory else "file"
283
+ reg._record(root, str(path), kind, _change_type(event))
284
+
285
+ watch = self._observer.schedule(Handler(), root, recursive=True)
286
+ self._roots[root] = watch
287
+
288
+ def unwatch(self, root: str) -> None:
289
+ root = posixpath.normpath(root)
290
+ with self._lock:
291
+ watch = self._roots.pop(root, None)
292
+ if watch is not None and self._observer is not None:
293
+ self._observer.unschedule(watch)
294
+ if not self._roots and self._observer is not None:
295
+ self._observer.stop()
296
+ self._observer = None
297
+
298
+ def _record(self, root: str, abs_path: str, kind: str, change: str) -> None:
299
+ with self._lock:
300
+ # last-write-wins on the change type; created is sticky so a
301
+ # create+write burst surfaces as "created" (batcher parity)
302
+ prev = self._pending.get(root, {}).get(abs_path)
303
+ if prev == ("created", kind):
304
+ change = "created"
305
+ self._pending.setdefault(root, {})[abs_path] = (change, kind)
306
+ if self._flush_timer is None:
307
+ self._flush_timer = threading.Timer(0.1, self._flush)
308
+ self._flush_timer.daemon = True
309
+ self._flush_timer.start()
310
+
311
+ def _flush(self) -> None:
312
+ with self._lock:
313
+ self._flush_timer = None
314
+ batches = self._pending
315
+ self._pending = {}
316
+ for root, paths in batches.items():
317
+ self._emit({
318
+ "type": "event",
319
+ "event": "fs.change",
320
+ "data": {
321
+ "root": root,
322
+ "paths": [
323
+ {"path": p, "change": c, "kind": k}
324
+ for p, (c, k) in sorted(paths.items())
325
+ ],
326
+ },
327
+ })
328
+
329
+ def shutdown(self) -> None:
330
+ # Grace for in-flight inotify callbacks (events land a few ms after
331
+ # the op result frame), then flush any debounced batch BEFORE
332
+ # stopping — the serve loop's EOF teardown would drop the last burst
333
+ # otherwise.
334
+ time.sleep(0.15)
335
+ if self._flush_timer is not None:
336
+ self._flush_timer.cancel()
337
+ self._flush()
338
+ with self._lock:
339
+ obs, self._observer = self._observer, None
340
+ self._roots.clear()
341
+ if obs is not None:
342
+ obs.stop()
343
+
344
+
345
+ def op_fs_watch(_ns, payload: Dict[str, Any], runtime) -> Tuple[Any, int, list]:
346
+ root = str(payload.get("root") or "")
347
+ if not root.startswith("/"):
348
+ raise CliError(message="fs.watch requires an absolute root",
349
+ exit_code=2, error_code="AISC_ERR_FS_PATH")
350
+ runtime.fs_watches.watch(root)
351
+ return {"root": posixpath.normpath(root)}, 0, []
352
+
353
+
354
+ def op_fs_unwatch(_ns, payload: Dict[str, Any], runtime) -> Tuple[Any, int, list]:
355
+ root = posixpath.normpath(str(payload.get("root") or "/"))
356
+ runtime.fs_watches.unwatch(root)
357
+ return {}, 0, []
358
+
359
+
360
+ OPS: Dict[str, Callable[..., Tuple[Any, int, list]]] = {
361
+ "fs.list": op_fs_list,
362
+ "fs.read": op_fs_read,
363
+ "fs.write": op_fs_write,
364
+ "fs.mkdir": op_fs_mkdir,
365
+ "fs.rename": op_fs_rename,
366
+ "fs.delete": op_fs_delete,
367
+ "fs.watch": op_fs_watch,
368
+ "fs.unwatch": op_fs_unwatch,
369
+ }
@@ -0,0 +1,167 @@
1
+ """Session commands implementation.
2
+
3
+ Implements ``aisc session`` subcommands: open, list, terminate.
4
+ Per docs/gui-planning/05-cli-gui-contract.md §6.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from aisc.adapters.docker_ import DockerExecutor, RealDockerExecutor
13
+ from aisc.application.session import (
14
+ list_sessions,
15
+ open_session,
16
+ terminate_session,
17
+ validate_agent,
18
+ validate_session_id,
19
+ )
20
+ from aisc.domain.models import (
21
+ CliError,
22
+ RuntimeErrorCode,
23
+ RuntimeExitCode,
24
+ SessionAgent,
25
+ )
26
+
27
+
28
+ def _resolve_workspace_and_registry(workspace: Optional[str]) -> Tuple[str, Path]:
29
+ """Return (canonical_workspace_str, registry_root_path).
30
+
31
+ Stage 7: the registry root is the data-root state dir
32
+ (``workspaces/<hash>/runtime``), never a workspace path (DATA-01);
33
+ legacy ``<workspace>/.aisc`` state is adopted on first use.
34
+ """
35
+ from aisc.application.data_root import workspace_state_dir
36
+
37
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
38
+ return str(ws_path), workspace_state_dir(ws_path)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # session open
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def cmd_session_open(
46
+ runtime_id: str,
47
+ session_id: str,
48
+ agent: str,
49
+ workspace: Optional[str] = None,
50
+ executor: Optional[DockerExecutor] = None,
51
+ resume_conversation_id: Optional[str] = None,
52
+ ) -> Tuple[Dict[str, Any], int]:
53
+ """Execute ``aisc session open`` per contract §6.1.
54
+
55
+ This is a text-only interactive command. It validates inputs, resolves
56
+ the running container, then runs ``docker exec -it`` with inherited
57
+ stdio. The agent's exit code becomes the process exit code.
58
+
59
+ v2.1.8 T4: ``resume_conversation_id`` forwards to the wrapper
60
+ (``--resume-id``) for provider-native conversation resume.
61
+
62
+ Returns (data_dict, exit_code). The caller is expected to ``sys.exit()``
63
+ with the exit code; the interactive streams are already inherited.
64
+ """
65
+ exec_ = executor or RealDockerExecutor()
66
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
67
+
68
+ proc = open_session(
69
+ runtime_id=runtime_id,
70
+ session_id=session_id,
71
+ agent=agent,
72
+ executor=exec_,
73
+ registry_root=reg_root,
74
+ resume_conversation_id=resume_conversation_id,
75
+ )
76
+
77
+ exit_code = proc.exit_code if proc.exit_code >= 0 else 1
78
+
79
+ data: Dict[str, Any] = {
80
+ "runtime_id": runtime_id,
81
+ "session_id": session_id,
82
+ "agent": agent,
83
+ "exit_code": exit_code,
84
+ }
85
+ if proc.command_not_found:
86
+ # 2.1.9 hotfix (#61): open_interactive packs the real failure text
87
+ # (daemon unreachable / exec create / exec start / transport) into
88
+ # stderr — surface it so print_session_text shows the true cause in
89
+ # the terminal instead of a misleading "docker command not found".
90
+ data["error"] = (proc.stderr or "").strip()[:200] or "docker command not found"
91
+ elif proc.timed_out:
92
+ data["error"] = "session timed out"
93
+
94
+ return data, exit_code
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # session list
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def cmd_session_list(
102
+ runtime_id: str,
103
+ workspace: Optional[str] = None,
104
+ executor: Optional[DockerExecutor] = None,
105
+ ) -> Dict[str, Any]:
106
+ """Execute ``aisc session list`` per contract §6.2."""
107
+ exec_ = executor or RealDockerExecutor()
108
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
109
+
110
+ sessions = list_sessions(
111
+ runtime_id=runtime_id,
112
+ executor=exec_,
113
+ registry_root=reg_root,
114
+ )
115
+ return {"sessions": sessions, "count": len(sessions)}
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # session terminate
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def cmd_session_terminate(
123
+ runtime_id: str,
124
+ session_id: str,
125
+ workspace: Optional[str] = None,
126
+ executor: Optional[DockerExecutor] = None,
127
+ grace_seconds: float = 5.0,
128
+ ) -> Dict[str, Any]:
129
+ """Execute ``aisc session terminate`` per contract §6.2."""
130
+ exec_ = executor or RealDockerExecutor()
131
+ _ws, reg_root = _resolve_workspace_and_registry(workspace)
132
+
133
+ result = terminate_session(
134
+ runtime_id=runtime_id,
135
+ session_id=session_id,
136
+ executor=exec_,
137
+ registry_root=reg_root,
138
+ grace_seconds=grace_seconds,
139
+ )
140
+ return result
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Text output
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def print_session_text(subcommand: str, data: Any, errors: list) -> None:
148
+ """Minimal human-readable output for ``aisc session`` text mode."""
149
+ if errors:
150
+ for e in errors:
151
+ print(f"Error: {e.get('message', '')}")
152
+ return
153
+ if not isinstance(data, dict):
154
+ return
155
+ if subcommand == "open":
156
+ print(f"session {data.get('session_id', '')} agent={data.get('agent', '')} "
157
+ f"exit_code={data.get('exit_code', '')}")
158
+ elif subcommand == "list":
159
+ sessions = data.get("sessions", [])
160
+ if not sessions:
161
+ print("(no sessions)")
162
+ for s in sessions:
163
+ print(f"{s.get('session_id', '')} agent={s.get('agent', '')} "
164
+ f"state={s.get('state', '')} pid={s.get('pid', '')}")
165
+ elif subcommand == "terminate":
166
+ print(f"session {data.get('session_id', '')} state={data.get('state', '')} "
167
+ f"exit_code={data.get('exit_code', '')}")
@@ -0,0 +1,145 @@
1
+ """``aisc update`` command surface (0.1.0 A4, D-7).
2
+
3
+ ``--check`` prints the plan; the bare form performs the frozen-exe update.
4
+ Envelope-compatible JSON in ``--format json``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ import aisc
13
+ from aisc.application.bundle_fetch import BundleFetchError, BundleFetcher
14
+ from aisc.application.update import (
15
+ UPDATE_ERROR_STATE,
16
+ UPDATE_ERROR_USAGE,
17
+ UpdateError,
18
+ build_check_report,
19
+ detect_install_form,
20
+ perform_update,
21
+ )
22
+ from aisc.domain.models import CliError
23
+
24
+
25
+ def _root_for_tools() -> Path:
26
+ from aisc.application.resources import locate_aisc_root
27
+
28
+ root = locate_aisc_root()
29
+ if root is None:
30
+ raise CliError(
31
+ message="tool pins need an AISC root (repo or bundle) to layer "
32
+ "against — run `aisc bundle fetch` first or use --aisc-root",
33
+ exit_code=1, error_code="AISC_ERR_GENERAL")
34
+ return root
35
+
36
+
37
+ def _data_root() -> Path:
38
+ from aisc.application.data_root import shared_root
39
+
40
+ return shared_root()
41
+
42
+
43
+ def _to_cli_error(exc: Exception) -> CliError:
44
+ if isinstance(exc, (UpdateError, BundleFetchError)):
45
+ return CliError(message=exc.message, exit_code=1, error_code="AISC_ERR_GENERAL")
46
+ raise exc
47
+
48
+
49
+ def cmd_update_check(version: Optional[str] = None) -> Dict[str, Any]:
50
+ from aisc.application.tool_versions import tools_check_section
51
+
52
+ form = detect_install_form()
53
+ try:
54
+ fetcher = BundleFetcher()
55
+ report = build_check_report(
56
+ cli_version=aisc.__version__,
57
+ form=form,
58
+ fetcher=fetcher,
59
+ requested_version=version,
60
+ )
61
+ except (UpdateError, BundleFetchError) as exc:
62
+ raise _to_cli_error(exc) from exc
63
+ # A5: the tools block is network-tolerant (latest=null on registry
64
+ # failure) — a check must never fail because the network did.
65
+ report["tools"] = tools_check_section(_root_for_tools())
66
+ return report
67
+
68
+
69
+ def cmd_update(
70
+ *,
71
+ version: Optional[str] = None,
72
+ from_file: Optional[str] = None,
73
+ sha256: Optional[str] = None,
74
+ rebuild: bool = False,
75
+ pin_tool: Optional[list] = None,
76
+ ) -> Dict[str, Any]:
77
+ if pin_tool:
78
+ from aisc.application.tool_versions import write_user_pins
79
+
80
+ pins: Dict[str, str] = {}
81
+ for spec in pin_tool:
82
+ if "=" not in spec:
83
+ raise CliError(
84
+ message=f"--pin-tool expects KEY=VALUE, got {spec!r} "
85
+ f"(pinnable: CLAUDE_CODE_VERSION, CODEX_VERSION, CC_SWITCH_VERSION)",
86
+ exit_code=2, error_code="AISC_ERR_USAGE")
87
+ k, v = spec.split("=", 1)
88
+ pins[k.strip()] = v.strip()
89
+ _root_for_tools() # fail fast when there is no root to layer against
90
+ try:
91
+ path = write_user_pins(_data_root(), pins)
92
+ except ValueError as exc:
93
+ raise CliError(message=str(exc), exit_code=2,
94
+ error_code="AISC_ERR_USAGE") from exc
95
+ return {
96
+ "status": "pinned",
97
+ "path": str(path),
98
+ "pins": pins,
99
+ "note": "pins apply at the next image build (aisc build / docker-rebuild); "
100
+ "they survive CLI updates, installer upgrades and bundle fetches",
101
+ }
102
+ form = detect_install_form()
103
+ try:
104
+ return perform_update(
105
+ cli_version=aisc.__version__,
106
+ form=form,
107
+ fetcher=BundleFetcher(),
108
+ version=version,
109
+ from_file=Path(from_file) if from_file else None,
110
+ sha256=sha256,
111
+ rebuild=rebuild,
112
+ )
113
+ except (UpdateError, BundleFetchError) as exc:
114
+ raise _to_cli_error(exc) from exc
115
+
116
+
117
+ def print_update_text(sub: str, data: Any) -> None:
118
+ if not isinstance(data, dict):
119
+ return
120
+ if sub == "check":
121
+ print(f"form : {data.get('form')}")
122
+ print(f"current : {data.get('cli_version')}")
123
+ print(f"latest : {data.get('latest_final') or '(none)'}")
124
+ print(f"action : {data.get('action')}")
125
+ if data.get("update_available") is not None:
126
+ print(f"available: {data.get('update_available')}")
127
+ for t in data.get("tools") or []:
128
+ latest = t.get("latest") or "unknown"
129
+ mark = "*" if t.get("source") == "user" else " "
130
+ print(f" tool {mark} {t.get('tool')}: current {t.get('current')} "
131
+ f"({t.get('source')}) | latest {latest}")
132
+ else:
133
+ if data.get("status") == "pinned":
134
+ print(f"status : pinned -> {data.get('path')}")
135
+ for k, v in (data.get("pins") or {}).items():
136
+ print(f" {k}={v}")
137
+ print(f"note : {data.get('note')}")
138
+ return
139
+ print(f"status: {data.get('status')}")
140
+ if data.get("to_version"):
141
+ print(f"version: {data.get('from_version')} -> {data.get('to_version')}")
142
+ if data.get("preserved_old_exe"):
143
+ print(f"old exe kept at: {data['preserved_old_exe']} (rollback copy)")
144
+ if data.get("rebuild_exit") is not None:
145
+ print(f"rebuild exit: {data['rebuild_exit']}")