opensandbox-cli 1.1.0__tar.gz → 1.1.1__tar.gz

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 (33) hide show
  1. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/PKG-INFO +109 -2
  2. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/README.md +106 -0
  3. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/pyproject.toml +2 -1
  4. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/client.py +0 -5
  5. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/command.py +47 -61
  6. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/config_cmd.py +46 -57
  7. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/devops.py +0 -9
  8. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/diagnostics.py +1 -10
  9. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/file.py +0 -24
  10. opensandbox_cli-1.1.1/src/opensandbox_cli/commands/sandbox.py +1057 -0
  11. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/skills.py +0 -3
  12. opensandbox_cli-1.1.1/src/opensandbox_cli/commands/snapshot.py +170 -0
  13. opensandbox_cli-1.1.1/src/opensandbox_cli/commands/template.py +324 -0
  14. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/main.py +4 -5
  15. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/output.py +28 -86
  16. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/skill_registry.py +6 -4
  17. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/skills/opensandbox-command-execution.md +2 -1
  18. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +60 -1
  19. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/utils.py +37 -40
  20. opensandbox_cli-1.1.0/src/opensandbox_cli/commands/sandbox.py +0 -594
  21. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/.gitignore +0 -0
  22. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/LICENSE +0 -0
  23. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/__init__.py +0 -0
  24. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/__main__.py +0 -0
  25. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/__init__.py +0 -0
  26. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/credential_vault.py +0 -0
  27. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/commands/egress.py +0 -0
  28. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/config.py +0 -0
  29. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/py.typed +0 -0
  30. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/skills/opensandbox-credential-vault.md +0 -0
  31. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/skills/opensandbox-file-operations.md +0 -0
  32. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/skills/opensandbox-network-egress.md +0 -0
  33. {opensandbox_cli-1.1.0 → opensandbox_cli-1.1.1}/src/opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opensandbox-cli
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Summary: OpenSandbox CLI - Command-line interface for managing sandboxes
5
5
  Project-URL: Homepage, https://open-sandbox.ai
6
6
  Project-URL: Repository, https://github.com/opensandbox-group/OpenSandbox
@@ -224,10 +224,11 @@ Classifier: Topic :: Software Development :: Libraries
224
224
  Classifier: Typing :: Typed
225
225
  Requires-Python: >=3.10
226
226
  Requires-Dist: click<9.0,>=8.1.0
227
- Requires-Dist: opensandbox<2.0.0,>=1.1.0
227
+ Requires-Dist: opensandbox<2.0.0,>=1.1.1
228
228
  Requires-Dist: pyyaml<7.0,>=6.0
229
229
  Requires-Dist: rich<14.0,>=13.0.0
230
230
  Requires-Dist: tomli>=2.0.0; python_version < '3.11'
231
+ Requires-Dist: tomlkit<1.0,>=0.13.0
231
232
  Description-Content-Type: text/markdown
232
233
 
233
234
  # OpenSandbox CLI
@@ -235,6 +236,7 @@ Description-Content-Type: text/markdown
235
236
  `osb` is the command-line interface for OpenSandbox. It is built for the common day-to-day flows:
236
237
 
237
238
  - create and manage sandboxes
239
+ - build golden-image templates and create sandboxes from templates or snapshots
238
240
  - run commands inside a sandbox
239
241
  - read and modify sandbox files
240
242
  - inspect runtime egress policy
@@ -387,6 +389,103 @@ Create with Credential Vault proxy enabled:
387
389
  osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json
