opensandbox-cli 0.1.0.dev1__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.
@@ -0,0 +1,20 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ try:
16
+ from importlib.metadata import version
17
+
18
+ __version__ = version("opensandbox-cli")
19
+ except Exception:
20
+ __version__ = "0.0.0-dev"
@@ -0,0 +1,20 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Allow running as ``python -m opensandbox_cli``."""
16
+
17
+ from opensandbox_cli.main import cli
18
+
19
+ if __name__ == "__main__":
20
+ cli()
@@ -0,0 +1,172 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """SDK client factory stored in Click context."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from datetime import timedelta
22
+ from typing import Any
23
+
24
+ import click
25
+ import httpx
26
+ from opensandbox.config.connection_sync import ConnectionConfigSync
27
+ from opensandbox.models.sandboxes import SandboxFilter
28
+ from opensandbox.sync.manager import SandboxManagerSync
29
+ from opensandbox.sync.sandbox import SandboxSync
30
+
31
+ from opensandbox_cli.output import OutputFormatter
32
+
33
+ # Full UUID pattern: 8-4-4-4-12 hex characters
34
+ _UUID_RE = re.compile(
35
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
36
+ )
37
+
38
+
39
+ @dataclass
40
+ class ClientContext:
41
+ """Shared context passed via ``ctx.obj`` to all Click commands."""
42
+
43
+ resolved_config: dict[str, Any]
44
+ output: OutputFormatter
45
+ _connection_config: ConnectionConfigSync | None = field(
46
+ default=None, init=False, repr=False
47
+ )
48
+ _manager: SandboxManagerSync | None = field(
49
+ default=None, init=False, repr=False
50
+ )
51
+ _shared_transport: httpx.BaseTransport | None = field(
52
+ default=None, init=False, repr=False
53
+ )
54
+ _devops_client: httpx.Client | None = field(
55
+ default=None, init=False, repr=False
56
+ )
57
+
58
+ @property
59
+ def connection_config(self) -> ConnectionConfigSync:
60
+ if self._connection_config is None:
61
+ cfg = self.resolved_config
62
+ self._shared_transport = httpx.HTTPTransport(
63
+ limits=httpx.Limits(
64
+ max_connections=100,
65
+ max_keepalive_connections=20,
66
+ keepalive_expiry=30.0,
67
+ ),
68
+ )
69
+ self._connection_config = ConnectionConfigSync(
70
+ api_key=cfg.get("api_key"),
71
+ domain=cfg.get("domain"),
72
+ protocol=cfg.get("protocol", "http"),
73
+ request_timeout=timedelta(seconds=cfg.get("request_timeout", 30)),
74
+ use_server_proxy=cfg.get("use_server_proxy", False),
75
+ transport=self._shared_transport,
76
+ )
77
+ return self._connection_config
78
+
79
+ def get_devops_client(self) -> httpx.Client:
80
+ """Return a cached HTTP client for experimental diagnostics endpoints."""
81
+ if self._devops_client is None:
82
+ config = self.connection_config
83
+ headers = dict(config.headers)
84
+ headers.setdefault("Accept", "text/plain")
85
+ headers.setdefault("User-Agent", config.user_agent)
86
+ if config.api_key:
87
+ headers["OPEN-SANDBOX-API-KEY"] = config.api_key
88
+
89
+ self._devops_client = httpx.Client(
90
+ base_url=config.get_base_url(),
91
+ headers=headers,
92
+ timeout=config.request_timeout.total_seconds(),
93
+ transport=self._shared_transport,
94
+ )
95
+ return self._devops_client
96
+
97
+ def get_manager(self) -> SandboxManagerSync:
98
+ """Return a lazily-created ``SandboxManagerSync``."""
99
+ if self._manager is None:
100
+ self._manager = SandboxManagerSync.create(self.connection_config)
101
+ return self._manager
102
+
103
+ def resolve_sandbox_id(self, prefix: str) -> str:
104
+ """Resolve a sandbox ID prefix to the full ID (Docker-style).
105
+
106
+ If *prefix* looks like a complete UUID, it is returned as-is without
107
+ querying the server. Otherwise **all pages** of sandboxes are fetched
108
+ so that prefix collisions on later pages are never missed.
109
+ """
110
+ # Skip resolution for full UUIDs
111
+ if _UUID_RE.match(prefix):
112
+ return prefix
113
+
114
+ mgr = self.get_manager()
115
+ matches: list[str] = []
116
+ page = 0
117
+
118
+ while True:
119
+ result = mgr.list_sandbox_infos(
120
+ SandboxFilter(page=page, page_size=100)
121
+ )
122
+ if result.sandbox_infos:
123
+ matches.extend(
124
+ info.id
125
+ for info in result.sandbox_infos
126
+ if info.id.startswith(prefix)
127
+ )
128
+ # Stop early if we already found >1 match (ambiguous)
129
+ if len(matches) > 1:
130
+ break
131
+ if not result.pagination.has_next_page:
132
+ break
133
+ page += 1
134
+
135
+ if len(matches) == 1:
136
+ return matches[0]
137
+ elif len(matches) == 0:
138
+ raise click.ClickException(
139
+ f"No sandbox found with ID prefix '{prefix}'"
140
+ )
141
+ else:
142
+ ids_str = ", ".join(matches[:5])
143
+ if len(matches) > 5:
144
+ ids_str += ", ..."
145
+ raise click.ClickException(
146
+ f"Ambiguous ID prefix '{prefix}' matches {len(matches)} sandboxes: {ids_str}"
147
+ )
148
+
149
+ def connect_sandbox(
150
+ self, sandbox_id: str, *, skip_health_check: bool = True
151
+ ) -> SandboxSync:
152
+ """Connect to an existing sandbox by ID (supports prefix matching)."""
153
+ sandbox_id = self.resolve_sandbox_id(sandbox_id)
154
+ return SandboxSync.connect(
155
+ sandbox_id,
156
+ connection_config=self.connection_config,
157
+ skip_health_check=skip_health_check,
158
+ )
159
+
160
+ def close(self) -> None:
161
+ """Release resources."""
162
+ if self._manager is not None:
163
+ self._manager.close()
164
+ self._manager = None
165
+ if self._devops_client is not None:
166
+ self._devops_client.close()
167
+ self._devops_client = None
168
+ if self._connection_config is not None:
169
+ self._connection_config = None
170
+ if self._shared_transport is not None:
171
+ self._shared_transport.close()
172
+ self._shared_transport = None
@@ -0,0 +1,13 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
@@ -0,0 +1,303 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Command execution commands: one-shot commands and persistent sessions."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import shlex
20
+ import sys
21
+ from datetime import timedelta
22
+
23
+ import click
24
+ from opensandbox.models.execd import OutputMessage, RunCommandOpts
25
+ from opensandbox.models.execd_sync import ExecutionHandlersSync
26
+
27
+ from opensandbox_cli.client import ClientContext
28
+ from opensandbox_cli.utils import DURATION, handle_errors
29
+
30
+
31
+ @click.group("command", invoke_without_command=True)
32
+ @click.pass_context
33
+ def command_group(ctx: click.Context) -> None:
34
+ """⚡ Execute commands in a sandbox."""
35
+ if ctx.invoked_subcommand is None:
36
+ click.echo(ctx.get_help())
37
+
38
+
39
+ # ---- run ------------------------------------------------------------------
40
+
41
+ def _run_command(
42
+ obj: ClientContext,
43
+ sandbox_id: str,
44
+ command: tuple[str, ...],
45
+ background: bool,
46
+ workdir: str | None,
47
+ timeout: timedelta | None,
48
+ ) -> None:
49
+ """Shared implementation for 'command run' and top-level 'exec'."""
50
+ cmd_str = " ".join(shlex.quote(arg) for arg in command)
51
+ sandbox = obj.connect_sandbox(sandbox_id)
52
+
53
+ try:
54
+ opts = RunCommandOpts(
55
+ background=background,
56
+ working_directory=workdir,
57
+ timeout=timeout,
58
+ )
59
+
60
+ if background:
61
+ execution = sandbox.commands.run(cmd_str, opts=opts)
62
+ obj.output.success_panel(
63
+ {
64
+ "execution_id": execution.id,
65
+ "sandbox_id": sandbox_id,
66
+ "mode": "background",
67
+ },
68
+ title="Background Command Started",
69
+ )
70
+ return
71
+
72
+ # Foreground: stream stdout/stderr to terminal
73
+ last_text = ""
74
+
75
+ def on_stdout(msg: OutputMessage) -> None:
76
+ nonlocal last_text
77
+ last_text = msg.text
78
+ sys.stdout.write(msg.text)
79
+ sys.stdout.flush()
80
+
81
+ def on_stderr(msg: OutputMessage) -> None:
82
+ nonlocal last_text
83
+ last_text = msg.text
84
+ sys.stderr.write(msg.text)
85
+ sys.stderr.flush()
86
+
87
+ handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr)
88
+ execution = sandbox.commands.run(cmd_str, opts=opts, handlers=handlers)
89
+
90
+ # Ensure terminal prompt starts on a new line
91
+ if last_text and not last_text.endswith("\n"):
92
+ sys.stdout.write("\n")
93
+ sys.stdout.flush()
94
+
95
+ _handle_execution_error(obj, execution)
96
+ finally:
97
+ sandbox.close()
98
+
99
+
100
+ def _handle_execution_error(obj: ClientContext, execution) -> None:
101
+ """Exit non-zero if the execution finished with an error."""
102
+ if execution.error:
103
+ obj.output.error_panel(
104
+ f"{execution.error.name}: {execution.error.value}",
105
+ title="Execution Error",
106
+ )
107
+ sys.exit(1)
108
+
109
+
110
+ @command_group.command("run")
111
+ @click.argument("sandbox_id")
112
+ @click.argument("command", nargs=-1, required=True)
113
+ @click.option("-d", "--background", is_flag=True, default=False, help="Run in background.")
114
+ @click.option("-w", "--workdir", default=None, help="Working directory.")
115
+ @click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).")
116
+ @click.pass_obj
117
+ @handle_errors
118
+ def command_run(
119
+ obj: ClientContext,
120
+ sandbox_id: str,
121
+ command: tuple[str, ...],
122
+ background: bool,
123
+ workdir: str | None,
124
+ timeout: timedelta | None,
125
+ ) -> None:
126
+ """Run a command in a sandbox."""
127
+ _run_command(obj, sandbox_id, command, background, workdir, timeout)
128
+
129
+
130
+ # ---- status ---------------------------------------------------------------
131
+
132
+ @command_group.command("status")
133
+ @click.argument("sandbox_id")
134
+ @click.argument("execution_id")
135
+ @click.pass_obj
136
+ @handle_errors
137
+ def command_status(obj: ClientContext, sandbox_id: str, execution_id: str) -> None:
138
+ """Get command execution status."""
139
+ sandbox = obj.connect_sandbox(sandbox_id)
140
+ try:
141
+ status = sandbox.commands.get_command_status(execution_id)
142
+ obj.output.print_model(status, title="Command Status")
143
+ finally:
144
+ sandbox.close()
145
+
146
+
147
+ # ---- logs -----------------------------------------------------------------
148
+
149
+ @command_group.command("logs")
150
+ @click.argument("sandbox_id")
151
+ @click.argument("execution_id")
152
+ @click.option("--cursor", type=int, default=None, help="Cursor for incremental reads.")
153
+ @click.pass_obj
154
+ @handle_errors
155
+ def command_logs(
156
+ obj: ClientContext, sandbox_id: str, execution_id: str, cursor: int | None
157
+ ) -> None:
158
+ """Get background command logs."""
159
+ sandbox = obj.connect_sandbox(sandbox_id)
160
+ try:
161
+ logs = sandbox.commands.get_background_command_logs(execution_id, cursor=cursor)
162
+ if obj.output.fmt in ("json", "yaml"):
163
+ obj.output.print_model(logs, title="Command Logs")
164
+ else:
165
+ click.echo(logs.content)
166
+ finally:
167
+ sandbox.close()
168
+
169
+
170
+ # ---- interrupt ------------------------------------------------------------
171
+
172
+ @command_group.command("interrupt")
173
+ @click.argument("sandbox_id")
174
+ @click.argument("execution_id")
175
+ @click.pass_obj
176
+ @handle_errors
177
+ def command_interrupt(obj: ClientContext, sandbox_id: str, execution_id: str) -> None:
178
+ """Interrupt a running command."""
179
+ sandbox = obj.connect_sandbox(sandbox_id)
180
+ try:
181
+ sandbox.commands.interrupt(execution_id)
182
+ obj.output.success(f"Interrupted: {execution_id}")
183
+ finally:
184
+ sandbox.close()
185
+
186
+
187
+ @command_group.group("session", invoke_without_command=True)
188
+ @click.pass_context
189
+ def session_group(ctx: click.Context) -> None:
190
+ """Manage persistent bash sessions."""
191
+ if ctx.invoked_subcommand is None:
192
+ click.echo(ctx.get_help())
193
+
194
+
195
+ @session_group.command("create")
196
+ @click.argument("sandbox_id")
197
+ @click.option("-w", "--workdir", default=None, help="Initial working directory.")
198
+ @click.pass_obj
199
+ @handle_errors
200
+ def session_create(obj: ClientContext, sandbox_id: str, workdir: str | None) -> None:
201
+ """Create a persistent bash session."""
202
+ sandbox = obj.connect_sandbox(sandbox_id)
203
+ try:
204
+ session_id = sandbox.commands.create_session(working_directory=workdir)
205
+ obj.output.success_panel(
206
+ {
207
+ "sandbox_id": sandbox.id,
208
+ "session_id": session_id,
209
+ "working_directory": workdir,
210
+ },
211
+ title="Session Created",
212
+ )
213
+ finally:
214
+ sandbox.close()
215
+
216
+
217
+ @session_group.command("run")
218
+ @click.argument("sandbox_id")
219
+ @click.argument("session_id")
220
+ @click.argument("command", nargs=-1, required=True)
221
+ @click.option("-w", "--workdir", default=None, help="Working directory override for this run.")
222
+ @click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).")
223
+ @click.pass_obj
224
+ @handle_errors
225
+ def session_run(
226
+ obj: ClientContext,
227
+ sandbox_id: str,
228
+ session_id: str,
229
+ command: tuple[str, ...],
230
+ workdir: str | None,
231
+ timeout: timedelta | None,
232
+ ) -> None:
233
+ """Run a command in an existing bash session."""
234
+ cmd_str = " ".join(shlex.quote(arg) for arg in command)
235
+ sandbox = obj.connect_sandbox(sandbox_id)
236
+ try:
237
+ last_text = ""
238
+
239
+ def on_stdout(msg: OutputMessage) -> None:
240
+ nonlocal last_text
241
+ last_text = msg.text
242
+ sys.stdout.write(msg.text)
243
+ sys.stdout.flush()
244
+
245
+ def on_stderr(msg: OutputMessage) -> None:
246
+ nonlocal last_text
247
+ last_text = msg.text
248
+ sys.stderr.write(msg.text)
249
+ sys.stderr.flush()
250
+
251
+ handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr)
252
+ execution = sandbox.commands.run_in_session(
253
+ session_id,
254
+ cmd_str,
255
+ working_directory=workdir,
256
+ timeout=int(timeout.total_seconds() * 1000) if timeout is not None else None,
257
+ handlers=handlers,
258
+ )
259
+
260
+ if last_text and not last_text.endswith("\n"):
261
+ sys.stdout.write("\n")
262
+ sys.stdout.flush()
263
+
264
+ _handle_execution_error(obj, execution)
265
+ finally:
266
+ sandbox.close()
267
+
268
+
269
+ @session_group.command("delete")
270
+ @click.argument("sandbox_id")
271
+ @click.argument("session_id")
272
+ @click.pass_obj
273
+ @handle_errors
274
+ def session_delete(obj: ClientContext, sandbox_id: str, session_id: str) -> None:
275
+ """Delete a persistent bash session."""
276
+ sandbox = obj.connect_sandbox(sandbox_id)
277
+ try:
278
+ sandbox.commands.delete_session(session_id)
279
+ obj.output.success(f"Deleted session: {session_id}")
280
+ finally:
281
+ sandbox.close()
282
+
283
+
284
+ # ---- top-level exec alias ------------------------------------------------
285
+
286
+ @click.command("exec")
287
+ @click.argument("sandbox_id")
288
+ @click.argument("command", nargs=-1, required=True)
289
+ @click.option("-d", "--background", is_flag=True, default=False, help="Run in background.")
290
+ @click.option("-w", "--workdir", default=None, help="Working directory.")
291
+ @click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).")
292
+ @click.pass_obj
293
+ @handle_errors
294
+ def exec_cmd(
295
+ obj: ClientContext,
296
+ sandbox_id: str,
297
+ command: tuple[str, ...],
298
+ background: bool,
299
+ workdir: str | None,
300
+ timeout: timedelta | None,
301
+ ) -> None:
302
+ """🚀 Execute a command in a sandbox (shortcut for 'command run')."""
303
+ _run_command(obj, sandbox_id, command, background, workdir, timeout)
@@ -0,0 +1,145 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Config management commands: init, show, set."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import click
22
+
23
+ from opensandbox_cli.client import ClientContext
24
+ from opensandbox_cli.config import DEFAULT_CONFIG_PATH, init_config_file
25
+ from opensandbox_cli.utils import handle_errors
26
+
27
+
28
+ @click.group("config", invoke_without_command=True)
29
+ @click.pass_context
30
+ def config_group(ctx: click.Context) -> None:
31
+ """⚙️ Manage CLI configuration."""
32
+ if ctx.invoked_subcommand is None:
33
+ click.echo(ctx.get_help())
34
+
35
+
36
+ # ---- init -----------------------------------------------------------------
37
+
38
+ @config_group.command("init")
39
+ @click.option("--force", is_flag=True, default=False, help="Overwrite existing config file.")
40
+ @click.option("--path", "config_path", type=click.Path(path_type=Path), default=None, help="Config file path.")
41
+ @handle_errors
42
+ def config_init(force: bool, config_path: Path | None) -> None:
43
+ """Create a default configuration file."""
44
+ # config_init doesn't have @click.pass_obj, get formatter from context
45
+ ctx = click.get_current_context(silent=True)
46
+ obj = getattr(ctx, "obj", None) if ctx else None
47
+ output = getattr(obj, "output", None) if obj else None
48
+
49
+ try:
50
+ path = init_config_file(config_path, force=force)
51
+ if output:
52
+ output.success(f"Config file created: {path}")
53
+ else:
54
+ click.echo(f"Config file created: {path}")
55
+ except FileExistsError as exc:
56
+ if output:
57
+ output.warning(str(exc))
58
+ else:
59
+ click.secho(str(exc), fg="yellow", err=True)
60
+
61
+
62
+ # ---- show -----------------------------------------------------------------
63
+
64
+ @config_group.command("show")
65
+ @click.pass_obj
66
+ @handle_errors
67
+ def config_show(obj: ClientContext) -> None:
68
+ """Show the resolved configuration."""
69
+ obj.output.print_dict(obj.resolved_config, title="Resolved Configuration")
70
+
71
+
72
+ # ---- set ------------------------------------------------------------------
73
+
74
+ @config_group.command("set")
75
+ @click.argument("key")
76
+ @click.argument("value")
77
+ @click.option("--path", "config_path", type=click.Path(path_type=Path), default=None, help="Config file path.")
78
+ @handle_errors
79
+ def config_set(key: str, value: str, config_path: Path | None) -> None:
80
+ """Set a configuration value (e.g. 'connection.domain' 'localhost:9090')."""
81
+ path = config_path or DEFAULT_CONFIG_PATH
82
+ if not path.exists():
83
+ click.secho(f"Config file not found: {path}. Run 'osb config init' first.", fg="red", err=True)
84
+ return
85
+
86
+ content = path.read_text()
87
+
88
+ # Simple key replacement in TOML
89
+ # Supports dotted keys like connection.domain
90
+ parts = key.split(".", 1)
91
+ if len(parts) == 2:
92
+ section, field = parts
93
+ # Try to find and update existing value
94
+ import re
95
+
96
+ section_pattern = rf"(\[{re.escape(section)}\].*?)(?=\n\[|\Z)"
97
+ section_match = re.search(section_pattern, content, re.DOTALL)
98
+
99
+ # Infer TOML value type: bool > int > float > string
100
+ def _toml_value(raw: str) -> str:
101
+ if raw.lower() in ("true", "false"):
102
+ return raw.lower()
103
+ try:
104
+ int(raw)
105
+ return raw
106
+ except ValueError:
107
+ pass
108
+ try:
109
+ float(raw)
110
+ return raw
111
+ except ValueError:
112
+ pass
113
+ return f'"{raw}"'
114
+
115
+ toml_val = _toml_value(value)
116
+
117
+ if section_match:
118
+ section_text = section_match.group(1)
119
+ field_pattern = rf'^(#?\s*{re.escape(field)}\s*=\s*).*$'
120
+ field_match = re.search(field_pattern, section_text, re.MULTILINE)
121
+ if field_match:
122
+ new_line = f'{field} = {toml_val}'
123
+ new_section = section_text[:field_match.start()] + new_line + section_text[field_match.end():]
124
+ content = content[:section_match.start()] + new_section + content[section_match.end():]
125
+ else:
126
+ # Add field to section
127
+ insert_pos = section_match.end()
128
+ content = content[:insert_pos] + f'\n{field} = {toml_val}' + content[insert_pos:]
129
+ else:
130
+ # Add new section
131
+ content += f'\n[{section}]\n{field} = {toml_val}\n'
132
+ else:
133
+ click.secho("Key must be in 'section.field' format (e.g. connection.domain).", fg="red", err=True)
134
+ return
135
+
136
+ path.write_text(content)
137
+
138
+ # config_set doesn't have @click.pass_obj, get formatter from context
139
+ ctx = click.get_current_context(silent=True)
140
+ obj = getattr(ctx, "obj", None) if ctx else None
141
+ output = getattr(obj, "output", None) if obj else None
142
+ if output:
143
+ output.success(f"Set {key} = {value}")
144
+ else:
145
+ click.echo(f"Set {key} = {value}")