msdev 0.9.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.
msdev/cli.py ADDED
@@ -0,0 +1,1460 @@
1
+ """msdev client CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import signal
9
+ import sys
10
+ import threading
11
+ import time
12
+ from dataclasses import asdict
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .core.guides import (
17
+ EnvironmentGuideStore,
18
+ MAX_GUIDE_BYTES,
19
+ NodeGuideStore,
20
+ )
21
+ from .core.limits import DEFAULT_EXEC_TIMEOUT_SECONDS, MAX_INVENTORY_CONTENT_BYTES
22
+ from .core.services import (
23
+ EnvironmentAddRequest,
24
+ EnvironmentService,
25
+ ExecRequest,
26
+ ExecutionService,
27
+ ModelAuditRequest,
28
+ ModelDiscoverRequest,
29
+ ModelImportRequest,
30
+ ModelListRequest,
31
+ ModelRefRequest,
32
+ ModelRegisterRequest,
33
+ ModelRebindRequest,
34
+ ModelRefreshRequest,
35
+ ModelReplicaAddRequest,
36
+ ModelReplicaRemoveRequest,
37
+ ModelService,
38
+ ModelUnregisterRequest,
39
+ ModelUpdateRequest,
40
+ ModelValidateRequest,
41
+ ModelVerifyRequest,
42
+ NpuService,
43
+ ServiceContext,
44
+ NodeAddRequest,
45
+ NodeService,
46
+ WorkspaceAddRequest,
47
+ WorkspaceService,
48
+ )
49
+ from .core.resources import EnvironmentStore, NodeStore
50
+ from .core.transport import (
51
+ RpcError,
52
+ SshRpcTransport,
53
+ UnixRpcTransport,
54
+ bootstrap_node,
55
+ )
56
+ from .core.workspace import WorkspaceStore
57
+ from .session.log import CliOperationLogger, begin_session, monotonic_ms
58
+
59
+
60
+ class _StrictArgumentParser(argparse.ArgumentParser):
61
+ def __init__(self, *args: Any, **kwargs: Any):
62
+ kwargs.setdefault("allow_abbrev", False)
63
+ super().__init__(*args, **kwargs)
64
+
65
+
66
+ def _print_json(value: Any) -> None:
67
+ print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
68
+
69
+
70
+ def _human_size(value: int | None) -> str:
71
+ size = float(value or 0)
72
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
73
+ if size < 1024 or unit == "TiB":
74
+ return f"{size:.1f}{unit}"
75
+ size /= 1024
76
+ return f"{size:.1f}TiB"
77
+
78
+
79
+ def _service_context(
80
+ node_store: NodeStore,
81
+ environment_store: EnvironmentStore,
82
+ workspace_store: WorkspaceStore | None = None,
83
+ ) -> ServiceContext:
84
+ values: dict[str, Any] = {
85
+ "node_store": node_store,
86
+ "environment_store": environment_store,
87
+ "create_transport": lambda node, environment=None: (
88
+ UnixRpcTransport()
89
+ if node is None
90
+ else SshRpcTransport(node, environment)
91
+ ),
92
+ "bootstrap": bootstrap_node,
93
+ }
94
+ if workspace_store is not None:
95
+ values["workspace_store"] = workspace_store
96
+ return ServiceContext(
97
+ **values,
98
+ )
99
+
100
+
101
+ def _parse_root_maps(values: list[str]) -> tuple[tuple[str, str], ...]:
102
+ result: list[tuple[str, str]] = []
103
+ for value in values:
104
+ old, separator, new = value.partition("=")
105
+ if not separator or not old or not new:
106
+ raise ValueError(f"invalid root mapping {value!r}; expected OLD=NEW")
107
+ result.append((old, new))
108
+ return tuple(result)
109
+
110
+
111
+ def _read_inventory(path: Path) -> str:
112
+ with path.open("rb") as handle:
113
+ raw = handle.read(MAX_INVENTORY_CONTENT_BYTES + 1)
114
+ if len(raw) > MAX_INVENTORY_CONTENT_BYTES:
115
+ raise ValueError(
116
+ f"inventory export exceeds {MAX_INVENTORY_CONTENT_BYTES} bytes: "
117
+ f"{path}"
118
+ )
119
+ content = raw.decode("utf-8")
120
+ if not content.splitlines():
121
+ raise ValueError(f"empty inventory export: {path}")
122
+ return content
123
+
124
+
125
+ def _write_inventory(path: Path, content: str) -> None:
126
+ path.parent.mkdir(parents=True, exist_ok=True)
127
+ path.write_text(content, encoding="utf-8")
128
+
129
+
130
+ def _command_guide(
131
+ args: argparse.Namespace,
132
+ guide_store: NodeGuideStore | EnvironmentGuideStore,
133
+ resource_type: str,
134
+ ) -> int:
135
+ if args.write is not None:
136
+ if args.write == "-":
137
+ raw = sys.stdin.buffer.read(MAX_GUIDE_BYTES + 1)
138
+ else:
139
+ with Path(args.write).open("rb") as handle:
140
+ raw = handle.read(MAX_GUIDE_BYTES + 1)
141
+ guide_store.write_bytes(args.name, raw)
142
+ print(f"updated guide for {resource_type} {args.name}")
143
+ return 0
144
+ if args.delete:
145
+ deleted = guide_store.delete(args.name)
146
+ action = "deleted guide for" if deleted else "no guide configured for"
147
+ print(f"{action} {resource_type} {args.name}")
148
+ return 0
149
+ content = guide_store.read(args.name)
150
+ if content is None:
151
+ print(f"no guide configured for {resource_type} {args.name}")
152
+ else:
153
+ print(content, end="" if content.endswith("\n") else "\n")
154
+ return 0
155
+
156
+
157
+ def command_node(
158
+ args: argparse.Namespace,
159
+ node_store: NodeStore,
160
+ environment_store: EnvironmentStore,
161
+ ) -> int:
162
+ service = NodeService(_service_context(node_store, environment_store))
163
+ action = args.node_command
164
+ if action == "guide":
165
+ if args.name != "local":
166
+ node_store.get(args.name)
167
+ return _command_guide(args, NodeGuideStore(), "node")
168
+ if action == "env":
169
+ node = (
170
+ service.update_variables(
171
+ args.name,
172
+ set_values=tuple(args.set_values),
173
+ unset_keys=tuple(args.unset_keys),
174
+ )
175
+ if args.set_values or args.unset_keys
176
+ else node_store.get(args.name)
177
+ )
178
+ for assignment in node.variables:
179
+ print(assignment)
180
+ return 0
181
+ if action == "add":
182
+ node = service.add(
183
+ NodeAddRequest(
184
+ name=args.name,
185
+ ssh_host=args.ssh_host,
186
+ remote_bin=args.remote_bin,
187
+ auto_bootstrap=not args.no_auto_bootstrap,
188
+ force=args.force,
189
+ )
190
+ )
191
+ print(
192
+ f"added node {node.name} (SSH Host {node.ssh_host}); "
193
+ f"created host environment {node.name}"
194
+ )
195
+ return 0
196
+ if action == "list":
197
+ print("local")
198
+ for node in service.list().nodes:
199
+ print(
200
+ f"{node.name}\tssh={node.ssh_host}\t"
201
+ f"auto-bootstrap={'on' if node.auto_bootstrap else 'off'}\t"
202
+ f"bin={node.remote_bin}"
203
+ )
204
+ return 0
205
+ if action == "remove":
206
+ removed = service.remove(args.name, cascade=args.cascade)
207
+ print(f"removed node {args.name}")
208
+ if removed:
209
+ print("removed environments: " + ", ".join(removed))
210
+ return 0
211
+ if action == "status":
212
+ print(f"node: {args.name}")
213
+ _print_json(service.status(args.name))
214
+ return 0
215
+ if action == "bootstrap":
216
+ result = service.bootstrap(args.name, args.package_path)
217
+ print(f"bootstrapped node {args.name}")
218
+ _print_json(result)
219
+ return 0
220
+ if action in {"connect", "disconnect"}:
221
+ result = (
222
+ service.connect(args.name, all_nodes=args.all)
223
+ if action == "connect"
224
+ else service.disconnect(args.name, all_nodes=args.all)
225
+ )
226
+ for item in result.items:
227
+ if item.error is not None:
228
+ print(f"{item.node}: error: {item.error}", file=sys.stderr)
229
+ elif action == "connect":
230
+ print(
231
+ f"{item.node}: {item.state} to {item.ssh_host} "
232
+ f"(persist={item.persist})"
233
+ )
234
+ else:
235
+ print(f"{item.node}: {item.state}")
236
+ return result.exit_code
237
+ raise ValueError(f"unsupported node command: {action}")
238
+
239
+
240
+ def command_environment(
241
+ args: argparse.Namespace,
242
+ node_store: NodeStore,
243
+ environment_store: EnvironmentStore,
244
+ ) -> int:
245
+ service = EnvironmentService(_service_context(node_store, environment_store))
246
+ action = args.environment_command
247
+ if action == "guide":
248
+ environment_store.get(args.name)
249
+ return _command_guide(args, EnvironmentGuideStore(), "environment")
250
+ if action == "shell":
251
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
252
+ raise ValueError("environment shell requires an interactive terminal")
253
+ return service.shell(args.name, cwd=args.cwd)
254
+ if action == "add":
255
+ environment = service.add(
256
+ EnvironmentAddRequest(
257
+ name=args.name,
258
+ node=args.node,
259
+ docker_container=args.docker_container,
260
+ layers=tuple(args.layer),
261
+ force=args.force,
262
+ )
263
+ )
264
+ print(f"added environment {environment.name} on node {environment.node}")
265
+ return 0
266
+ if action == "list":
267
+ print("local")
268
+ for environment in service.list().environments:
269
+ runtime = (
270
+ f"docker:{environment.container}"
271
+ if environment.runtime == "docker"
272
+ else "host"
273
+ )
274
+ layers = ">".join(environment.layers) if environment.layers else "-"
275
+ print(
276
+ f"{environment.name}\tnode={environment.node}\t"
277
+ f"runtime={runtime}\tlayers={layers}"
278
+ )
279
+ return 0
280
+ if action == "inspect":
281
+ _print_json(asdict(service.inspect(args.name)))
282
+ return 0
283
+ if action == "remove":
284
+ service.remove(args.name)
285
+ print(f"removed environment {args.name}")
286
+ return 0
287
+ raise ValueError(f"unsupported environment command: {action}")
288
+
289
+
290
+ def command_npu(
291
+ args: argparse.Namespace,
292
+ node_store: NodeStore,
293
+ environment_store: EnvironmentStore,
294
+ ) -> int:
295
+ service = NpuService(_service_context(node_store, environment_store))
296
+ if args.all:
297
+ service_result = service.list(all_nodes=True)
298
+ results = {
299
+ item.node: {
300
+ "ssh_host": item.ssh_host,
301
+ **(
302
+ {"error": item.error}
303
+ if item.error is not None
304
+ else {"result": item.result}
305
+ ),
306
+ }
307
+ for item in service_result.items
308
+ }
309
+ if args.json:
310
+ _print_json(results)
311
+ else:
312
+ for item in service_result.items:
313
+ value = results[item.node]
314
+ print(f"===== {item.node} ({item.ssh_host}) =====")
315
+ if "error" in value:
316
+ print(f"error: {value['error']}", file=sys.stderr)
317
+ continue
318
+ result = value["result"]
319
+ if result.get("reason"):
320
+ print(result["reason"], file=sys.stderr)
321
+ print(result.get("output", ""), end="")
322
+ return service_result.exit_code
323
+
324
+ service_result = service.list(node=args.node)
325
+ item = service_result.items[0]
326
+ result = item.result or {}
327
+ if args.json:
328
+ _print_json({"node": item.node, **result})
329
+ else:
330
+ print(f"node: {item.node}")
331
+ if result.get("reason"):
332
+ print(result["reason"], file=sys.stderr)
333
+ print(result.get("output", ""), end="")
334
+ return service_result.exit_code
335
+
336
+
337
+ def command_model(
338
+ args: argparse.Namespace,
339
+ node_store: NodeStore,
340
+ environment_store: EnvironmentStore,
341
+ ) -> int:
342
+ service = ModelService(_service_context(node_store, environment_store))
343
+ if args.model_command == "register":
344
+ result = service.register(
345
+ ModelRegisterRequest(
346
+ node=args.node,
347
+ ref=args.ref,
348
+ path=args.path,
349
+ scope="user",
350
+ format_name=args.format,
351
+ full_checksum=args.full_checksum,
352
+ )
353
+ )
354
+ print(f"registered on {result.node}: {args.ref}")
355
+ _print_json(result.value)
356
+ return result.exit_code
357
+ if args.model_command == "discover":
358
+ result = service.discover(
359
+ ModelDiscoverRequest(
360
+ node=args.node,
361
+ root=args.root,
362
+ max_depth=args.max_depth,
363
+ register=args.register,
364
+ namespace=args.namespace,
365
+ )
366
+ )
367
+ if args.json:
368
+ _print_json(result.value)
369
+ return result.exit_code
370
+ models = result.value["models"]
371
+ action = "registered" if args.register else "found"
372
+ print(
373
+ f"{action} {len(models)} model(s) under {result.value['root']} "
374
+ f"on {result.node}"
375
+ )
376
+ if models:
377
+ print("REF\tFORMAT\tSIZE\tFILES\tPATH")
378
+ for item in models:
379
+ print(
380
+ f"{item['ref']}\t{item['format']}\t"
381
+ f"{_human_size(item['size_bytes'])}\t"
382
+ f"{item['file_count']}\t{item['path']}"
383
+ )
384
+ return result.exit_code
385
+ if args.model_command == "list":
386
+ result = service.list(
387
+ ModelListRequest(
388
+ node=args.node,
389
+ all_nodes=args.all,
390
+ format_name=args.format_filter,
391
+ model_type=args.model_type,
392
+ state=args.state,
393
+ tag=args.tag_filter,
394
+ name=args.name_filter,
395
+ include_deleted=args.include_deleted,
396
+ )
397
+ )
398
+ if args.all:
399
+ nodes = [
400
+ {
401
+ "node": item.node,
402
+ "ssh_host": item.ssh_host,
403
+ "models": list(item.models),
404
+ }
405
+ for item in result.nodes
406
+ ]
407
+ errors = [
408
+ {
409
+ "node": item.node,
410
+ "ssh_host": item.ssh_host,
411
+ "error": item.error,
412
+ }
413
+ for item in result.errors
414
+ ]
415
+ if args.json:
416
+ _print_json({"nodes": nodes, "errors": errors})
417
+ else:
418
+ rows = [
419
+ (node.node, model)
420
+ for node in result.nodes
421
+ for model in node.models
422
+ ]
423
+ if rows:
424
+ print("NODE\tREF\tSTATE\tVERIFY\tSIZE\tTAGS\tPATH")
425
+ for node_name, item in rows:
426
+ print(
427
+ f"{node_name}\t{item['ref']}\t"
428
+ f"{item.get('state') or '-'}\t"
429
+ f"{item.get('verification_level') or '-'}\t"
430
+ f"{_human_size(item.get('size_bytes'))}\t"
431
+ f"{','.join(item.get('tags') or []) or '-'}\t"
432
+ f"{item.get('path') or '-'}"
433
+ )
434
+ else:
435
+ print("no models registered across SSH nodes")
436
+ for error in result.errors:
437
+ print(
438
+ f"{error.node} ({error.ssh_host}): "
439
+ f"error: {error.error}",
440
+ file=sys.stderr,
441
+ )
442
+ return result.exit_code
443
+
444
+ node = result.nodes[0]
445
+ models = node.models
446
+ if args.json:
447
+ _print_json(list(models))
448
+ return result.exit_code
449
+ if not models:
450
+ print(f"no models registered on {node.node}")
451
+ return result.exit_code
452
+ print("REF\tSTATE\tVERIFY\tSIZE\tTAGS\tPATH")
453
+ for item in models:
454
+ print(
455
+ f"{item['ref']}\t{item.get('state') or '-'}\t"
456
+ f"{item.get('verification_level') or '-'}\t"
457
+ f"{_human_size(item.get('size_bytes'))}\t"
458
+ f"{','.join(item.get('tags') or []) or '-'}\t"
459
+ f"{item.get('path') or '-'}"
460
+ )
461
+ return result.exit_code
462
+ if args.model_command == "inspect":
463
+ result = service.inspect(ModelRefRequest(args.node, args.ref))
464
+ _print_json(result.value)
465
+ return result.exit_code
466
+ if args.model_command == "update":
467
+ result = service.update(
468
+ ModelUpdateRequest(
469
+ node=args.node,
470
+ ref=args.ref,
471
+ add_aliases=tuple(args.add_alias),
472
+ remove_aliases=tuple(args.remove_alias),
473
+ add_tags=tuple(args.add_tag),
474
+ remove_tags=tuple(args.remove_tag),
475
+ description=args.description,
476
+ clear_description=args.clear_description,
477
+ )
478
+ )
479
+ _print_json(result.value)
480
+ return result.exit_code
481
+ if args.model_command == "delete":
482
+ result = service.delete(ModelRefRequest(args.node, args.ref))
483
+ _print_json(result.value)
484
+ return result.exit_code
485
+ if args.model_command == "restore":
486
+ result = service.restore(ModelRefRequest(args.node, args.ref))
487
+ _print_json(result.value)
488
+ return result.exit_code
489
+ if args.model_command == "refresh":
490
+ result = service.refresh(
491
+ ModelRefreshRequest(
492
+ node=args.node,
493
+ ref=args.ref,
494
+ path=args.path,
495
+ full_checksum=args.full_checksum,
496
+ )
497
+ )
498
+ _print_json(result.value)
499
+ return result.exit_code
500
+ if args.model_command == "validate":
501
+ result = service.validate(
502
+ ModelValidateRequest(
503
+ node=args.node,
504
+ ref=args.ref,
505
+ path=args.path,
506
+ )
507
+ )
508
+ _print_json(result.value)
509
+ return result.exit_code
510
+ if args.model_command == "audit":
511
+ result = service.audit(
512
+ ModelAuditRequest(
513
+ node=args.node,
514
+ ref=args.ref,
515
+ limit=args.limit,
516
+ )
517
+ )
518
+ _print_json(result.value)
519
+ return result.exit_code
520
+ if args.model_command == "replica":
521
+ if args.replica_command == "list":
522
+ result = service.replica_list(
523
+ ModelRefRequest(args.node, args.ref)
524
+ )
525
+ _print_json(result.value)
526
+ return result.exit_code
527
+ if args.replica_command == "add":
528
+ result = service.replica_add(
529
+ ModelReplicaAddRequest(
530
+ node=args.node,
531
+ ref=args.ref,
532
+ path=args.path,
533
+ full_checksum=args.full_checksum,
534
+ )
535
+ )
536
+ _print_json(result.value)
537
+ return result.exit_code
538
+ if args.replica_command == "remove":
539
+ result = service.replica_remove(
540
+ ModelReplicaRemoveRequest(
541
+ node=args.node,
542
+ ref=args.ref,
543
+ path=args.path,
544
+ )
545
+ )
546
+ _print_json(result.value)
547
+ return result.exit_code
548
+ if args.model_command == "verify":
549
+ result = service.verify(
550
+ ModelVerifyRequest(
551
+ node=args.node,
552
+ ref=args.ref,
553
+ full_checksum=args.full_checksum,
554
+ )
555
+ )
556
+ _print_json(result.value)
557
+ return result.exit_code
558
+ if args.model_command == "unregister":
559
+ result = service.unregister(
560
+ ModelUnregisterRequest(
561
+ node=args.node,
562
+ ref=args.ref,
563
+ path=args.path,
564
+ )
565
+ )
566
+ _print_json(result.value)
567
+ return result.exit_code
568
+ if args.model_command == "export":
569
+ result = service.export(args.node)
570
+ _write_inventory(args.output, result.jsonl)
571
+ print(
572
+ f"exported {len(result.payload.get('artifacts', []))} artifacts "
573
+ f"from {result.node} to {args.output}"
574
+ )
575
+ return result.exit_code
576
+ if args.model_command == "import":
577
+ result = service.import_records(
578
+ ModelImportRequest(
579
+ node=args.node,
580
+ jsonl=_read_inventory(args.input),
581
+ root_maps=_parse_root_maps(args.map_root),
582
+ )
583
+ )
584
+ _print_json(result.value)
585
+ return result.exit_code
586
+ if args.model_command == "rebind":
587
+ result = service.rebind(
588
+ ModelRebindRequest(
589
+ node=args.node,
590
+ ref=args.ref,
591
+ path=args.path,
592
+ scope="user",
593
+ full_checksum=args.full_checksum,
594
+ )
595
+ )
596
+ _print_json(result.value)
597
+ return result.exit_code
598
+ raise ValueError(f"unsupported model command: {args.model_command}")
599
+
600
+
601
+ def command_exec(
602
+ args: argparse.Namespace,
603
+ node_store: NodeStore,
604
+ environment_store: EnvironmentStore,
605
+ ) -> int:
606
+ command = list(args.argv)
607
+ if command and command[0] == "--":
608
+ command = command[1:]
609
+ if not command:
610
+ raise ValueError("missing command after `msdev exec --`")
611
+ env: dict[str, str] = {}
612
+ for value in args.env_var:
613
+ key, separator, item = value.partition("=")
614
+ if not separator or not key:
615
+ raise ValueError(f"invalid environment assignment: {value!r}")
616
+ env[key] = item
617
+ request = ExecRequest(
618
+ environment=args.environment,
619
+ argv=tuple(command),
620
+ cwd=args.cwd,
621
+ env=env,
622
+ timeout_seconds=args.timeout_seconds,
623
+ )
624
+ service = ExecutionService(_service_context(node_store, environment_store))
625
+ if args.result_json:
626
+ result = service.run(request)
627
+ _print_json(result.to_dict())
628
+ else:
629
+ def on_output(stream_name: str, chunk: bytes) -> None:
630
+ destination = (
631
+ sys.stdout if stream_name == "stdout" else sys.stderr
632
+ )
633
+ binary = getattr(destination, "buffer", None)
634
+ if binary is not None:
635
+ binary.write(chunk)
636
+ binary.flush()
637
+ else:
638
+ destination.write(chunk.decode("utf-8", errors="replace"))
639
+ destination.flush()
640
+
641
+ result = service.stream(request, on_output)
642
+ return result.returncode
643
+
644
+
645
+ def _print_workspace_git_result(
646
+ result: Any,
647
+ *,
648
+ result_json: bool = False,
649
+ ) -> int:
650
+ if result.stdout:
651
+ print(result.stdout, end="")
652
+ if result.stderr:
653
+ print(result.stderr, end="", file=sys.stderr)
654
+ if result.stdout_truncated or result.stderr_truncated:
655
+ print(
656
+ "warning: remote command output was truncated at the daemon limit",
657
+ file=sys.stderr,
658
+ )
659
+ if result_json:
660
+ _print_json(result.to_dict())
661
+ return result.returncode
662
+
663
+
664
+ def _workspace_write_content(args: argparse.Namespace) -> bytes:
665
+ if args.content is not None:
666
+ return args.content.encode("utf-8")
667
+ if args.file is not None:
668
+ return args.file.read_bytes()
669
+ return sys.stdin.buffer.read()
670
+
671
+
672
+ def command_workspace(
673
+ args: argparse.Namespace,
674
+ node_store: NodeStore,
675
+ environment_store: EnvironmentStore,
676
+ workspace_store: WorkspaceStore,
677
+ ) -> int:
678
+ action = args.workspace_command
679
+ service = WorkspaceService(
680
+ _service_context(node_store, environment_store, workspace_store)
681
+ )
682
+ if action == "add":
683
+ workspace = service.add(
684
+ WorkspaceAddRequest(
685
+ name=args.name,
686
+ environment=args.environment,
687
+ root=args.root,
688
+ execution_root=args.execution_root,
689
+ force=args.force,
690
+ )
691
+ )
692
+ print(
693
+ f"added workspace {workspace.name} "
694
+ f"(environment={workspace.environment}, root={workspace.root})"
695
+ )
696
+ return 0
697
+ if action == "list" and args.name is None:
698
+ workspaces = service.list().workspaces
699
+ if args.json:
700
+ _print_json([workspace.__dict__ for workspace in workspaces])
701
+ else:
702
+ for workspace in workspaces:
703
+ print(
704
+ f"{workspace.name}\tenvironment={workspace.environment}\t"
705
+ f"root={workspace.root}\t"
706
+ f"execution-root={workspace.execution_root or workspace.root}"
707
+ )
708
+ return 0
709
+ if action == "inspect":
710
+ _print_json(service.inspect(args.name).to_dict())
711
+ return 0
712
+ if action == "remove":
713
+ result = service.remove(args.name)
714
+ print(f"removed workspace {result.name}")
715
+ return 0
716
+
717
+ if action == "stat":
718
+ _print_json(service.stat(args.name, args.path).raw)
719
+ return 0
720
+ if action == "read":
721
+ result = service.read(
722
+ args.name,
723
+ args.path,
724
+ max_bytes=args.max_bytes,
725
+ )
726
+ if args.json:
727
+ _print_json(result.raw)
728
+ elif args.output:
729
+ args.output.write_bytes(result.content)
730
+ else:
731
+ sys.stdout.buffer.write(result.content)
732
+ sys.stdout.buffer.flush()
733
+ return 0
734
+ if action in {"list", "list-files"}:
735
+ result = service.list_directory(args.name, args.path).raw
736
+ if args.json:
737
+ _print_json(result)
738
+ else:
739
+ for item in result["entries"]:
740
+ print(f"{item['type']}\t{item['size'] if item['size'] is not None else '-'}\t{item['path']}")
741
+ return 0
742
+ if action == "glob":
743
+ result = service.glob(
744
+ args.name,
745
+ args.pattern,
746
+ max_results=args.max_results,
747
+ )
748
+ if args.json:
749
+ _print_json(result.raw)
750
+ else:
751
+ for path in result.matches:
752
+ print(path)
753
+ return 0
754
+ if action == "search":
755
+ result = service.search(
756
+ args.name,
757
+ args.pattern,
758
+ paths=args.paths,
759
+ glob=args.glob_filter,
760
+ max_results=args.max_results,
761
+ )
762
+ if args.json:
763
+ _print_json(result.raw)
764
+ else:
765
+ for item in result.matches:
766
+ print(f"{item['path']}:{item['line']}:{item['text']}")
767
+ return 0
768
+ if action in {"write", "apply-patch"}:
769
+ content = _workspace_write_content(args)
770
+ if action == "apply-patch":
771
+ result = service.apply_patch(
772
+ args.name,
773
+ args.path,
774
+ content,
775
+ expected_sha256=args.expected_sha256,
776
+ )
777
+ else:
778
+ result = service.write(args.name, args.path, content)
779
+ _print_json(result.raw)
780
+ return 0
781
+ if action == "delete":
782
+ _print_json(service.delete(args.name, args.path).raw)
783
+ return 0
784
+ if action == "git-status":
785
+ return _print_workspace_git_result(
786
+ service.git_status(args.name),
787
+ result_json=args.result_json,
788
+ )
789
+ if action == "git-diff":
790
+ return _print_workspace_git_result(
791
+ service.git_diff(args.name, args.argv),
792
+ result_json=args.result_json,
793
+ )
794
+ raise ValueError(f"unsupported workspace command: {action}")
795
+
796
+
797
+ def command_session(args: argparse.Namespace) -> int:
798
+ from .session.export import (
799
+ iter_events,
800
+ list_sessions,
801
+ reject_output_alias,
802
+ render_markdown,
803
+ resolve_session_sources,
804
+ session_log_location,
805
+ write_markdown_atomic,
806
+ )
807
+
808
+ if args.session_command == "begin":
809
+ session = begin_session(args.name)
810
+ value = {
811
+ "session_id": session.session_id,
812
+ "path": str(session.path),
813
+ }
814
+ if args.json:
815
+ _print_json(value)
816
+ else:
817
+ print(session.session_id)
818
+ print(session.path)
819
+ return 0
820
+ if args.session_command == "list":
821
+ sessions = list_sessions(session_log_location())
822
+ if not sessions:
823
+ print("no CLI session logs found")
824
+ return 0
825
+ for session in sessions:
826
+ print(f"{session.name}\t{session.size} bytes\tmtime_ns={session.mtime_ns}")
827
+ return 0
828
+ if args.session_command == "export":
829
+ sources = resolve_session_sources(
830
+ latest=args.latest,
831
+ inputs=args.input,
832
+ session_id=args.export_session_id,
833
+ )
834
+ chunks = render_markdown(iter_events(sources), args.detail)
835
+ if args.output is None:
836
+ for chunk in chunks:
837
+ sys.stdout.write(chunk)
838
+ sys.stdout.flush()
839
+ else:
840
+ reject_output_alias(args.output, sources)
841
+ write_markdown_atomic(args.output, chunks)
842
+ print(f"exported session runbook to {args.output}")
843
+ return 0
844
+ raise ValueError(f"unsupported session command: {args.session_command}")
845
+
846
+
847
+ def build_parser() -> argparse.ArgumentParser:
848
+ parser = _StrictArgumentParser(
849
+ prog="msdev",
850
+ description="Manage msdev nodes and execution environments",
851
+ )
852
+ parser.add_argument(
853
+ "--config",
854
+ type=Path,
855
+ default=None,
856
+ help="override local node/environment registry path",
857
+ )
858
+ parser.add_argument(
859
+ "--workspace-config",
860
+ type=Path,
861
+ default=None,
862
+ help="override local workspace registry path",
863
+ )
864
+ parser.add_argument(
865
+ "--session-id",
866
+ default=os.environ.get("MSDEV_SESSION_ID"),
867
+ help="explicit CLI session used for operation logs",
868
+ )
869
+ parser.add_argument(
870
+ "--intent-kind",
871
+ choices=("execution", "verification", "diagnostic", "exploration"),
872
+ default=os.environ.get("MSDEV_INTENT_KIND"),
873
+ )
874
+ parser.add_argument(
875
+ "--intent-summary",
876
+ default=os.environ.get("MSDEV_INTENT_SUMMARY"),
877
+ )
878
+ parser.add_argument(
879
+ "--intent-phase",
880
+ default=os.environ.get("MSDEV_INTENT_PHASE"),
881
+ )
882
+ parser.add_argument(
883
+ "--intent-step-id",
884
+ default=os.environ.get("MSDEV_INTENT_STEP_ID"),
885
+ )
886
+ sub = parser.add_subparsers(dest="command", required=True)
887
+
888
+ node = sub.add_parser("node", help="manage SSH connection nodes")
889
+ node_sub = node.add_subparsers(dest="node_command", required=True)
890
+ node_add = node_sub.add_parser("add")
891
+ node_add.add_argument("name")
892
+ node_add.add_argument("--ssh-host", help="OpenSSH Host alias; defaults to name")
893
+ node_add.add_argument("--remote-bin", default="~/.local/bin/msdevd")
894
+ node_add.add_argument(
895
+ "--force",
896
+ action="store_true",
897
+ help="replace an existing node and its same-named host environment",
898
+ )
899
+ node_add.add_argument(
900
+ "--no-auto-bootstrap",
901
+ action="store_true",
902
+ help="do not install/start msdevd automatically on first use",
903
+ )
904
+ node_sub.add_parser("list")
905
+ node_env = node_sub.add_parser(
906
+ "env",
907
+ help="read or update persistent host environment variables",
908
+ )
909
+ node_env.add_argument("name")
910
+ node_env.add_argument(
911
+ "--set",
912
+ dest="set_values",
913
+ action="append",
914
+ default=[],
915
+ metavar="KEY=VALUE",
916
+ )
917
+ node_env.add_argument(
918
+ "--unset",
919
+ dest="unset_keys",
920
+ action="append",
921
+ default=[],
922
+ metavar="KEY",
923
+ )
924
+ node_guide = node_sub.add_parser(
925
+ "guide",
926
+ help="read or maintain the local Markdown guide for a node",
927
+ )
928
+ node_guide.add_argument("name")
929
+ node_guide_action = node_guide.add_mutually_exclusive_group()
930
+ node_guide_action.add_argument(
931
+ "--write",
932
+ metavar="FILE",
933
+ help="replace the guide from FILE, or - for stdin",
934
+ )
935
+ node_guide_action.add_argument(
936
+ "--delete",
937
+ action="store_true",
938
+ help="delete the local guide without removing the node",
939
+ )
940
+ node_remove = node_sub.add_parser("remove")
941
+ node_remove.add_argument("name")
942
+ node_remove.add_argument(
943
+ "--cascade",
944
+ action="store_true",
945
+ help="also remove every environment on this node",
946
+ )
947
+ node_status = node_sub.add_parser("status")
948
+ node_status.add_argument("name")
949
+ node_bootstrap = node_sub.add_parser("bootstrap")
950
+ node_bootstrap.add_argument("name")
951
+ node_bootstrap.add_argument("--package-path", type=Path)
952
+ node_connect = node_sub.add_parser(
953
+ "connect",
954
+ help="open a persistent SSH master and prompt once for authentication",
955
+ )
956
+ node_connect.add_argument("name", nargs="?")
957
+ node_connect.add_argument("--all", action="store_true")
958
+ node_disconnect = node_sub.add_parser(
959
+ "disconnect",
960
+ help="close a persistent SSH master",
961
+ )
962
+ node_disconnect.add_argument("name", nargs="?")
963
+ node_disconnect.add_argument("--all", action="store_true")
964
+
965
+ environment = sub.add_parser(
966
+ "env",
967
+ help="manage host, Docker, conda, venv, and uv execution environments",
968
+ )
969
+ environment_sub = environment.add_subparsers(
970
+ dest="environment_command",
971
+ required=True,
972
+ )
973
+ environment_add = environment_sub.add_parser("add")
974
+ environment_add.add_argument("name")
975
+ environment_add.add_argument("--node", required=True)
976
+ environment_add.add_argument("--docker-container")
977
+ environment_add.add_argument(
978
+ "--layer",
979
+ action="append",
980
+ default=[],
981
+ metavar="TYPE:VALUE",
982
+ help=(
983
+ "append an environment layer; repeat for nesting "
984
+ "(conda:<name-or-prefix>, venv:<path>, uv:<project-path>)"
985
+ ),
986
+ )
987
+ environment_add.add_argument("--force", action="store_true")
988
+ environment_sub.add_parser("list")
989
+ environment_inspect = environment_sub.add_parser("inspect")
990
+ environment_inspect.add_argument("name")
991
+ environment_guide = environment_sub.add_parser("guide")
992
+ environment_guide.add_argument("name")
993
+ environment_guide_action = environment_guide.add_mutually_exclusive_group()
994
+ environment_guide_action.add_argument("--write", metavar="FILE")
995
+ environment_guide_action.add_argument("--delete", action="store_true")
996
+ environment_shell = environment_sub.add_parser("shell")
997
+ environment_shell.add_argument("name")
998
+ environment_shell.add_argument("--cwd")
999
+ environment_remove = environment_sub.add_parser("remove")
1000
+ environment_remove.add_argument("name")
1001
+
1002
+ workspace = sub.add_parser(
1003
+ "workspace",
1004
+ help="manage and access explicit remote workspaces",
1005
+ )
1006
+ workspace_sub = workspace.add_subparsers(
1007
+ dest="workspace_command",
1008
+ required=True,
1009
+ )
1010
+ workspace_add = workspace_sub.add_parser("add")
1011
+ workspace_add.add_argument("name")
1012
+ workspace_add.add_argument("--env", dest="environment", required=True)
1013
+ workspace_add.add_argument("--root", required=True)
1014
+ workspace_add.add_argument(
1015
+ "--execution-root",
1016
+ help="command cwd in the environment runtime",
1017
+ )
1018
+ workspace_add.add_argument("--force", action="store_true")
1019
+
1020
+ workspace_list = workspace_sub.add_parser(
1021
+ "list",
1022
+ help="list registered workspaces, or list a named workspace directory",
1023
+ )
1024
+ workspace_list.add_argument("name", nargs="?")
1025
+ workspace_list.add_argument("path", nargs="?", default=".")
1026
+ workspace_list.add_argument("--json", action="store_true")
1027
+
1028
+ workspace_inspect = workspace_sub.add_parser("inspect")
1029
+ workspace_inspect.add_argument("name")
1030
+ workspace_remove = workspace_sub.add_parser("remove")
1031
+ workspace_remove.add_argument("name")
1032
+
1033
+ workspace_stat = workspace_sub.add_parser("stat")
1034
+ workspace_stat.add_argument("name")
1035
+ workspace_stat.add_argument("path", nargs="?", default=".")
1036
+
1037
+ workspace_read = workspace_sub.add_parser("read")
1038
+ workspace_read.add_argument("name")
1039
+ workspace_read.add_argument("path")
1040
+ workspace_read.add_argument("--max-bytes", type=int, default=1024 * 1024)
1041
+ workspace_read.add_argument("--output", type=Path)
1042
+ workspace_read.add_argument("--json", action="store_true")
1043
+
1044
+ workspace_glob = workspace_sub.add_parser("glob")
1045
+ workspace_glob.add_argument("name")
1046
+ workspace_glob.add_argument("pattern")
1047
+ workspace_glob.add_argument("--max-results", type=int, default=1000)
1048
+ workspace_glob.add_argument("--json", action="store_true")
1049
+
1050
+ workspace_search = workspace_sub.add_parser("search")
1051
+ workspace_search.add_argument("name")
1052
+ workspace_search.add_argument("pattern")
1053
+ workspace_search.add_argument("paths", nargs="*", default=["."])
1054
+ workspace_search.add_argument("--glob", dest="glob_filter")
1055
+ workspace_search.add_argument("--max-results", type=int, default=1000)
1056
+ workspace_search.add_argument("--json", action="store_true")
1057
+
1058
+ for command_name, help_text in (
1059
+ ("write", "atomically replace a workspace file"),
1060
+ (
1061
+ "apply-patch",
1062
+ "compare-and-swap a workspace file using its expected SHA256",
1063
+ ),
1064
+ ):
1065
+ write_parser = workspace_sub.add_parser(command_name, help=help_text)
1066
+ write_parser.add_argument("name")
1067
+ write_parser.add_argument("path")
1068
+ content_source = write_parser.add_mutually_exclusive_group()
1069
+ content_source.add_argument("--content")
1070
+ content_source.add_argument("--file", type=Path)
1071
+ if command_name == "apply-patch":
1072
+ write_parser.add_argument("--expected-sha256", required=True)
1073
+
1074
+ workspace_delete = workspace_sub.add_parser("delete")
1075
+ workspace_delete.add_argument("name")
1076
+ workspace_delete.add_argument("path")
1077
+
1078
+ workspace_git_status = workspace_sub.add_parser("git-status")
1079
+ workspace_git_status.add_argument("name")
1080
+ workspace_git_status.add_argument("--result-json", action="store_true")
1081
+ workspace_git_diff = workspace_sub.add_parser("git-diff")
1082
+ workspace_git_diff.add_argument("name")
1083
+ workspace_git_diff.add_argument("--result-json", action="store_true")
1084
+ workspace_git_diff.add_argument("argv", nargs=argparse.REMAINDER)
1085
+
1086
+ npu = sub.add_parser("npu", help="inspect NPU state")
1087
+ npu_sub = npu.add_subparsers(dest="npu_command", required=True)
1088
+ npu_list = npu_sub.add_parser("list")
1089
+ npu_scope = npu_list.add_mutually_exclusive_group(required=True)
1090
+ npu_scope.add_argument("--node")
1091
+ npu_scope.add_argument(
1092
+ "--all",
1093
+ action="store_true",
1094
+ help="query every registered SSH node",
1095
+ )
1096
+ npu_list.add_argument("--json", action="store_true")
1097
+
1098
+ model = sub.add_parser("model", help="manage model inventory")
1099
+ model_sub = model.add_subparsers(dest="model_command", required=True)
1100
+ register = model_sub.add_parser("register")
1101
+ register.add_argument("--node", required=True)
1102
+ register.add_argument("--ref", required=True)
1103
+ register.add_argument("--path", required=True)
1104
+ register.add_argument("--format")
1105
+ register.add_argument("--full-checksum", action="store_true")
1106
+
1107
+ discover = model_sub.add_parser(
1108
+ "discover",
1109
+ help="find model snapshots under a remote directory",
1110
+ )
1111
+ discover.add_argument("--node", required=True)
1112
+ discover.add_argument("--root", required=True)
1113
+ discover.add_argument("--max-depth", type=int, default=5)
1114
+ discover.add_argument("--namespace", default="discovered")
1115
+ discover.add_argument(
1116
+ "--register",
1117
+ action="store_true",
1118
+ help="register discovered snapshots after scanning",
1119
+ )
1120
+ discover.add_argument("--json", action="store_true")
1121
+
1122
+ model_list = model_sub.add_parser("list")
1123
+ model_list_scope = model_list.add_mutually_exclusive_group(required=True)
1124
+ model_list_scope.add_argument("--node")
1125
+ model_list_scope.add_argument(
1126
+ "--all",
1127
+ action="store_true",
1128
+ help="aggregate inventories from every registered SSH node",
1129
+ )
1130
+ model_list.add_argument("--format", dest="format_filter")
1131
+ model_list.add_argument("--model-type")
1132
+ model_list.add_argument(
1133
+ "--state",
1134
+ choices=["ready", "dirty", "missing", "broken"],
1135
+ )
1136
+ model_list.add_argument("--tag", dest="tag_filter")
1137
+ model_list.add_argument("--name", dest="name_filter")
1138
+ model_list.add_argument("--include-deleted", action="store_true")
1139
+ model_list.add_argument("--json", action="store_true")
1140
+
1141
+ inspect = model_sub.add_parser("inspect")
1142
+ inspect.add_argument("ref")
1143
+ inspect.add_argument("--node", required=True)
1144
+
1145
+ update = model_sub.add_parser("update")
1146
+ update.add_argument("ref")
1147
+ update.add_argument("--node", required=True)
1148
+ update.add_argument("--add-alias", action="append", default=[])
1149
+ update.add_argument("--remove-alias", action="append", default=[])
1150
+ update.add_argument("--add-tag", action="append", default=[])
1151
+ update.add_argument("--remove-tag", action="append", default=[])
1152
+ description_group = update.add_mutually_exclusive_group()
1153
+ description_group.add_argument("--description")
1154
+ description_group.add_argument("--clear-description", action="store_true")
1155
+
1156
+ delete = model_sub.add_parser("delete", help="soft-delete model metadata")
1157
+ delete.add_argument("ref")
1158
+ delete.add_argument("--node", required=True)
1159
+
1160
+ restore = model_sub.add_parser("restore")
1161
+ restore.add_argument("ref")
1162
+ restore.add_argument("--node", required=True)
1163
+
1164
+ refresh = model_sub.add_parser("refresh")
1165
+ refresh.add_argument("ref")
1166
+ refresh.add_argument("--node", required=True)
1167
+ refresh.add_argument("--path")
1168
+ refresh.add_argument("--full-checksum", action="store_true")
1169
+
1170
+ validate = model_sub.add_parser("validate")
1171
+ validate.add_argument("ref")
1172
+ validate.add_argument("--node", required=True)
1173
+ validate.add_argument("--path")
1174
+
1175
+ audit = model_sub.add_parser("audit")
1176
+ audit.add_argument("--ref")
1177
+ audit.add_argument("--node", required=True)
1178
+ audit.add_argument("--limit", type=int, default=100)
1179
+
1180
+ replica = model_sub.add_parser("replica")
1181
+ replica_sub = replica.add_subparsers(dest="replica_command", required=True)
1182
+ replica_list = replica_sub.add_parser("list")
1183
+ replica_list.add_argument("ref")
1184
+ replica_list.add_argument("--node", required=True)
1185
+ replica_add = replica_sub.add_parser("add")
1186
+ replica_add.add_argument("ref")
1187
+ replica_add.add_argument("--node", required=True)
1188
+ replica_add.add_argument("--path", required=True)
1189
+ replica_add.add_argument("--full-checksum", action="store_true")
1190
+ replica_remove = replica_sub.add_parser("remove")
1191
+ replica_remove.add_argument("ref")
1192
+ replica_remove.add_argument("--node", required=True)
1193
+ replica_remove.add_argument("--path", required=True)
1194
+
1195
+ verify = model_sub.add_parser("verify")
1196
+ verify.add_argument("ref")
1197
+ verify.add_argument("--node", required=True)
1198
+ verify.add_argument("--full-checksum", action="store_true")
1199
+
1200
+ unregister = model_sub.add_parser("unregister")
1201
+ unregister.add_argument("ref")
1202
+ unregister.add_argument("--path")
1203
+ unregister.add_argument("--node", required=True)
1204
+
1205
+ export = model_sub.add_parser("export")
1206
+ export.add_argument("--output", type=Path, required=True)
1207
+ export.add_argument("--node", required=True)
1208
+
1209
+ import_parser = model_sub.add_parser("import")
1210
+ import_parser.add_argument("input", type=Path)
1211
+ import_parser.add_argument("--map-root", action="append", default=[], metavar="OLD=NEW")
1212
+ import_parser.add_argument("--node", required=True)
1213
+
1214
+ rebind = model_sub.add_parser("rebind")
1215
+ rebind.add_argument("ref")
1216
+ rebind.add_argument("--path", required=True)
1217
+ rebind.add_argument("--full-checksum", action="store_true")
1218
+ rebind.add_argument("--node", required=True)
1219
+
1220
+ execute = sub.add_parser(
1221
+ "exec",
1222
+ help="execute a command in an explicit environment",
1223
+ )
1224
+ execute.add_argument("--env", dest="environment", required=True)
1225
+ execute.add_argument("--cwd")
1226
+ execute.add_argument(
1227
+ "--env-var",
1228
+ action="append",
1229
+ default=[],
1230
+ metavar="KEY=VALUE",
1231
+ )
1232
+ execute.add_argument(
1233
+ "--timeout-seconds",
1234
+ type=float,
1235
+ default=DEFAULT_EXEC_TIMEOUT_SECONDS,
1236
+ help="positive timeout in seconds; -1 waits indefinitely",
1237
+ )
1238
+ execute.add_argument("--result-json", action="store_true")
1239
+ execute.add_argument("argv", nargs=argparse.REMAINDER)
1240
+
1241
+ session = sub.add_parser(
1242
+ "session",
1243
+ help="manage and export explicit CLI operation sessions",
1244
+ )
1245
+ session_sub = session.add_subparsers(dest="session_command", required=True)
1246
+ session_begin = session_sub.add_parser(
1247
+ "begin",
1248
+ help="create an explicit CLI operation session",
1249
+ )
1250
+ session_begin.add_argument("--name")
1251
+ session_begin.add_argument("--json", action="store_true")
1252
+ session_sub.add_parser(
1253
+ "list",
1254
+ help="list discoverable CLI session logs",
1255
+ )
1256
+ session_export = session_sub.add_parser(
1257
+ "export",
1258
+ help="export CLI operation logs as a Markdown runbook",
1259
+ )
1260
+ session_source = session_export.add_mutually_exclusive_group(required=True)
1261
+ session_source.add_argument(
1262
+ "--input",
1263
+ type=Path,
1264
+ action="append",
1265
+ default=[],
1266
+ metavar="PATH",
1267
+ help="JSONL session input; repeat to merge multiple files",
1268
+ )
1269
+ session_source.add_argument(
1270
+ "--latest",
1271
+ action="store_true",
1272
+ help="use the latest CLI operation session log",
1273
+ )
1274
+ session_source.add_argument(
1275
+ "--session-id",
1276
+ dest="export_session_id",
1277
+ help="export the log for an explicit msdev session ID",
1278
+ )
1279
+ session_export.add_argument(
1280
+ "--output", type=Path,
1281
+ help="output file path (default: stdout)",
1282
+ )
1283
+ session_export.add_argument(
1284
+ "--detail", choices=("brief", "normal", "full"), default="normal",
1285
+ help="brief one-line calls, normal runbook, or full sanitized events",
1286
+ )
1287
+
1288
+ return parser
1289
+
1290
+
1291
+ def _operation_name(args: argparse.Namespace) -> str:
1292
+ parts = [args.command]
1293
+ for attribute in (
1294
+ "node_command",
1295
+ "environment_command",
1296
+ "workspace_command",
1297
+ "npu_command",
1298
+ "model_command",
1299
+ "replica_command",
1300
+ "session_command",
1301
+ ):
1302
+ value = getattr(args, attribute, None)
1303
+ if value:
1304
+ parts.append(value)
1305
+ return ".".join(parts)
1306
+
1307
+
1308
+ def _replay_argv(argv: list[str]) -> list[str]:
1309
+ metadata = {
1310
+ "--session-id",
1311
+ "--intent-kind",
1312
+ "--intent-summary",
1313
+ "--intent-phase",
1314
+ "--intent-step-id",
1315
+ }
1316
+ result: list[str] = []
1317
+ skip_next = False
1318
+ for value in argv:
1319
+ if skip_next:
1320
+ skip_next = False
1321
+ continue
1322
+ option = value.partition("=")[0]
1323
+ if option in metadata:
1324
+ skip_next = "=" not in value
1325
+ continue
1326
+ result.append(value)
1327
+ return result
1328
+
1329
+
1330
+ def _intent_from_args(args: argparse.Namespace) -> dict[str, str] | None:
1331
+ values = {
1332
+ "kind": args.intent_kind,
1333
+ "summary": args.intent_summary,
1334
+ "phase": args.intent_phase,
1335
+ "step_id": args.intent_step_id,
1336
+ }
1337
+ if not any(values.values()):
1338
+ return None
1339
+ if not values["kind"]:
1340
+ raise ValueError("--intent-kind is required when intent metadata is used")
1341
+ if not values["summary"]:
1342
+ raise ValueError("--intent-summary is required when intent metadata is used")
1343
+ return {key: value for key, value in values.items() if value}
1344
+
1345
+
1346
+ def _watch_parent_exit(
1347
+ parent_pid: int,
1348
+ stop_event: threading.Event,
1349
+ *,
1350
+ poll_seconds: float = 0.1,
1351
+ ) -> None:
1352
+ while not stop_event.wait(poll_seconds):
1353
+ if os.getppid() != parent_pid:
1354
+ os.kill(os.getpid(), signal.SIGTERM)
1355
+ return
1356
+
1357
+
1358
+ def main() -> None:
1359
+ parser = build_parser()
1360
+ args = parser.parse_args()
1361
+ node_store = NodeStore(args.config)
1362
+ environment_store = EnvironmentStore(args.config)
1363
+ workspace_store = WorkspaceStore(args.workspace_config)
1364
+ started = time.monotonic()
1365
+ intent: dict[str, str] | None = None
1366
+ previous_sigterm = signal.getsignal(signal.SIGTERM)
1367
+
1368
+ def cancel_on_sigterm(_signum: int, _frame: Any) -> None:
1369
+ raise KeyboardInterrupt
1370
+
1371
+ signal.signal(signal.SIGTERM, cancel_on_sigterm)
1372
+ parent_watch_stop: threading.Event | None = None
1373
+ parent_watch_thread: threading.Thread | None = None
1374
+ if args.command == "exec":
1375
+ parent_watch_stop = threading.Event()
1376
+ parent_watch_thread = threading.Thread(
1377
+ target=_watch_parent_exit,
1378
+ args=(os.getppid(), parent_watch_stop),
1379
+ daemon=True,
1380
+ )
1381
+ parent_watch_thread.start()
1382
+ try:
1383
+ intent = _intent_from_args(args)
1384
+ if args.command == "node":
1385
+ code = command_node(args, node_store, environment_store)
1386
+ elif args.command == "env":
1387
+ code = command_environment(args, node_store, environment_store)
1388
+ elif args.command == "npu" and args.npu_command == "list":
1389
+ code = command_npu(args, node_store, environment_store)
1390
+ elif args.command == "model":
1391
+ code = command_model(args, node_store, environment_store)
1392
+ elif args.command == "exec":
1393
+ code = command_exec(args, node_store, environment_store)
1394
+ elif args.command == "workspace":
1395
+ code = command_workspace(
1396
+ args,
1397
+ node_store,
1398
+ environment_store,
1399
+ workspace_store,
1400
+ )
1401
+ elif args.command == "session":
1402
+ code = command_session(args)
1403
+ else:
1404
+ parser.error("unsupported command")
1405
+ return
1406
+ except (KeyError, ValueError, RuntimeError, RpcError, OSError) as exc:
1407
+ print(f"error: {exc}", file=sys.stderr)
1408
+ code = 2
1409
+ except KeyboardInterrupt:
1410
+ print("cancelled", file=sys.stderr)
1411
+ code = 130
1412
+ finally:
1413
+ if parent_watch_stop is not None:
1414
+ parent_watch_stop.set()
1415
+ if parent_watch_thread is not None:
1416
+ parent_watch_thread.join(timeout=0.2)
1417
+ signal.signal(signal.SIGTERM, previous_sigterm)
1418
+ if args.session_id and args.command != "session":
1419
+ try:
1420
+ node = getattr(args, "node", None)
1421
+ if not isinstance(node, str):
1422
+ node = (
1423
+ getattr(args, "name", None)
1424
+ if args.command == "node"
1425
+ else None
1426
+ )
1427
+ environment = getattr(args, "environment", None)
1428
+ if not isinstance(environment, str):
1429
+ environment = (
1430
+ getattr(args, "name", None)
1431
+ if args.command == "env"
1432
+ else None
1433
+ )
1434
+ workspace = (
1435
+ getattr(args, "name", None)
1436
+ if args.command == "workspace"
1437
+ else None
1438
+ )
1439
+ CliOperationLogger(args.session_id).record(
1440
+ argv=_replay_argv(sys.argv[1:]),
1441
+ command=_operation_name(args),
1442
+ node=node if isinstance(node, str) else None,
1443
+ environment=(
1444
+ environment if isinstance(environment, str) else None
1445
+ ),
1446
+ workspace=workspace if isinstance(workspace, str) else None,
1447
+ intent=intent,
1448
+ exit_code=code,
1449
+ elapsed_ms=monotonic_ms(started),
1450
+ )
1451
+ except (OSError, ValueError) as exc:
1452
+ print(
1453
+ f"warning: CLI operation log was not written: {exc}",
1454
+ file=sys.stderr,
1455
+ )
1456
+ raise SystemExit(code)
1457
+
1458
+
1459
+ if __name__ == "__main__":
1460
+ main()