388
390
  ```
389
391
 
392
+ Create from a template (golden image):
393
+
394
+ ```bash
395
+ osb sandbox create --template <template-id> --timeout 30m -o json
396
+ ```
397
+
398
+ Template mode fixes the workload shape on the server: `--image`, `--env`,
399
+ `--resource`, `--entrypoint`, `--volumes-file`, and `--credential-proxy` cannot
400
+ be combined with `--template`. Only `--timeout` (required), `--metadata`,
401
+ `--extension`, and `--network-policy-file` may accompany it. Only templates in
402
+ the `Succeeded` phase can be used. See [Manage templates](#manage-templates).
403
+
404
+ Create from a snapshot:
405
+
406
+ ```bash
407
+ osb sandbox create --snapshot-id <snapshot-id> --timeout 30m -o json
408
+ ```
409
+
410
+ `--image`, `--template`, and `--snapshot-id` are mutually exclusive.
411
+
412
+ Create from a request file (`-f`, like `kubectl apply -f`):
413
+
414
+ ```bash
415
+ osb sandbox create -f sandbox-request.json -o json
416
+ ```
417
+
418
+ `sandbox-request.json` uses the public `CreateSandboxRequest` JSON format
419
+ (camelCase keys):
420
+
421
+ ```json
422
+ {
423
+ "image": "python:3.12",
424
+ "timeout": 1800,
425
+ "resourceLimits": { "cpu": "1", "memory": "2Gi" },
426
+ "entrypoint": ["python", "-m", "http.server"],
427
+ "networkPolicy": {
428
+ "defaultAction": "deny",
429
+ "egress": [{ "action": "allow", "target": "pypi.org" }]
430
+ }
431
+ }
432
+ ```
433
+
434
+ - Exactly one of `image` (string or `{ "uri": ..., "auth": { "username": ..., "password": ... } }`),
435
+ `templateId`, or `snapshotId` is required; `timeout` is integer seconds, or
436
+ `null` for manual cleanup.
437
+ - Supported fields: `image`, `templateId`, `snapshotId`, `platform`, `timeout`,
438
+ `resourceLimits`, `resourceRequests`, `env`, `metadata`, `extensions`,
439
+ `entrypoint`, `networkPolicy`, `credentialProxy`, `secureAccess`, `volumes`,
440
+ `lifecycle`.
441
+ - `-f` cannot be combined with request-building flags; `--skip-health-check`,
442
+ `--ready-timeout`, and `-o` still apply. In template mode the file may only
443
+ set `timeout`, `metadata`, `extensions`, and `networkPolicy` alongside
444
+ `templateId` (a finite `timeout` is required).
445
+
446
+ ### Manage templates
447
+
448
+ Templates are golden-image builds; the build runs asynchronously, so poll
449
+ `template get` until the status phase is `Succeeded`. Template management
450
+ requires a Kubernetes-backed runtime.
451
+
452
+ ```bash
453
+ osb template create --image python:3.12 --publish s3://bucket/publish -o json
454
+ osb template get <template-id> -o json
455
+ osb template list -o json
456
+ osb template delete <template-id> -o json
457
+ ```
458
+
459
+ `template create` also accepts `--resource cpu=1 memory=512Mi disk=2Gi`,
460
+ `--entrypoint` (repeat per argv item), `--env KEY=VALUE`, `--metadata
461
+ KEY=VALUE`, `--format native|overlaybd`, `--readiness-probe`, and
462
+ `--warmup-seconds`. Or pass a request file in the public `CreateTemplateRequest`
463
+ JSON format:
464
+
465
+ ```bash
466
+ osb template create -f template-request.json -o json
467
+ ```
468
+
469
+ ```json
470
+ {
471
+ "image": "python:3.12",
472
+ "publish": "s3://bucket/publish",
473
+ "resourceLimits": { "cpu": "1", "memory": "512Mi" },
474
+ "readiness": { "probe": "tcp://127.0.0.1:44772", "warmupSeconds": 60 }
475
+ }
476
+ ```
477
+
478
+ ### Manage snapshots
479
+
480
+ Snapshots capture a sandbox's state and can back new sandboxes.
481
+
482
+ ```bash
483
+ osb snapshot create <sandbox-id> --name golden -o json
484
+ osb snapshot get <snapshot-id> -o json
485
+ osb snapshot list --sandbox-id <sandbox-id> -o json
486
+ osb snapshot delete <snapshot-id> -o json
487
+ ```
488
+
390
489
  ### List and inspect sandboxes
391
490
 
392
491
  ```bash
