skilllite 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,608 @@
1
+ """
2
+ Skillbox executor - interfaces with the Rust skillbox binary.
3
+
4
+ This module provides the SkillboxExecutor class that implements the
5
+ SandboxExecutor interface using the Rust-based skillbox sandbox.
6
+
7
+ This is the canonical implementation of skill execution with sandbox support.
8
+ The core/executor.py module delegates to this class.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from ..base import SandboxExecutor, ExecutionResult
21
+ from ..config import (
22
+ SandboxConfig,
23
+ DEFAULT_EXECUTION_TIMEOUT,
24
+ DEFAULT_MAX_MEMORY_MB,
25
+ DEFAULT_SANDBOX_LEVEL,
26
+ DEFAULT_ALLOW_NETWORK,
27
+ DEFAULT_ENABLE_SANDBOX,
28
+ )
29
+ from ..utils import convert_json_to_cli_args
30
+ from .binary import find_binary, ensure_installed
31
+
32
+
33
+ class SkillboxExecutor(SandboxExecutor):
34
+ """
35
+ Executes skills using the skillbox binary.
36
+
37
+ This class provides a Python interface to the Rust-based sandbox executor.
38
+ Supports both traditional skill execution (via entry_point in SKILL.md) and
39
+ direct script execution (via exec command).
40
+
41
+ Features:
42
+ - Sandbox security levels (1/2/3)
43
+ - Configurable resource limits (memory, timeout)
44
+ - Level 3 user interaction support for authorization prompts
45
+ - Memory monitoring with psutil (optional)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ binary_path: Optional[str] = None,
51
+ cache_dir: Optional[str] = None,
52
+ allow_network: bool = False,
53
+ enable_sandbox: bool = True,
54
+ execution_timeout: Optional[int] = None,
55
+ max_memory_mb: Optional[int] = None,
56
+ sandbox_level: Optional[str] = None,
57
+ auto_install: bool = False
58
+ ):
59
+ """
60
+ Initialize the executor.
61
+
62
+ Args:
63
+ binary_path: Path to the skillbox binary. If None, auto-detect.
64
+ cache_dir: Directory for caching virtual environments.
65
+ allow_network: Whether to allow network access by default.
66
+ enable_sandbox: Whether to enable sandbox protection (default: True).
67
+ execution_timeout: Skill execution timeout in seconds (default: 120).
68
+ max_memory_mb: Maximum memory limit in MB (default: 512).
69
+ sandbox_level: Sandbox security level (1/2/3, default from env or 3).
70
+ auto_install: Automatically download and install binary if not found.
71
+ """
72
+ # Load configuration with environment variable fallbacks
73
+ self._config = SandboxConfig(
74
+ binary_path=binary_path,
75
+ cache_dir=cache_dir,
76
+ allow_network=allow_network if allow_network else DEFAULT_ALLOW_NETWORK,
77
+ enable_sandbox=enable_sandbox if enable_sandbox else DEFAULT_ENABLE_SANDBOX,
78
+ execution_timeout=execution_timeout if execution_timeout is not None else DEFAULT_EXECUTION_TIMEOUT,
79
+ max_memory_mb=max_memory_mb if max_memory_mb is not None else DEFAULT_MAX_MEMORY_MB,
80
+ sandbox_level=sandbox_level if sandbox_level is not None else DEFAULT_SANDBOX_LEVEL,
81
+ auto_install=auto_install,
82
+ )
83
+
84
+ # Set instance attributes from config
85
+ self.binary_path = self._config.binary_path or self._find_binary(auto_install)
86
+ self.cache_dir = self._config.cache_dir
87
+ self.allow_network = self._config.allow_network
88
+ self.enable_sandbox = self._config.enable_sandbox
89
+ self.execution_timeout = self._config.execution_timeout
90
+ self.max_memory_mb = self._config.max_memory_mb
91
+ self.sandbox_level = self._config.sandbox_level
92
+
93
+ def _find_binary(self, auto_install: bool = False) -> str:
94
+ """
95
+ Find the skillbox binary.
96
+
97
+ Uses the binary module to search for the binary in standard locations.
98
+ If auto_install is True, will download and install if not found.
99
+
100
+ Args:
101
+ auto_install: Automatically install if not found.
102
+
103
+ Returns:
104
+ Path to the binary.
105
+
106
+ Raises:
107
+ FileNotFoundError: If binary not found and auto_install is False.
108
+ PermissionError: If permission denied when accessing binary.
109
+ RuntimeError: If failed to find or install binary.
110
+ """
111
+ try:
112
+ return ensure_installed(auto_install=auto_install, show_progress=True)
113
+ except FileNotFoundError as e:
114
+ raise FileNotFoundError(
115
+ f"Skillbox binary not found. Please run 'cargo build --release' in skillbox/ "
116
+ f"directory, or set SKILLBOX_BINARY_PATH environment variable to the binary path. "
117
+ f"Original error: {e}"
118
+ ) from e
119
+ except PermissionError as e:
120
+ raise PermissionError(
121
+ f"Permission denied when accessing skillbox binary. "
122
+ f"Please check file permissions. Original error: {e}"
123
+ ) from e
124
+ except Exception as e:
125
+ raise RuntimeError(
126
+ f"Failed to find or install skillbox binary: {e}. "
127
+ f"Please ensure Rust is installed and run 'cargo build --release' in skillbox/ directory."
128
+ ) from e
129
+
130
+ @property
131
+ def is_available(self) -> bool:
132
+ """Check if skillbox is available and ready to use."""
133
+ if not self.binary_path:
134
+ return False
135
+ return os.path.exists(self.binary_path) and os.access(self.binary_path, os.X_OK)
136
+
137
+ @property
138
+ def name(self) -> str:
139
+ """Return the name of this sandbox implementation."""
140
+ return "skillbox"
141
+
142
+ def _convert_json_to_cli_args(self, input_data: Dict[str, Any]) -> List[str]:
143
+ """
144
+ Convert JSON input data to command line arguments list.
145
+
146
+ Delegates to the shared utility function in sandbox.utils.
147
+
148
+ Args:
149
+ input_data: JSON input data from LLM
150
+
151
+ Returns:
152
+ List of command line arguments
153
+ """
154
+ return convert_json_to_cli_args(input_data)
155
+
156
+ def _build_skill_env(self, skill_dir: Path, timeout: Optional[int] = None) -> Dict[str, str]:
157
+ """
158
+ Build environment variables for skill execution.
159
+
160
+ Args:
161
+ skill_dir: Path to the skill directory
162
+ timeout: Optional timeout override
163
+
164
+ Returns:
165
+ Environment dictionary
166
+ """
167
+ return {
168
+ **os.environ,
169
+ "PYTHONUNBUFFERED": "1",
170
+ "SKILL_DIR": str(skill_dir),
171
+ "SKILL_ASSETS_DIR": str(skill_dir / "assets"),
172
+ "SKILL_REFERENCES_DIR": str(skill_dir / "references"),
173
+ "SKILL_SCRIPTS_DIR": str(skill_dir / "scripts"),
174
+ "SKILLBOX_SANDBOX_LEVEL": self.sandbox_level,
175
+ "SKILLBOX_MAX_MEMORY_MB": str(self.max_memory_mb),
176
+ "SKILLBOX_TIMEOUT_SECS": str(timeout if timeout is not None else self.execution_timeout),
177
+ "SKILLBOX_AUTO_APPROVE": os.environ.get("SKILLBOX_AUTO_APPROVE", ""),
178
+ }
179
+
180
+ def _parse_output(self, stdout: str, stderr: str, returncode: int) -> ExecutionResult:
181
+ """
182
+ Parse subprocess output into ExecutionResult.
183
+
184
+ Args:
185
+ stdout: Standard output
186
+ stderr: Standard error
187
+ returncode: Process return code
188
+
189
+ Returns:
190
+ ExecutionResult
191
+ """
192
+ if returncode == 0:
193
+ try:
194
+ output = json.loads(stdout.strip())
195
+ return ExecutionResult(
196
+ success=True,
197
+ output=output,
198
+ exit_code=returncode,
199
+ stdout=stdout,
200
+ stderr=stderr
201
+ )
202
+ except json.JSONDecodeError:
203
+ # Script output is not JSON, return as raw text
204
+ return ExecutionResult(
205
+ success=True,
206
+ output={"raw_output": stdout.strip()},
207
+ exit_code=returncode,
208
+ stdout=stdout,
209
+ stderr=stderr
210
+ )
211
+ else:
212
+ return ExecutionResult(
213
+ success=False,
214
+ error=stderr or f"Exit code: {returncode}",
215
+ exit_code=returncode,
216
+ stdout=stdout,
217
+ stderr=stderr
218
+ )
219
+
220
+ def exec_script(
221
+ self,
222
+ skill_dir: Path,
223
+ script_path: str,
224
+ input_data: Dict[str, Any],
225
+ args: Optional[list] = None,
226
+ allow_network: Optional[bool] = None,
227
+ timeout: Optional[int] = None,
228
+ enable_sandbox: Optional[bool] = None
229
+ ) -> ExecutionResult:
230
+ """
231
+ Execute a specific script directly.
232
+
233
+ This method allows executing any script in the skill directory without
234
+ requiring an entry_point in SKILL.md. Useful for skills with multiple
235
+ scripts or prompt-only skills with helper scripts.
236
+
237
+ Note: Due to skillbox's --args parameter not correctly parsing arguments,
238
+ we execute Python scripts directly using subprocess for CLI-style scripts.
239
+
240
+ Args:
241
+ skill_dir: Path to the skill directory
242
+ script_path: Relative path to the script (e.g., "scripts/init_skill.py")
243
+ input_data: Input data for the script. For CLI scripts using argparse,
244
+ this will be automatically converted to command line arguments.
245
+ args: Optional command line arguments list (overrides auto-conversion)
246
+ allow_network: Override default network setting
247
+ timeout: Execution timeout in seconds
248
+ enable_sandbox: Override default sandbox setting
249
+
250
+ Returns:
251
+ ExecutionResult with the output or error
252
+ """
253
+ if allow_network is None:
254
+ allow_network = self.allow_network
255
+
256
+ if enable_sandbox is None:
257
+ enable_sandbox = self.enable_sandbox
258
+
259
+ # Convert JSON input to CLI args if no explicit args provided
260
+ if args is None and input_data:
261
+ args = self._convert_json_to_cli_args(input_data)
262
+
263
+ full_script_path = skill_dir / script_path
264
+
265
+ # Check sandbox level - Level 3 requires skillbox for security scanning
266
+ # Level 1 and 2 can use direct execution for better performance
267
+ if script_path.endswith('.py') and self.sandbox_level != "3":
268
+ return self._exec_python_script_direct(
269
+ skill_dir, full_script_path, input_data, args, timeout
270
+ )
271
+
272
+ # For other languages or Level 3, use skillbox exec
273
+ cmd = [
274
+ self.binary_path,
275
+ "exec",
276
+ str(skill_dir),
277
+ script_path,
278
+ json.dumps(input_data),
279
+ ]
280
+
281
+ if args:
282
+ args_str = " ".join(args) if isinstance(args, list) else args
283
+ cmd.extend(["--args", args_str])
284
+
285
+ if allow_network:
286
+ cmd.append("--allow-network")
287
+
288
+ if enable_sandbox:
289
+ cmd.append("--enable-sandbox")
290
+
291
+ if self.cache_dir:
292
+ cmd.extend(["--cache-dir", self.cache_dir])
293
+
294
+ # Add resource limits
295
+ effective_timeout = timeout if timeout is not None else self.execution_timeout
296
+ cmd.extend([
297
+ "--timeout", str(effective_timeout),
298
+ "--max-memory", str(self.max_memory_mb)
299
+ ])
300
+
301
+ skill_env = self._build_skill_env(skill_dir, timeout)
302
+
303
+ # Execute with Level 3 user interaction support
304
+ try:
305
+ if self.sandbox_level == "3":
306
+ # Level 3: Allow user interaction for authorization prompts
307
+ result = subprocess.run(
308
+ cmd,
309
+ stdin=None,
310
+ stdout=subprocess.PIPE,
311
+ stderr=None, # Let stderr flow to terminal for authorization prompts
312
+ text=True,
313
+ timeout=effective_timeout,
314
+ env=skill_env
315
+ )
316
+ return self._parse_output(result.stdout, "", result.returncode)
317
+ else:
318
+ result = subprocess.run(
319
+ cmd,
320
+ capture_output=True,
321
+ text=True,
322
+ timeout=effective_timeout,
323
+ env=skill_env
324
+ )
325
+ return self._parse_output(result.stdout, result.stderr, result.returncode)
326
+
327
+ except subprocess.TimeoutExpired:
328
+ return ExecutionResult(
329
+ success=False,
330
+ error=f"Execution timed out after {effective_timeout} seconds",
331
+ exit_code=-1
332
+ )
333
+ except FileNotFoundError:
334
+ return ExecutionResult(
335
+ success=False,
336
+ error=f"skillbox binary not found at: {self.binary_path}",
337
+ exit_code=-1
338
+ )
339
+ except Exception as e:
340
+ return ExecutionResult(
341
+ success=False,
342
+ error=f"Execution failed: {str(e)}",
343
+ exit_code=-1
344
+ )
345
+
346
+ def _exec_python_script_direct(
347
+ self,
348
+ skill_dir: Path,
349
+ script_path: Path,
350
+ input_data: Dict[str, Any],
351
+ args: Optional[list],
352
+ timeout: Optional[int]
353
+ ) -> ExecutionResult:
354
+ """
355
+ Execute Python script directly using subprocess with memory monitoring.
356
+
357
+ This provides better argument handling for CLI-style Python scripts
358
+ that use argparse or sys.argv, and adds memory limit protection.
359
+
360
+ Args:
361
+ skill_dir: Path to the skill directory
362
+ script_path: Full path to the script
363
+ input_data: Input data (for reference)
364
+ args: Command line arguments
365
+ timeout: Execution timeout in seconds
366
+
367
+ Returns:
368
+ ExecutionResult with the output or error
369
+ """
370
+ effective_timeout = timeout if timeout is not None else self.execution_timeout
371
+
372
+ # Try to import psutil for memory monitoring
373
+ try:
374
+ import psutil
375
+ has_psutil = True
376
+ except ImportError:
377
+ has_psutil = False
378
+
379
+ python_executable = sys.executable
380
+ cmd = [python_executable, str(script_path)]
381
+
382
+ if args:
383
+ cmd.extend(args)
384
+
385
+ skill_env = self._build_skill_env(skill_dir, timeout)
386
+
387
+ # Memory monitoring variables
388
+ memory_limit_bytes = self.max_memory_mb * 1024 * 1024
389
+ memory_exceeded = threading.Event()
390
+ memory_monitor_error: List[str] = []
391
+
392
+ def monitor_memory(proc: subprocess.Popen) -> None:
393
+ """Monitor process memory usage in a separate thread."""
394
+ if not has_psutil:
395
+ return
396
+ try:
397
+ import psutil as ps
398
+ ps_process = ps.Process(proc.pid)
399
+ while not memory_exceeded.is_set() and proc.poll() is None:
400
+ try:
401
+ mem_info = ps_process.memory_info()
402
+ if mem_info.rss > memory_limit_bytes:
403
+ memory_monitor_error.append(
404
+ f"Process killed: memory usage ({mem_info.rss / (1024*1024):.2f} MB) "
405
+ f"exceeded limit ({self.max_memory_mb} MB)"
406
+ )
407
+ memory_exceeded.set()
408
+ proc.terminate()
409
+ break
410
+ except (ps.NoSuchProcess, ps.AccessDenied):
411
+ break
412
+ time.sleep(0.1)
413
+ except Exception:
414
+ pass
415
+
416
+ try:
417
+ # Pass input data via stdin for scripts that read from stdin
418
+ input_json = json.dumps(input_data, ensure_ascii=False)
419
+
420
+ proc = subprocess.Popen(
421
+ cmd,
422
+ stdin=subprocess.PIPE,
423
+ stdout=subprocess.PIPE,
424
+ stderr=subprocess.PIPE,
425
+ text=True,
426
+ env=skill_env
427
+ )
428
+
429
+ # Start memory monitoring thread
430
+ if has_psutil:
431
+ monitor_thread = threading.Thread(target=monitor_memory, args=(proc,), daemon=True)
432
+ monitor_thread.start()
433
+
434
+ try:
435
+ stdout, stderr = proc.communicate(input=input_json, timeout=effective_timeout)
436
+ except subprocess.TimeoutExpired:
437
+ proc.kill()
438
+ proc.communicate()
439
+ memory_exceeded.set()
440
+ return ExecutionResult(
441
+ success=False,
442
+ error=f"Execution timed out after {effective_timeout} seconds",
443
+ exit_code=-1
444
+ )
445
+
446
+ # Stop memory monitoring
447
+ memory_exceeded.set()
448
+
449
+ # Check if process was killed due to memory limit
450
+ if memory_monitor_error:
451
+ return ExecutionResult(
452
+ success=False,
453
+ error=memory_monitor_error[0],
454
+ exit_code=-1
455
+ )
456
+
457
+ return self._parse_output(stdout, stderr, proc.returncode)
458
+
459
+ except Exception as e:
460
+ memory_exceeded.set()
461
+ return ExecutionResult(
462
+ success=False,
463
+ error=f"Execution failed: {str(e)}",
464
+ exit_code=-1
465
+ )
466
+
467
+ def execute(
468
+ self,
469
+ skill_dir: Path,
470
+ input_data: Dict[str, Any],
471
+ allow_network: Optional[bool] = None,
472
+ timeout: Optional[int] = None,
473
+ entry_point: Optional[str] = None,
474
+ enable_sandbox: Optional[bool] = None
475
+ ) -> ExecutionResult:
476
+ """
477
+ Execute a skill with the given input.
478
+
479
+ Args:
480
+ skill_dir: Path to the skill directory
481
+ input_data: Input data for the skill
482
+ allow_network: Override default network setting
483
+ timeout: Execution timeout in seconds
484
+ entry_point: Optional specific script to execute (e.g., "scripts/init_skill.py").
485
+ If provided, uses exec_script instead of run command.
486
+ enable_sandbox: Override default sandbox setting
487
+
488
+ Returns:
489
+ ExecutionResult with the output or error
490
+ """
491
+ # If a specific entry_point is provided, use exec_script
492
+ if entry_point:
493
+ return self.exec_script(
494
+ skill_dir=skill_dir,
495
+ script_path=entry_point,
496
+ input_data=input_data,
497
+ allow_network=allow_network,
498
+ timeout=timeout,
499
+ enable_sandbox=enable_sandbox
500
+ )
501
+
502
+ if allow_network is None:
503
+ allow_network = self.allow_network
504
+
505
+ if enable_sandbox is None:
506
+ enable_sandbox = self.enable_sandbox
507
+
508
+ effective_timeout = timeout if timeout is not None else self.execution_timeout
509
+
510
+ # Check sandbox level - Level 1 and 2 should use direct execution for Python skills
511
+ if self.sandbox_level != "3":
512
+ python_entry_points = ["scripts/main.py", "main.py"]
513
+ for entry in python_entry_points:
514
+ script_path = skill_dir / entry
515
+ if script_path.exists():
516
+ return self.exec_script(
517
+ skill_dir=skill_dir,
518
+ script_path=entry,
519
+ input_data=input_data,
520
+ allow_network=allow_network,
521
+ timeout=timeout,
522
+ enable_sandbox=enable_sandbox
523
+ )
524
+
525
+ # Build command for skillbox run
526
+ cmd = [
527
+ self.binary_path,
528
+ "run",
529
+ str(skill_dir),
530
+ json.dumps(input_data)
531
+ ]
532
+
533
+ if allow_network:
534
+ cmd.append("--allow-network")
535
+
536
+ if self.cache_dir:
537
+ cmd.extend(["--cache-dir", self.cache_dir])
538
+
539
+ skill_env = self._build_skill_env(skill_dir, timeout)
540
+
541
+ # Execute with Level 3 user interaction support
542
+ try:
543
+ if self.sandbox_level == "3":
544
+ result = subprocess.run(
545
+ cmd,
546
+ stdin=None,
547
+ stdout=subprocess.PIPE,
548
+ stderr=None,
549
+ text=True,
550
+ timeout=effective_timeout,
551
+ env=skill_env
552
+ )
553
+ else:
554
+ result = subprocess.run(
555
+ cmd,
556
+ capture_output=True,
557
+ text=True,
558
+ timeout=effective_timeout,
559
+ env=skill_env
560
+ )
561
+
562
+ stderr = result.stderr if hasattr(result, 'stderr') and result.stderr else ""
563
+
564
+ if result.returncode == 0:
565
+ try:
566
+ output = json.loads(result.stdout.strip())
567
+ return ExecutionResult(
568
+ success=True,
569
+ output=output,
570
+ exit_code=result.returncode,
571
+ stdout=result.stdout,
572
+ stderr=stderr
573
+ )
574
+ except json.JSONDecodeError as e:
575
+ return ExecutionResult(
576
+ success=False,
577
+ error=f"Invalid JSON output: {e}",
578
+ exit_code=result.returncode,
579
+ stdout=result.stdout,
580
+ stderr=stderr
581
+ )
582
+ else:
583
+ return ExecutionResult(
584
+ success=False,
585
+ error=stderr or f"Exit code: {result.returncode}",
586
+ exit_code=result.returncode,
587
+ stdout=result.stdout,
588
+ stderr=stderr
589
+ )
590
+
591
+ except subprocess.TimeoutExpired:
592
+ return ExecutionResult(
593
+ success=False,
594
+ error=f"Execution timed out after {effective_timeout} seconds",
595
+ exit_code=-1
596
+ )
597
+ except FileNotFoundError:
598
+ return ExecutionResult(
599
+ success=False,
600
+ error=f"skillbox binary not found at: {self.binary_path}",
601
+ exit_code=-1
602
+ )
603
+ except Exception as e:
604
+ return ExecutionResult(
605
+ success=False,
606
+ error=str(e),
607
+ exit_code=-1
608
+ )
@@ -0,0 +1,77 @@
1
+ """
2
+ Common utilities for sandbox implementations.
3
+
4
+ This module provides shared functionality used by different executor
5
+ implementations, reducing code duplication.
6
+ """
7
+
8
+ from typing import Any, Dict, List
9
+
10
+
11
+ # Default positional argument keys that should be treated as positional args
12
+ DEFAULT_POSITIONAL_KEYS = {"skill_name", "skill-name", "name", "input", "file", "filename"}
13
+
14
+
15
+ def convert_json_to_cli_args(
16
+ input_data: Dict[str, Any],
17
+ positional_keys: set = None
18
+ ) -> List[str]:
19
+ """
20
+ Convert JSON input data to command line arguments list.
21
+
22
+ This handles the conversion of JSON parameters to CLI format:
23
+ - Positional args: keys like "skill_name" or "skill-name" become positional values
24
+ - Named args: keys like "path" become "--path value"
25
+ - Boolean flags: true becomes "--flag", false is omitted
26
+ - Arrays: become comma-separated values
27
+
28
+ Args:
29
+ input_data: JSON input data from LLM
30
+ positional_keys: Set of keys to treat as positional arguments.
31
+ Defaults to DEFAULT_POSITIONAL_KEYS.
32
+
33
+ Returns:
34
+ List of command line arguments
35
+
36
+ Example:
37
+ >>> convert_json_to_cli_args({"name": "test", "verbose": True, "count": 5})
38
+ ['test', '--verbose', '--count', '5']
39
+ """
40
+ if positional_keys is None:
41
+ positional_keys = DEFAULT_POSITIONAL_KEYS
42
+
43
+ args_list = []
44
+
45
+ # First, handle positional arguments
46
+ for key in positional_keys:
47
+ if key in input_data:
48
+ value = input_data[key]
49
+ if isinstance(value, str):
50
+ args_list.append(value)
51
+ break
52
+
53
+ # Then handle named arguments
54
+ for key, value in input_data.items():
55
+ # Skip positional args already handled
56
+ normalized_key = key.replace("-", "_")
57
+ if normalized_key in {k.replace("-", "_") for k in positional_keys}:
58
+ continue
59
+
60
+ # Convert key to CLI format (e.g., "skill_name" -> "--skill-name")
61
+ cli_key = f"--{key.replace('_', '-')}"
62
+
63
+ if isinstance(value, bool):
64
+ # Boolean flags: only add if True
65
+ if value:
66
+ args_list.append(cli_key)
67
+ elif isinstance(value, list):
68
+ # Arrays become comma-separated
69
+ if value:
70
+ args_list.append(cli_key)
71
+ args_list.append(",".join(str(v) for v in value))
72
+ elif value is not None:
73
+ # Regular values
74
+ args_list.append(cli_key)
75
+ args_list.append(str(value))
76
+
77
+ return args_list