cmdbox-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. cmdbox/__init__.py +0 -0
  2. cmdbox/cli/__init__.py +0 -0
  3. cmdbox/cli/app.py +125 -0
  4. cmdbox/cli/commands/__init__.py +0 -0
  5. cmdbox/cli/commands/alias_fallback.py +102 -0
  6. cmdbox/cli/commands/command_crud.py +429 -0
  7. cmdbox/cli/commands/command_run.py +255 -0
  8. cmdbox/cli/commands/history.py +109 -0
  9. cmdbox/cli/commands/init.py +54 -0
  10. cmdbox/cli/commands/settings.py +62 -0
  11. cmdbox/cli/commands/tag_crud.py +277 -0
  12. cmdbox/cli/commands/variable_crud.py +349 -0
  13. cmdbox/cli/common/__init__.py +0 -0
  14. cmdbox/cli/common/errors.py +58 -0
  15. cmdbox/cli/common/update_fields.py +88 -0
  16. cmdbox/cli/completions/__init__.py +0 -0
  17. cmdbox/cli/completions/commands.py +26 -0
  18. cmdbox/cli/completions/fields.py +31 -0
  19. cmdbox/cli/completions/tags.py +24 -0
  20. cmdbox/cli/completions/variables.py +26 -0
  21. cmdbox/cli/handlers/__init__.py +0 -0
  22. cmdbox/cli/handlers/command_handlers.py +357 -0
  23. cmdbox/cli/handlers/common_handlers.py +15 -0
  24. cmdbox/cli/handlers/history_handlers.py +94 -0
  25. cmdbox/cli/handlers/init_handler.py +127 -0
  26. cmdbox/cli/handlers/run_handler.py +178 -0
  27. cmdbox/cli/handlers/settings_handler.py +59 -0
  28. cmdbox/cli/handlers/tag_handlers.py +220 -0
  29. cmdbox/cli/handlers/variable_handlers.py +272 -0
  30. cmdbox/cli/prompts/__init__.py +0 -0
  31. cmdbox/cli/prompts/completers.py +161 -0
  32. cmdbox/cli/prompts/prompts.py +108 -0
  33. cmdbox/cli/prompts/validators.py +46 -0
  34. cmdbox/cli/ui/__init__.py +0 -0
  35. cmdbox/cli/ui/console.py +31 -0
  36. cmdbox/cli/ui/editor.py +141 -0
  37. cmdbox/cli/ui/presenters/__init__.py +0 -0
  38. cmdbox/cli/ui/presenters/app_presenter.py +8 -0
  39. cmdbox/cli/ui/presenters/command_presenter.py +168 -0
  40. cmdbox/cli/ui/presenters/history_presenter.py +83 -0
  41. cmdbox/cli/ui/presenters/init_instructions.py +52 -0
  42. cmdbox/cli/ui/presenters/init_presenter.py +57 -0
  43. cmdbox/cli/ui/presenters/result_presenter.py +144 -0
  44. cmdbox/cli/ui/presenters/settings_presenter.py +130 -0
  45. cmdbox/cli/ui/presenters/tag_presenter.py +97 -0
  46. cmdbox/cli/ui/presenters/variable_presenter.py +103 -0
  47. cmdbox/cli/ui/primitives.py +410 -0
  48. cmdbox/cli/ui/theme.py +43 -0
  49. cmdbox/cli/ui/theme_builder.py +49 -0
  50. cmdbox/common/__init__.py +0 -0
  51. cmdbox/common/io.py +34 -0
  52. cmdbox/container.py +156 -0
  53. cmdbox/core/__init__.py +0 -0
  54. cmdbox/core/fields.py +48 -0
  55. cmdbox/core/paths.py +52 -0
  56. cmdbox/database.py +65 -0
  57. cmdbox/exceptions.py +10 -0
  58. cmdbox/init/__init__.py +0 -0
  59. cmdbox/init/detect.py +82 -0
  60. cmdbox/init/integrations/bash.sh +10 -0
  61. cmdbox/init/integrations/cmd.bat +14 -0
  62. cmdbox/init/integrations/fish.fish +11 -0
  63. cmdbox/init/integrations/powershell.ps1 +14 -0
  64. cmdbox/init/integrations/zsh.sh +10 -0
  65. cmdbox/init/io.py +68 -0
  66. cmdbox/init/specs.py +54 -0
  67. cmdbox/logging_setup/__init__.py +0 -0
  68. cmdbox/logging_setup/log_config.py +123 -0
  69. cmdbox/logging_setup/log_decorators.py +40 -0
  70. cmdbox/logging_setup/log_handlers.py +94 -0
  71. cmdbox/migrations/__init__.py +1 -0
  72. cmdbox/migrations/errors.py +10 -0
  73. cmdbox/migrations/runner.py +127 -0
  74. cmdbox/migrations/versions/__init__.py +0 -0
  75. cmdbox/models.py +165 -0
  76. cmdbox/repositories/__init__.py +0 -0
  77. cmdbox/repositories/base_repository.py +181 -0
  78. cmdbox/repositories/command_repository.py +391 -0
  79. cmdbox/repositories/errors.py +120 -0
  80. cmdbox/repositories/history_repository.py +155 -0
  81. cmdbox/repositories/results.py +37 -0
  82. cmdbox/repositories/tag_repository.py +91 -0
  83. cmdbox/repositories/validators.py +256 -0
  84. cmdbox/repositories/variable_repository.py +324 -0
  85. cmdbox/resolve/__init__.py +0 -0
  86. cmdbox/resolve/errors.py +65 -0
  87. cmdbox/resolve/lookup.py +137 -0
  88. cmdbox/resolve/resolver.py +402 -0
  89. cmdbox/resolve/type_defs.py +96 -0
  90. cmdbox/runtime/__init__.py +0 -0
  91. cmdbox/runtime/executor.py +454 -0
  92. cmdbox/runtime/results.py +25 -0
  93. cmdbox/runtime/shell.py +90 -0
  94. cmdbox/services/__init__.py +0 -0
  95. cmdbox/services/command_services.py +261 -0
  96. cmdbox/services/errors.py +37 -0
  97. cmdbox/services/field_selection.py +162 -0
  98. cmdbox/services/history_service.py +68 -0
  99. cmdbox/services/run_service.py +204 -0
  100. cmdbox/services/tag_services.py +134 -0
  101. cmdbox/services/variable_services.py +224 -0
  102. cmdbox/settings/__init__.py +0 -0
  103. cmdbox/settings/models.py +129 -0
  104. cmdbox/settings/settings_repository.py +36 -0
  105. cmdbox/settings/settings_service.py +144 -0
  106. cmdbox/version.py +1 -0
  107. cmdbox_cli-1.0.0.dist-info/METADATA +125 -0
  108. cmdbox_cli-1.0.0.dist-info/RECORD +112 -0
  109. cmdbox_cli-1.0.0.dist-info/WHEEL +5 -0
  110. cmdbox_cli-1.0.0.dist-info/entry_points.txt +2 -0
  111. cmdbox_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  112. cmdbox_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,454 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+ import subprocess