@@ -431,6 +530,10 @@ osb command status <sandbox-id> <execution-id> -o json
431
530
  osb command logs <sandbox-id> <execution-id> -o json
432
531
  ```
433
532
 
533
+ Background start errors or a stream ending before startup confirmation make the
534
+ CLI exit non-zero without success metadata. After a successful start, use
535
+ `command status` and `command logs` to check the process's eventual result.
536
+
434
537
  By default the payload after `--` is joined into one shell command string, so
435
538
  pipelines, redirection, and `$VAR` expansion work as in a terminal. Add `--argv`
436
539
  to pass the arguments to the executable as a literal argv list (no shell) when
@@ -561,6 +664,8 @@ Not every command supports every format. Use `--help` on the specific command wh
561
664
  The main command groups are:
562
665
 
563
666
  - `osb sandbox`: lifecycle management
667
+ - `osb template`: golden-image template builds
668
+ - `osb snapshot`: snapshot management
564
669
  - `osb command`: command execution and persistent sessions
565
670
  - `osb file`: file and directory operations
566
671
  - `osb egress`: runtime egress policy
@@ -573,6 +678,8 @@ Explore them directly:
573
678
 
574
679
  ```bash
575
680
  osb sandbox --help
681
+ osb template --help
682
+ osb snapshot --help
576
683
  osb command --help
577
684
  osb file --help
578
685
  osb skills --help
@@ -3,6 +3,7 @@
3
3
  `osb` is the command-line interface for OpenSandbox. It is built for the common day-to-day flows:
4
4
 
5
5
  - create and manage sandboxes
6
+ - build golden-image templates and create sandboxes from templates or snapshots
6
7
  - run commands inside a sandbox
7
8
  - read and modify sandbox files
8
9
  - inspect runtime egress policy
@@ -155,6 +156,103 @@ Create with Credential Vault proxy enabled:
155
156
  osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json
156
157
  ```
157
158
 
159
+ Create from a template (golden image):
160
+
161
+ ```bash
162
+ osb sandbox create --template <template-id> --timeout 30m -o json
163
+ ```
164
+
165
+ Template mode fixes the workload shape on the server: `--image`, `--env`,
166
+ `--resource`, `--entrypoint`, `--volumes-file`, and `--credential-proxy` cannot
167
+ be combined with `--template`. Only `--timeout` (required), `--metadata`,
168
+ `--extension`, and `--network-policy-file` may accompany it. Only templates in
169
+ the `Succeeded` phase can be used. See [Manage templates](#manage-templates).
170
+
171
+ Create from a snapshot:
172
+
173
+ ```bash
174
+ osb sandbox create --snapshot-id <snapshot-id> --timeout 30m -o json
175
+ ```
176
+
177
+ `--image`, `--template`, and `--snapshot-id` are mutually exclusive.
178
+
179
+ Create from a request file (`-f`, like `kubectl apply -f`):
180
+
181
+ ```bash
182
+ osb sandbox create -f sandbox-request.json -o json
183
+ ```
184
+
185
+ `sandbox-request.json` uses the public `CreateSandboxRequest` JSON format
186
+ (camelCase keys):
187
+
188
+ ```json
189
+ {
190
+ "image": "python:3.12",
191
+ "timeout": 1800,
192
+ "resourceLimits": { "cpu": "1", "memory": "2Gi" },
193
+ "entrypoint": ["python", "-m", "http.server"],
194
+ "networkPolicy": {
195
+ "defaultAction": "deny",
196
+ "egress": [{ "action": "allow", "target": "pypi.org" }]
197
+ }
198
+ }
199
+ ```
200
+
201
+ - Exactly one of `image` (string or `{ "uri": ..., "auth": { "username": ..., "password": ... } }`),
202
+ `templateId`, or `snapshotId` is required; `timeout` is integer seconds, or
203
+ `null` for manual cleanup.
204
+ - Supported fields: `image`, `templateId`, `snapshotId`, `platform`, `timeout`,
205
+ `resourceLimits`, `resourceRequests`, `env`, `metadata`, `extensions`,
206
+ `entrypoint`, `networkPolicy`, `credentialProxy`, `secureAccess`, `volumes`,
207
+ `lifecycle`.
208
+ - `-f` cannot be combined with request-building flags; `--skip-health-check`,
209
+ `--ready-timeout`, and `-o` still apply. In template mode the file may only
210
+ set `timeout`, `metadata`, `extensions`, and `networkPolicy` alongside
211
+ `templateId` (a finite `timeout` is required).
212
+
213
+ ### Manage templates
214
+
215
+ Templates are golden-image builds; the build runs asynchronously, so poll
216
+ `template get` until the status phase is `Succeeded`. Template management
217
+ requires a Kubernetes-backed runtime.
218
+
219
+ ```bash
220
+ osb template create --image python:3.12 --publish s3://bucket/publish -o json
221
+ osb template get <template-id> -o json
222
+ osb template list -o json
223
+ osb template delete <template-id> -o json
224
+ ```
225
+
226
+ `template create` also accepts `--resource cpu=1 memory=512Mi disk=2Gi`,
227
+ `--entrypoint` (repeat per argv item), `--env KEY=VALUE`, `--metadata
228
+ KEY=VALUE`, `--format native|overlaybd`, `--readiness-probe`, and
229
+ `--warmup-seconds`. Or pass a request file in the public `CreateTemplateRequest`
230
+ JSON format:
231
+
232
+ ```bash
233
+ osb template create -f template-request.json -o json
234
+ ```
235
+
236
+ ```json
237
+ {
238
+ "image": "python:3.12",
239
+ "publish": "s3://bucket/publish",
240
+ "resourceLimits": { "cpu": "1", "memory": "512Mi" },
241
+ "readiness": { "probe": "tcp://127.0.0.1:44772", "warmupSeconds": 60 }
242
+ }
243
+ ```
244
+
245
+ ### Manage snapshots
246
+
247
+ Snapshots capture a sandbox's state and can back new sandboxes.
248
+
249
+ ```bash
250
+ osb snapshot create <sandbox-id> --name golden -o json
251
+ osb snapshot get <snapshot-id> -o json
252
+ osb snapshot list --sandbox-id <sandbox-id> -o json
253
+ osb snapshot delete <snapshot-id> -o json
254
+ ```
255
+
158
256
  ### List and inspect sandboxes
159
257
 
160
258
  ```bash
@@ -199,6 +297,10 @@ osb command status <sandbox-id> <execution-id> -o json
199
297
  osb command logs <sandbox-id> <execution-id> -o json
200
298
  ```
201
299
 
300
+ Background start errors or a stream ending before startup confirmation make the
301
+ CLI exit non-zero without success metadata. After a successful start, use
302
+ `command status` and `command logs` to check the process's eventual result.
303
+
202
304
  By default the payload after `--` is joined into one shell command string, so
203
305
  pipelines, redirection, and `$VAR` expansion work as in a terminal. Add `--argv`
204
306
  to pass the arguments to the executable as a literal argv list (no shell) when
@@ -329,6 +431,8 @@ Not every command supports every format. Use `--help` on the specific command wh
329
431
  The main command groups are:
330
432
 
331
433
  - `osb sandbox`: lifecycle management
434
+ - `osb template`: golden-image template builds
435
+ - `osb snapshot`: snapshot management
332
436
  - `osb command`: command execution and persistent sessions
333
437
  - `osb file`: file and directory operations
334
438
  - `osb egress`: runtime egress policy
@@ -341,6 +445,8 @@ Explore them directly:
341
445
 
342
446
  ```bash