5
+ import tempfile
6
+ from dataclasses import dataclass
7
+ from typing import Mapping
8
+
9
+ import typer
10
+
11
+ from cmdbox.runtime.results import ExecutionResult
12
+ from cmdbox.runtime.shell import build_shell_command
13
+ from cmdbox.logging_setup.log_decorators import log_action
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RunContext:
20
+ """
21
+ Represents the execution context for a runnable process.
22
+
23
+ This class defines the environment and settings under which a process is
24
+ executed. It includes attributes such as the current working directory,
25
+ environment variables, and capture settings. Being a frozen dataclass, the
26
+ RunContext instances are immutable.
27
+
28
+ Attributes:
29
+ cwd (str | None): The current working directory for the process. If None,
30
+ the process will inherit the working directory from the parent process.
31
+ env (Mapping[str, str] | None): Environment variables to set for the
32
+ process. If None, the process will inherit the environment from the
33
+ parent process.
34
+ capture (bool): Whether the process's output streams should be captured.
35
+ If False, the output streams will inherit those of the parent process.
36
+ shell (str | None): The shell to use for executing the command. If None,
37
+ the system default shell will be used.
38
+ timeout (int | None): The maximum time in seconds to wait for the command
39
+ to complete. If None, the command will run indefinitely. Defaults to None.
40
+ emit (bool): Whether to emit the command template. If True, the command
41
+ template is emitted to the current terminal window to be evaluated
42
+ in the current session. If False, the command is executed in a
43
+ different session using a subprocess. Defaults to False.
44
+ verbose (bool): Whether to output additional information alongside the
45
+ command output. Defaults to False.
46
+ """
47
+
48
+ cwd: str | None = None
49
+ env: Mapping[str, str] | None = None
50
+ capture: bool = False
51
+ shell: str | None = None
52
+ timeout: int | None = None
53
+ emit: bool = False
54
+ verbose: bool = False
55
+
56
+
57
+ class Executor:
58
+
59
+ @log_action(__name__, "run_executor_run")
60
+ def run(self, command: str, ctx: RunContext | None = None) -> ExecutionResult:
61
+ """
62
+ Executes a shell command in a subprocess, capturing the output and exit code.
63
+
64
+ This method takes a shell command as a string and runs it in a subprocess. It
65
+ allows the caller to specify the working directory, environment variables, and
66
+ whether or not to capture the output through the provided context. The result
67
+ of the execution is returned encapsulated in an `ExecutionResult`.
68
+
69
+ Args:
70
+ command (str): The shell command to be executed.
71
+ ctx (RunContext, optional): An instance of `RunContext` that provides
72
+ additional execution context such as the working directory,
73
+ environment variables, and capture preferences. Defaults to a new
74
+ `RunContext()` instance.
75
+
76
+ Returns:
77
+ ExecutionResult: An object containing the executed command, the exit code,
78
+ and the captured standard output and error streams.
79
+ """
80
+ if not ctx:
81
+ ctx = RunContext()
82
+ log.info(
83
+ "Executing command: mode=%s, multiline=%s, capture=%s, shell=%s, cmd_len=%s, cwd_set=%s, env_override=%s, timeout=%s",
84
+ "emit" if ctx.emit else "subprocess",
85
+ self.is_multiline(command),
86
+ ctx.capture,
87
+ ctx.shell,
88
+ len(command),
89
+ ctx.cwd is not None,
90
+ ctx.env is not None,
91
+ ctx.timeout,
92
+ )
93
+
94
+ if ctx.emit:
95
+ self.emit_command(command)
96
+ return None # Just a safeguard, this should not return if emit is True
97
+
98
+ env = os.environ.copy()
99
+ if ctx.env:
100
+ env.update(dict(ctx.env))
101
+
102
+ if self.is_multiline(command):
103
+ return self.run_multiline_as_script(command, ctx=ctx, env=env)
104
+
105
+ popen_args = build_shell_command(command, preferred_shell=ctx.shell)
106
+ return self.execute_command(
107
+ command,
108
+ popen_args=popen_args,
109
+ cwd=ctx.cwd,
110
+ env=env,
111
+ capture_output=ctx.capture,
112
+ timeout=ctx.timeout,
113
+ )
114
+
115
+ @log_action(__name__, "run_executor_run_multiline_as_script")
116
+ def run_multiline_as_script(
117
+ self, command: str, ctx: RunContext, env: dict[str, str]
118
+ ) -> ExecutionResult:
119
+ """
120
+ Executes a multiline command as a script in the context of a specified shell environment.
121
+
122
+ This method creates a temporary script file containing the provided multiline command and
123
+ executes it using subprocess. The command is normalized for consistent behavior across
124
+ platforms, and an optional shell-specific header is prepended based on the execution context.
125
+
126
+ Args:
127
+ command (str): The multiline command to execute as a script.
128
+ ctx (RunContext): Context about the execution, including shell type, working directory,
129
+ and whether to capture output.
130
+ env (dict[str, str]): A dictionary of environment variables to set during script execution.
131
+
132
+ Returns:
133
+ ExecutionResult: An object containing the executed command, its exit code, and captured
134
+ standard output and error streams.
135
+ """
136
+ shell = (ctx.shell or "default").lower()
137
+ suffix = self.script_suffix_for_shell(shell)
138
+ script_path = None
139
+
140
+ if shell == "default":
141
+ log.debug("exec multiline as default shell, suffix=%s", suffix)
142
+ else:
143
+ log.debug("exec multiline as shell=%s, suffix=%s", shell, suffix)
144
+
145
+ # Normalize newlines so contents is consistent across platforms
146
+ script_body = command.replace("\r\n", "\n").rstrip("\n") + "\n"
147
+
148
+ try:
149
+ with tempfile.NamedTemporaryFile(
150
+ mode="w",
151
+ encoding="utf-8",
152
+ delete=False,
153
+ suffix=suffix,
154
+ ) as file:
155
+ script_path = file.name
156
+ header = self.script_header_for_shell(shell)
157
+ if header:
158
+ file.write(header)
159
+ file.write(script_body)
160
+
161
+ popen_args = self.build_script_exec_args(script_path, shell=shell)
162
+ log.debug(
163
+ "exec multiline args_count=%s header=%s", len(popen_args), bool(header)
164
+ )
165
+
166
+ return self.execute_command(
167
+ command,
168
+ popen_args=popen_args,
169
+ cwd=ctx.cwd,
170
+ env=env,
171
+ capture_output=ctx.capture,
172
+ timeout=ctx.timeout,
173
+ )
174
+
175
+ finally:
176
+ if script_path:
177
+ try:
178
+ os.remove(script_path)
179
+ except OSError:
180
+ pass
181
+
182
+ def execute_command(
183
+ self,
184
+ command: str,
185
+ *,
186
+ popen_args: list[str],
187
+ cwd: str | None = None,
188
+ env: dict[str, str] | None = None,
189
+ capture_output: bool = False,
190
+ timeout: int | None = None,
191
+ ) -> ExecutionResult:
192
+ """
193
+ Executes a command using the appropriate subprocess strategy based on
194
+ whether a timeout is set. When no timeout is provided, uses subprocess.run
195
+ for simplicity. When a timeout is provided, uses subprocess.Popen with
196
+ process tree cleanup to prevent orphaned child processes on timeout.
197
+
198
+ Args:
199
+ command (str): The original command string, used for logging and the
200
+ returned ExecutionResult.
201
+ popen_args (list[str]): The fully resolved argument list to pass to
202
+ the subprocess.
203
+ cwd (str | None): Working directory for the process.
204
+ env (dict[str, str] | None): Environment variables for the process.
205
+ capture_output (bool): Whether to capture stdout and stderr.
206
+ timeout (int | None): Maximum seconds to wait before killing the
207
+ process tree. If None, the command runs indefinitely.
208
+
209
+ Returns:
210
+ ExecutionResult: The result of the execution including exit code and
211
+ any captured output.
212
+ """
213
+ if timeout is not None:
214
+ # If timeout is provided, use _run_subprocess method to handle timeout
215
+ try:
216
+ completed = self._run_subprocess(
217
+ popen_args,
218
+ cwd=cwd,
219
+ env=env,
220
+ capture_output=capture_output,
221
+ timeout=timeout,
222
+ )
223
+ except subprocess.TimeoutExpired:
224
+ log.warning("Command timed out after %s seconds", timeout)
225
+ return ExecutionResult(
226
+ command=command,
227
+ exit_code=124,
228
+ stdout="",
229
+ stderr=f"Command timed out after {timeout} seconds",
230
+ )
231
+ else:
232
+ completed = subprocess.run(
233
+ popen_args,
234
+ cwd=cwd,
235
+ text=True,
236
+ env=env,
237
+ capture_output=capture_output,
238
+ )
239
+
240
+ log.info("Command completed with exit code: %s", completed.returncode)
241
+
242
+ return ExecutionResult(
243
+ command=command,
244
+ exit_code=completed.returncode,
245
+ stdout=completed.stdout or "",
246
+ stderr=completed.stderr or "",
247
+ )
248
+
249
+ def _run_subprocess(
250
+ self,
251
+ popen_args: list[str],
252
+ *,
253
+ cwd: str | None,
254
+ env: dict[str, str] | None,
255
+ capture_output: bool,
256
+ timeout: int | None,
257
+ ) -> subprocess.CompletedProcess:
258
+ """
259
+ Runs a subprocess with proper process tree cleanup on timeout. On timeout,
260
+ the entire process tree is killed rather than just the direct child, which
261
+ prevents orphaned processes from continuing to run after the timeout expires.
262
+
263
+ Args:
264
+ popen_args (list[str]): The command and arguments to run.
265
+ cwd (str | None): Working directory for the process.
266
+ env (dict[str, str]): Environment variables for the process.
267
+ capture_output (bool): Whether to capture stdout and stderr.
268
+ timeout (int | None): Maximum seconds to wait before killing the process tree.
269
+
270
+ Returns:
271
+ subprocess.CompletedProcess: The result of the process.
272
+
273
+ Raises:
274
+ subprocess.TimeoutExpired: If the process exceeds the timeout.
275
+ """
276
+ kwargs = dict(
277
+ cwd=cwd,
278
+ text=True,
279
+ env=env,
280
+ stdout=subprocess.PIPE if capture_output else None,
281
+ stderr=subprocess.PIPE if capture_output else None,
282
+ )
283
+
284
+ if sys.platform != "win32":
285
+ kwargs["start_new_session"] = True
286
+
287
+ with subprocess.Popen(popen_args, **kwargs) as proc:
288
+ try:
289
+ stdout, stderr = proc.communicate(timeout=timeout)
290
+ return subprocess.CompletedProcess(
291
+ popen_args, proc.returncode, stdout, stderr
292
+ )
293
+ except (subprocess.TimeoutExpired, KeyboardInterrupt):
294
+ self._kill_process_tree(proc)
295
+ proc.wait()
296
+ raise
297
+
298
+ @staticmethod
299
+ def _kill_process_tree(proc: subprocess.Popen) -> None:
300
+ """
301
+ Kills a process and all of its children. On Windows, uses taskkill to
302
+ terminate the entire process tree. On Linux, sends SIGTERM to the process
303
+ group created by start_new_session=True.
304
+
305
+ Args:
306
+ proc (subprocess.Popen): The process whose tree should be killed.
307
+ """
308
+ if sys.platform == "win32":
309
+ subprocess.run(
310
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
311
+ capture_output=True,
312
+ )
313
+ else:
314
+ import signal
315
+
316
+ try:
317
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
318
+ except ProcessLookupError:
319
+ proc.kill()
320
+
321
+ @staticmethod
322
+ def emit_command(command: str) -> None:
323
+ """
324
+ Emits a formatted command to standard output and terminates the process
325
+ with a success exit code.
326
+
327
+ This method takes a string command as input, appends a newline character,
328
+ and writes it to the standard output. It ensures the command is formatted
329
+ with a single trailing newline before being emitted. Once executed, the
330
+ method forcefully exits the process with an exit code of 0.
331
+
332
+ Args:
333
+ command (str): The input command that needs to be written to standard
334
+ output. It should be a valid string representation of the command
335
+ to execute.
336
+ """
337
+ cmd = command.strip("\n") + "\n"
338
+ sys.stdout.write(cmd)
339
+ raise typer.Exit(code=0)
340
+
341
+ @staticmethod
342
+ def is_multiline(command: str) -> bool:
343
+ """
344
+ Determines if the given command is multiline. A command is considered multiline
345
+ if it contains at least one newline character after stripping leading and trailing
346
+ newlines.
347
+
348
+ Args:
349
+ command (str): The command string to check.
350
+
351
+ Returns:
352
+ bool: True if the command contains at least one newline character after
353
+ trimming leading and trailing newline characters, False otherwise.
354
+ """
355
+ return "\n" in command.strip("\n")
356
+
357
+ @staticmethod
358
+ def script_suffix_for_shell(shell: str) -> str:
359
+ """
360
+ Determines the appropriate script file suffix for a given shell.
361
+
362
+ This function inspects the input shell name or its characteristics and returns
363
+ the corresponding script file suffix based on the shell's type.
364
+
365
+ Args:
366
+ shell (str): The name of the shell or its identifier.
367
+
368
+ Returns:
369
+ str: The appropriate script file suffix for the given shell.
370
+ """
371
+ if shell == "default":
372
+ return ".cmd" if sys.platform == "win32" else ".sh"
373
+ if "cmd" in shell or shell == "cmd.exe":
374
+ return ".cmd"
375
+ if "powershell" in shell or shell == "pwsh":
376
+ return ".ps1"
377
+ if "fish" in shell:
378
+ return ".fish"
379
+ # Default to bash
380
+ return ".sh"
381
+
382
+ @staticmethod
383
+ def script_header_for_shell(shell: str) -> str:
384
+ """
385
+ Generates a script header for the given shell type.
386
+
387
+ This method determines the appropriate script header based on the
388
+ specified shell string. Supported shells include bash, zsh, and fish.
389
+ If the shell type isn't recognized or no header is required, it returns
390
+ an empty string.
391
+
392
+ Args:
393
+ shell (str): The name of the shell for which the script header is
394
+ to be generated.
395
+
396
+ Returns:
397
+ str: The script header string corresponding to the provided shell,
398
+ or an empty string if no header is required.
399
+ """
400
+ if "bash" in shell:
401
+ return "#!/usr/bin/env bash\n"
402
+ if "zsh" in shell:
403
+ return "#!/usr/bin/env zsh\n"
404
+ if "fish" in shell:
405
+ return "#!/usr/bin/env fish\n"
406
+ # Other types do not require a header
407
+ return ""
408
+
409
+ @staticmethod
410
+ def build_script_exec_args(script_path: str, shell: str) -> list[str]:
411
+ """
412
+ Assembles a list of arguments to execute a given script based on the specified shell.
413
+
414
+ This method generates the appropriate command and arguments to execute
415
+ a script, tailored to the shell type provided. It ensures compatibility with
416
+ various shell environments such as cmd, PowerShell, bash, zsh, and fish.
417
+
418
+ Args:
419
+ script_path (str): The file path to the script that needs to be executed.
420
+ shell (str): The name or identifier of the shell environment used for script execution.
421
+
422
+ Returns:
423
+ list[str]: A list of arguments that, when executed, run the specified script in the given shell.
424
+ """
425
+ if shell == "default":
426
+ if sys.platform == "win32":
427
+ return ["cmd.exe", "/d", "/s", "/c", script_path]
428
+ return ["sh", script_path]
429
+
430
+ if "cmd" in shell or shell == "cmd.exe":
431
+ return ["cmd.exe", "/d", "/s", "/c", script_path]
432
+
433
+ if "powershell" in shell or shell == "pwsh":
434
+ exe = "pwsh" if "pwsh" in shell else "powershell"
435
+ return [
436
+ exe,
437
+ "-NoProfile",
438
+ "-NonInteractive",
439
+ "-ExecutionPolicy",
440
+ "Bypass",
441
+ "-File",
442
+ script_path,
443
+ ]
444
+
445
+ if "fish" in shell:
446
+ return ["fish", script_path]
447
+
448
+ if "zsh" in shell:
449
+ return ["zsh", script_path]
450
+
451
+ if "bash" in shell:
452
+ return ["bash", script_path]
453
+
454
+ return ["sh", script_path]
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class ExecutionResult:
6
+ """
7
+ Represents the result of a command execution.
8
+
9
+ This class is a data container that holds details of a command execution
10
+ outcome, including the executed command, its exit status, and associated
11
+ standard output and error streams. It is designed to provide a structured
12
+ result of running an external command in a consistent and immutable format.
13
+
14
+ Attributes:
15
+ command (str): The actual command that was executed. Extrapolated
16
+ from the Command.template.
17
+ exit_code (int): The exit code returned by the command.
18
+ stdout (str): The standard output produced by the command.
19
+ stderr (str): The standard error output produced by the command.
20
+ """
21
+
22
+ command: str
23
+ exit_code: int
24
+ stdout: str
25
+ stderr: str
@@ -0,0 +1,90 @@
1
+ import os
2
+ import sys
3
+ from shutil import which
4
+
5
+
6
+ def build_shell_command(command: str, preferred_shell: str | None = None) -> list[str]:
7
+ """
8
+ Builds a shell command list based on the provided command and preferred shell, with fallback mechanisms for
9
+ platform compatibility and environment-specific configurations.
10
+
11
+ This function determines the appropriate shell and constructs a command list for execution. It handles both
12
+ Windows and Unix-like environments, prioritizing the specified preferred shell or defaulting to environment
13
+ variables and standard fallback options. If no suitable shell is found, it raises a RuntimeError.
14
+
15
+ Args:
16
+ command (str): The shell command to execute.
17
+ preferred_shell (str | None): The preferred shell to use for executing the command. If None, the function
18
+ attempts to determine an appropriate shell based on the platform, environment, and fallback defaults.
19
+
20
+ Returns:
21
+ list[str]: A list containing the shell command and its arguments.
22
+
23
+ Raises:
24
+ RuntimeError: If no usable shell is found for the current system.
25
+ """
26
+ candidates: list[tuple[str, list[str]]] = []
27
+
28
+ # Windows options
29
+ if sys.platform.startswith("win"):
30
+ if preferred_shell:
31
+ candidates.append(
32
+ (preferred_shell, _windows_shell_args(preferred_shell, command))
33
+ )
34
+
35
+ env_shell = os.environ.get("CMDBOX_SHELL")
36
+ if env_shell:
37
+ candidates.append((env_shell, _windows_shell_args(env_shell, command)))
38
+
39
+ # Known good fallbacks
40
+ candidates.extend(
41
+ [
42
+ ("pwsh", ["pwsh", "-NoProfile", "-Command", command]),
43
+ ("powershell", ["powershell", "-NoProfile", "-Command", command]),
44
+ ("cmd.exe", ["cmd.exe", "/C", command]),
45
+ ]
46
+ )
47
+
48
+ for exe, args in candidates:
49
+ if which(exe):
50
+ return args
51
+
52
+ # Last resort option
53
+ return ["cmd.exe", "/C", command]
54
+
55
+ # Unix options
56
+ if preferred_shell:
57
+ candidates.append((preferred_shell, [preferred_shell, "-lc", command]))
58
+
59
+ env_shell = os.environ.get("SHELL")
60
+ if env_shell:
61
+ candidates.append((env_shell, [env_shell, "-lc", command]))
62
+
63
+ candidates.extend(
64
+ [
65
+ ("/bin/bash", ["/bin/bash", "-lc", command]),
66
+ ("/bin/sh", ["/bin/sh", "-lc", command]),
67
+ ]
68
+ )
69
+
70
+ for exe, args in candidates:
71
+ if os.path.isabs(exe):
72
+ if os.path.exists(exe):
73
+ return args
74
+ elif which(exe):
75
+ return args
76
+
77
+ raise RuntimeError("No usable shell found for this system")
78
+
79
+
80
+ def _windows_shell_args(shell: str, command: str) -> list[str]:
81
+ shell = shell.lower()
82
+
83
+ if shell in ("pwsh", "powershell"):
84
+ return [shell, "-NoProfile", "-Command", command]
85
+
86
+ if shell in ("cmd", "cmd.exe"):
87
+ return ["cmd.exe", "/C", command]
88
+
89
+ # Treat unknown shell as executable
90
+ return [shell, command]
File without changes