343
447
  osb sandbox --help
448
+ osb template --help
449
+ osb snapshot --help
344
450
  osb command --help
345
451
  osb file --help
346
452
  osb skills --help
@@ -44,10 +44,11 @@ classifiers = [
44
44
  dependencies = [
45
45
  # `command run --argv` passes a list to commands.run; the SDK accepts a
46
46
  # list there from 0.1.17. Release the CLI only after that SDK is published.
47
- "opensandbox>=1.1.0,<2.0.0",
47
+ "opensandbox>=1.1.1,<2.0.0",
48
48
  "click>=8.1.0,<9.0",
49
49
  "rich>=13.0.0,<14.0",
50
50
  "pyyaml>=6.0,<7.0",
51
+ "tomlkit>=0.13.0,<1.0",
51
52
  "tomli>=2.0.0; python_version < '3.11'",
52
53
  ]
53
54
 
@@ -90,15 +90,10 @@ class ClientContext:
90
90
  self._manager = SandboxManagerSync.create(self.connection_config)
91
91
  return self._manager
92
92
 
93
- def resolve_sandbox_id(self, sandbox_id: str) -> str:
94
- """Return the sandbox ID exactly as provided by the user."""
95
- return sandbox_id
96
-
97
93
  def connect_sandbox(
98
94
  self, sandbox_id: str, *, skip_health_check: bool = True
99
95
  ) -> SandboxSync:
100
96
  """Connect to an existing sandbox by ID."""
101
- sandbox_id = self.resolve_sandbox_id(sandbox_id)
102
97
  return SandboxSync.connect(
103
98
  sandbox_id,
104
99
  connection_config=self.connection_config,
@@ -18,10 +18,11 @@ from __future__ import annotations
18
18
 
19
19
  import shlex
20
20
  import sys
21
+ from collections.abc import Callable
21
22
  from datetime import timedelta
22
23
 
23
24
  import click
24
- from opensandbox.models.execd import OutputMessage, RunCommandOpts
25
+ from opensandbox.models.execd import Execution, OutputMessage, RunCommandOpts
25
26
  from opensandbox.models.execd_sync import ExecutionHandlersSync
26
27
 
27
28
  from opensandbox_cli.client import ClientContext
@@ -41,7 +42,29 @@ def command_group(ctx: click.Context) -> None:
41
42
  click.echo(ctx.get_help())
42
43
 
43
44
 
44
- # ---- run ------------------------------------------------------------------
45
+ def _stream_handlers() -> tuple[ExecutionHandlersSync, Callable[[], None]]:
46
+ """Create stdout/stderr streaming handlers and a trailing-newline flusher."""
47
+ last_text = ""
48
+
49
+ def on_stdout(msg: OutputMessage) -> None:
50
+ nonlocal last_text
51
+ last_text = msg.text
52
+ sys.stdout.write(msg.text)
53
+ sys.stdout.flush()
54
+
55
+ def on_stderr(msg: OutputMessage) -> None:
56
+ nonlocal last_text
57
+ last_text = msg.text
58
+ sys.stderr.write(msg.text)
59
+ sys.stderr.flush()
60
+
61
+ def flush_newline() -> None:
62
+ if last_text and not last_text.endswith("\n"):
63
+ sys.stdout.write("\n")
64
+ sys.stdout.flush()
65
+
66
+ return ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr), flush_newline
67
+
45
68
 
46
69
  def _run_command(
47
70
  obj: ClientContext,
@@ -55,15 +78,10 @@ def _run_command(
55
78
  ) -> None:
56
79
  """Shared implementation for ``command run``.
57
80
 
58
- Mode contract:
59
- - foreground (default): stream output directly, allow only ``-o raw``
60
- - background (``--background``): return a tracked execution object, allow structured output
61
-
62
- Payload contract:
63
- - default: join the arguments into one shell command string (works against
64
- every deployed execd)
65
- - ``--argv``: send the arguments as a literal argv list, no shell; needs an
66
- execd that accepts the ``argv`` request field
81
+ Foreground mode streams output and allows only ``-o raw``; background mode
82
+ returns a tracked execution object and allows structured output. The payload
83
+ is joined into one shell command string unless ``argv`` sends it as a
84
+ literal argv list (needs an argv-capable execd).
67
85
  """
68
86
  allowed = ("table", "json", "yaml") if background else ("raw",)
69
87
  fallback = "table" if background else "raw"
@@ -84,6 +102,7 @@ def _run_command(
84
102
 
85
103
  if background:
86
104
  execution = sandbox.commands.run(payload, opts=opts)
105
+ _handle_execution_error(obj, execution, background=True)
87
106
  obj.output.success_panel(
88
107
  {
89
108
  "execution_id": execution.id,
@@ -94,42 +113,33 @@ def _run_command(
94
113
  )
95
114
  return
96
115
 
97
- # Foreground: stream stdout/stderr to terminal
98
- last_text = ""
99
-
100
- def on_stdout(msg: OutputMessage) -> None:
101
- nonlocal last_text
102
- last_text = msg.text
103
- sys.stdout.write(msg.text)
104
- sys.stdout.flush()
105
-
106
- def on_stderr(msg: OutputMessage) -> None:
107
- nonlocal last_text
108
- last_text = msg.text
109
- sys.stderr.write(msg.text)
110
- sys.stderr.flush()
111
-
112
- handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr)
116
+ handlers, flush_newline = _stream_handlers()
113
117
  execution = sandbox.commands.run(payload, opts=opts, handlers=handlers)
114
-
115
- # Ensure terminal prompt starts on a new line
116
- if last_text and not last_text.endswith("\n"):
117
- sys.stdout.write("\n")
118
- sys.stdout.flush()
119
-
118
+ flush_newline()
120
119
  _handle_execution_error(obj, execution)
121
120
  finally:
122
121
  sandbox.close()
123
122
 
124
123
 
125
- def _handle_execution_error(obj: ClientContext, execution) -> None:
126
- """Exit non-zero if the execution finished with an error."""
124
+ def _handle_execution_error(
125
+ obj: ClientContext, execution: Execution, *, background: bool = False
126
+ ) -> None:
127
+ """Exit non-zero on execution errors or missing completion/startup confirmation."""
127
128
  if execution.error:
128
129
  obj.output.error_panel(
129
130
  f"{execution.error.name}: {execution.error.value}",
130
131
  title="Execution Error",
131
132
  )
132
133
  sys.exit(1)
134
+ if execution.complete is None:
135
+ confirmation = (
136
+ "background command startup was confirmed" if background else "the command finished"
137
+ )
138
+ obj.output.error_panel(
139
+ f"The output stream ended before {confirmation}, so its result is unknown.",
140
+ title="Execution Incomplete",
141
+ )
142
+ sys.exit(1)
133
143
 
134
144
 
135
145
  @command_group.command(
@@ -185,8 +195,6 @@ def command_run(
185
195
  )
186
196
 
187
197
 
188
- # ---- status ---------------------------------------------------------------
189
-
190
198
  @command_group.command("status")
191
199
  @click.argument("sandbox_id")
192
200
  @click.argument("execution_id")
@@ -209,8 +217,6 @@ def command_status(
209
217
  sandbox.close()
210
218
 
211
219
 
212
- # ---- logs -----------------------------------------------------------------
213
-
214
220
  @command_group.command("logs")
215
221
  @click.argument("sandbox_id")
216
222
  @click.argument("execution_id")
@@ -242,8 +248,6 @@ def command_logs(
242
248
  sandbox.close()
243
249
 
244
250
 
245
- # ---- interrupt ------------------------------------------------------------
246
-
247
251
  @command_group.command("interrupt")
248
252
  @click.argument("sandbox_id")
249
253
  @click.argument("execution_id")
@@ -330,21 +334,7 @@ def session_run(
330
334
  cmd_str = " ".join(shlex.quote(arg) for arg in command)
331
335
  sandbox = obj.connect_sandbox(sandbox_id)
332
336
  try:
333
- last_text = ""
334
-
335
- def on_stdout(msg: OutputMessage) -> None:
336
- nonlocal last_text
337
- last_text = msg.text
338
- sys.stdout.write(msg.text)
339
- sys.stdout.flush()
340
-
341
- def on_stderr(msg: OutputMessage) -> None:
342
- nonlocal last_text
343
- last_text = msg.text
344
- sys.stderr.write(msg.text)
345
- sys.stderr.flush()
346
-
347
- handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr)
337
+ handlers, flush_newline = _stream_handlers()
348
338
  execution = sandbox.commands.run_in_session(
349
339
  session_id,
350
340
  cmd_str,
@@ -352,11 +342,7 @@ def session_run(
352
342
  timeout=timeout,
353
343
  handlers=handlers,
354
344
  )
355
-
356
- if last_text and not last_text.endswith("\n"):
357
- sys.stdout.write("\n")
358
- sys.stdout.flush()
359
-
345
+ flush_newline()
360
346
  _handle_execution_error(obj, execution)
361
347
  finally:
362
348
  sandbox.close()
@@ -20,6 +20,10 @@ from collections.abc import Mapping
20
20
  from typing import Any
21
21
 
22
22
  import click
23
+ import tomlkit
24
+ from tomlkit.container import OutOfOrderTableProxy
25
+ from tomlkit.exceptions import ParseError
26
+ from tomlkit.items import InlineTable, Table
23
27
 
24
28
  from opensandbox_cli.client import ClientContext
25
29
  from opensandbox_cli.config import init_config_file, resolve_config
@@ -34,13 +38,11 @@ def config_group(ctx: click.Context) -> None:
34
38
  click.echo(ctx.get_help())
35
39
 
36
40
 
37
- # ---- init -----------------------------------------------------------------
38
-
39
41
  @config_group.command("init")
40
42
  @click.option("--force", is_flag=True, default=False, help="Overwrite existing config file.")
41
43
  @output_option("table", "json", "yaml")
42
- @handle_errors
43
44
  @click.pass_obj
45
+ @handle_errors
44
46
  def config_init(obj: ClientContext, force: bool, output_format: str | None) -> None:
45
47
  """Create a default configuration file."""
46
48
  output = prepare_output(
@@ -54,8 +56,6 @@ def config_init(obj: ClientContext, force: bool, output_format: str | None) -> N
54
56
  output.warning(str(exc))
55
57
 
56
58
 
57
- # ---- show -----------------------------------------------------------------
58
-
59
59
  _SENSITIVE_CONFIG_KEYS = {
60
60
  "api_key",
61
61
  }
@@ -80,6 +80,7 @@ def _sanitize_config_for_display(data: Mapping[str, Any]) -> dict[str, Any]:
80
80
  sanitized[key] = value
81
81
  return sanitized
82
82
 
83
+
83
84
  @config_group.command("show")
84
85
  @output_option("table", "json", "yaml")
85
86
  @click.pass_obj
@@ -105,14 +106,34 @@ def config_show(obj: ClientContext, output_format: str | None) -> None:
105
106
  )
106
107
 
107
108
 
108
- # ---- set ------------------------------------------------------------------
109
+ def _parse_toml_value(raw: str) -> Any:
110
+ """Infer a TOML scalar type from a raw CLI string.
111
+
112
+ Booleans, integers, and floats are recognized so values like ``false`` or
113
+ ``30`` keep their native TOML type; anything else is stored as a string.
114
+ The value is always serialized by tomlkit, so quotes, escapes, and
115
+ whitespace in string values are encoded correctly.
116
+ """
117
+ normalized = raw.lower()
118
+ if normalized in ("true", "false"):
119
+ return normalized == "true"
120
+ try:
121
+ return int(raw)
122
+ except ValueError:
123
+ pass
124
+ try:
125
+ return float(raw)
126
+ except ValueError:
127
+ pass
128
+ return raw
129
+
109
130
 
110
131
  @config_group.command("set")
111
132
  @click.argument("key")
112
133
  @click.argument("value")
113
134
  @output_option("table", "json", "yaml")
114
- @handle_errors
115
135
  @click.pass_obj
136
+ @handle_errors
116
137
  def config_set(
117
138
  obj: ClientContext,
118
139
  key: str,
@@ -125,59 +146,27 @@ def config_set(
125
146
  if not path.exists():
126
147
  raise click.ClickException(f"Config file not found: {path}. Run 'osb config init' first.")
127
148
 
128
- content = path.read_text()
129
-
130
- # TODO: Replace this regex-based TOML editing with a parser-backed update
131
- # path so formatting/comments survive reliably as config complexity grows.
132
- # Simple key replacement in TOML
133
- # Supports dotted keys like connection.domain
134
- parts = key.split(".", 1)
135
- if len(parts) == 2:
136
- section, field = parts
137
- # Try to find and update existing value
138
- import re
139
-
140
- section_pattern = rf"(\[{re.escape(section)}\].*?)(?=\n\[|\Z)"
141
- section_match = re.search(section_pattern, content, re.DOTALL)
142
-
143
- # Infer TOML value type: bool > int > float > string
144
- def _toml_value(raw: str) -> str:
145
- if raw.lower() in ("true", "false"):
146
- return raw.lower()
147
- try:
148
- int(raw)
149
- return raw
150
- except ValueError:
151
- pass
152
- try:
153
- float(raw)
154
- return raw
155
- except ValueError:
156
- pass
157
- return f'"{raw}"'
158
-
159
- toml_val = _toml_value(value)
160
-
161
- if section_match:
162
- section_text = section_match.group(1)
163
- field_pattern = rf'^(#?\s*{re.escape(field)}\s*=\s*).*$'
164
- field_match = re.search(field_pattern, section_text, re.MULTILINE)
165
- if field_match:
166
- new_line = f'{field} = {toml_val}'
167
- new_section = section_text[:field_match.start()] + new_line + section_text[field_match.end():]
168
- content = content[:section_match.start()] + new_section + content[section_match.end():]
169
- else:
170
- # Add field to section
171
- insert_pos = section_match.end()
172
- content = content[:insert_pos] + f'\n{field} = {toml_val}' + content[insert_pos:]
173
- else:
174
- # Add new section
175
- content += f'\n[{section}]\n{field} = {toml_val}\n'
176
- else:
149
+ section, _, field = key.partition(".")
150
+ if not section or not field or "." in field:
177
151
  raise click.ClickException(
178
152
  "Key must be in 'section.field' format (e.g. connection.domain)."
179
153
  )
180
154
 
181
- path.write_text(content)
155
+ try:
156
+ document = tomlkit.parse(path.read_text(encoding="utf-8"))
157
+ except ParseError as exc:
158
+ raise click.ClickException(
159
+ f"Config file is not valid TOML ({path}): {exc}"
160
+ ) from exc
161
+
162
+ table = document.get(section)
163
+ if table is None:
164
+ table = tomlkit.table()
165
+ document[section] = table
166
+ if not isinstance(table, (Table, InlineTable, OutOfOrderTableProxy)):
167
+ raise click.ClickException(f"Cannot set {key}: '{section}' is not a TOML table.")
168
+
169
+ table[field] = _parse_toml_value(value)
170
+ path.write_text(tomlkit.dumps(document), encoding="utf-8")
182
171
 
183
172
  obj.output.success(f"Set {key} = {value}")