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,229 @@
1
+ """
2
+ Sandbox configuration management.
3
+
4
+ This module provides centralized configuration for the sandbox executor,
5
+ including default values, environment variable handling, and validation.
6
+ """
7
+
8
+ import os
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional
11
+
12
+
13
+ # Default configuration values
14
+ DEFAULT_EXECUTION_TIMEOUT = 120 # seconds
15
+ DEFAULT_MAX_MEMORY_MB = 512 # MB
16
+ DEFAULT_SANDBOX_LEVEL = "3" # Level 3: Sandbox isolation + static code scanning
17
+ DEFAULT_ALLOW_NETWORK = False
18
+ DEFAULT_ENABLE_SANDBOX = True
19
+ DEFAULT_AUTO_INSTALL = False
20
+
21
+
22
+ @dataclass
23
+ class SandboxConfig:
24
+ """
25
+ Configuration for sandbox execution.
26
+
27
+ This class centralizes all configuration options for the sandbox executor,
28
+ supporting both programmatic configuration and environment variables.
29
+
30
+ Priority order (highest to lowest):
31
+ 1. Explicit constructor arguments
32
+ 2. Environment variables
33
+ 3. Default values
34
+
35
+ Environment Variables:
36
+ SKILLBOX_BINARY_PATH: Path to the skillbox binary
37
+ SKILLBOX_CACHE_DIR: Directory for caching virtual environments
38
+ SKILLBOX_SANDBOX_LEVEL: Security level (1/2/3)
39
+ SKILLBOX_MAX_MEMORY_MB: Maximum memory limit in MB
40
+ SKILLBOX_TIMEOUT_SECS: Execution timeout in seconds
41
+ SKILLBOX_ALLOW_NETWORK: Allow network access (true/false/1/0)
42
+ SKILLBOX_ENABLE_SANDBOX: Enable sandbox protection (true/false/1/0)
43
+ SKILLBOX_AUTO_APPROVE: Auto-approve security prompts (true/false/1/0)
44
+
45
+ # Legacy environment variables (deprecated, use SKILLBOX_* prefix)
46
+ EXECUTION_TIMEOUT: Execution timeout in seconds
47
+ MAX_MEMORY_MB: Maximum memory limit in MB
48
+
49
+ Attributes:
50
+ binary_path: Path to the skillbox binary. If None, auto-detect.
51
+ cache_dir: Directory for caching virtual environments.
52
+ allow_network: Whether to allow network access by default.
53
+ enable_sandbox: Whether to enable sandbox protection.
54
+ execution_timeout: Skill execution timeout in seconds.
55
+ max_memory_mb: Maximum memory limit in MB.
56
+ sandbox_level: Sandbox security level (1/2/3).
57
+ auto_install: Automatically download and install binary if not found.
58
+ auto_approve: Auto-approve security prompts in Level 3.
59
+ """
60
+
61
+ binary_path: Optional[str] = None
62
+ cache_dir: Optional[str] = None
63
+ allow_network: bool = field(default_factory=lambda: _parse_bool_env("SKILLBOX_ALLOW_NETWORK", DEFAULT_ALLOW_NETWORK))
64
+ enable_sandbox: bool = field(default_factory=lambda: _parse_bool_env("SKILLBOX_ENABLE_SANDBOX", DEFAULT_ENABLE_SANDBOX))
65
+ execution_timeout: int = field(default_factory=lambda: _get_timeout_from_env())
66
+ max_memory_mb: int = field(default_factory=lambda: _get_memory_from_env())
67
+ sandbox_level: str = field(default_factory=lambda: os.environ.get("SKILLBOX_SANDBOX_LEVEL", DEFAULT_SANDBOX_LEVEL))
68
+ auto_install: bool = field(default_factory=lambda: _parse_bool_env("SKILLBOX_AUTO_INSTALL", DEFAULT_AUTO_INSTALL))
69
+ auto_approve: bool = field(default_factory=lambda: _parse_bool_env("SKILLBOX_AUTO_APPROVE", False))
70
+
71
+ def __post_init__(self):
72
+ """Validate configuration after initialization."""
73
+ self._validate()
74
+
75
+ def _validate(self) -> None:
76
+ """Validate configuration values."""
77
+ # Validate sandbox level
78
+ if self.sandbox_level not in ("1", "2", "3"):
79
+ raise ValueError(
80
+ f"Invalid sandbox_level '{self.sandbox_level}'. "
81
+ f"Must be '1', '2', or '3'."
82
+ )
83
+
84
+ # Validate timeout
85
+ if self.execution_timeout <= 0:
86
+ raise ValueError(
87
+ f"Invalid execution_timeout {self.execution_timeout}. "
88
+ f"Must be a positive integer."
89
+ )
90
+
91
+ # Validate memory limit
92
+ if self.max_memory_mb <= 0:
93
+ raise ValueError(
94
+ f"Invalid max_memory_mb {self.max_memory_mb}. "
95
+ f"Must be a positive integer."
96
+ )
97
+
98
+ @classmethod
99
+ def from_env(cls) -> "SandboxConfig":
100
+ """
101
+ Create configuration from environment variables only.
102
+
103
+ Returns:
104
+ SandboxConfig with values from environment variables.
105
+ """
106
+ return cls(
107
+ binary_path=os.environ.get("SKILLBOX_BINARY_PATH"),
108
+ cache_dir=os.environ.get("SKILLBOX_CACHE_DIR"),
109
+ )
110
+
111
+ def with_overrides(
112
+ self,
113
+ binary_path: Optional[str] = None,
114
+ cache_dir: Optional[str] = None,
115
+ allow_network: Optional[bool] = None,
116
+ enable_sandbox: Optional[bool] = None,
117
+ execution_timeout: Optional[int] = None,
118
+ max_memory_mb: Optional[int] = None,
119
+ sandbox_level: Optional[str] = None,
120
+ auto_install: Optional[bool] = None,
121
+ ) -> "SandboxConfig":
122
+ """
123
+ Create a new config with specified overrides.
124
+
125
+ Args:
126
+ binary_path: Override binary path
127
+ cache_dir: Override cache directory
128
+ allow_network: Override network setting
129
+ enable_sandbox: Override sandbox setting
130
+ execution_timeout: Override timeout
131
+ max_memory_mb: Override memory limit
132
+ sandbox_level: Override sandbox level
133
+ auto_install: Override auto-install setting
134
+
135
+ Returns:
136
+ New SandboxConfig with overrides applied.
137
+ """
138
+ return SandboxConfig(
139
+ binary_path=binary_path if binary_path is not None else self.binary_path,
140
+ cache_dir=cache_dir if cache_dir is not None else self.cache_dir,
141
+ allow_network=allow_network if allow_network is not None else self.allow_network,
142
+ enable_sandbox=enable_sandbox if enable_sandbox is not None else self.enable_sandbox,
143
+ execution_timeout=execution_timeout if execution_timeout is not None else self.execution_timeout,
144
+ max_memory_mb=max_memory_mb if max_memory_mb is not None else self.max_memory_mb,
145
+ sandbox_level=sandbox_level if sandbox_level is not None else self.sandbox_level,
146
+ auto_install=auto_install if auto_install is not None else self.auto_install,
147
+ )
148
+
149
+
150
+ def _parse_bool_env(key: str, default: bool) -> bool:
151
+ """
152
+ Parse a boolean value from environment variable.
153
+
154
+ Accepts: true, false, 1, 0, yes, no (case-insensitive)
155
+
156
+ Args:
157
+ key: Environment variable name
158
+ default: Default value if not set
159
+
160
+ Returns:
161
+ Parsed boolean value
162
+ """
163
+ value = os.environ.get(key)
164
+ if value is None:
165
+ return default
166
+
167
+ value_lower = value.lower().strip()
168
+ if value_lower in ("true", "1", "yes", "on"):
169
+ return True
170
+ elif value_lower in ("false", "0", "no", "off", ""):
171
+ return False
172
+ else:
173
+ return default
174
+
175
+
176
+ def _get_timeout_from_env() -> int:
177
+ """
178
+ Get execution timeout from environment variables.
179
+
180
+ Checks SKILLBOX_TIMEOUT_SECS first, then falls back to legacy EXECUTION_TIMEOUT.
181
+
182
+ Returns:
183
+ Timeout in seconds
184
+ """
185
+ # New environment variable (preferred)
186
+ value = os.environ.get("SKILLBOX_TIMEOUT_SECS")
187
+ if value:
188
+ try:
189
+ return int(value)
190
+ except ValueError:
191
+ pass
192
+
193
+ # Legacy environment variable (deprecated)
194
+ value = os.environ.get("EXECUTION_TIMEOUT")
195
+ if value:
196
+ try:
197
+ return int(value)
198
+ except ValueError:
199
+ pass
200
+
201
+ return DEFAULT_EXECUTION_TIMEOUT
202
+
203
+
204
+ def _get_memory_from_env() -> int:
205
+ """
206
+ Get memory limit from environment variables.
207
+
208
+ Checks SKILLBOX_MAX_MEMORY_MB first, then falls back to legacy MAX_MEMORY_MB.
209
+
210
+ Returns:
211
+ Memory limit in MB
212
+ """
213
+ # New environment variable (preferred)
214
+ value = os.environ.get("SKILLBOX_MAX_MEMORY_MB")
215
+ if value:
216
+ try:
217
+ return int(value)
218
+ except ValueError:
219
+ pass
220
+
221
+ # Legacy environment variable (deprecated)
222
+ value = os.environ.get("MAX_MEMORY_MB")
223
+ if value:
224
+ try:
225
+ return int(value)
226
+ except ValueError:
227
+ pass
228
+
229
+ return DEFAULT_MAX_MEMORY_MB
@@ -0,0 +1,44 @@
1
+ """
2
+ Skillbox sandbox implementation.
3
+
4
+ This module provides the Rust-based skillbox sandbox executor,
5
+ including binary management and execution logic.
6
+ """
7
+
8
+ from .binary import (
9
+ BINARY_VERSION,
10
+ BINARY_NAME,
11
+ get_install_dir,
12
+ get_binary_path,
13
+ get_version_file,
14
+ get_platform,
15
+ get_download_url,
16
+ is_installed,
17
+ get_installed_version,
18
+ needs_update,
19
+ install,
20
+ uninstall,
21
+ find_binary,
22
+ ensure_installed,
23
+ )
24
+ from .executor import SkillboxExecutor
25
+
26
+ __all__ = [
27
+ # Binary management
28
+ "BINARY_VERSION",
29
+ "BINARY_NAME",
30
+ "get_install_dir",
31
+ "get_binary_path",
32
+ "get_version_file",
33
+ "get_platform",
34
+ "get_download_url",
35
+ "is_installed",
36
+ "get_installed_version",
37
+ "needs_update",
38
+ "install",
39
+ "uninstall",
40
+ "find_binary",
41
+ "ensure_installed",
42
+ # Executor
43
+ "SkillboxExecutor",
44
+ ]
@@ -0,0 +1,421 @@
1
+ """
2
+ Binary management for the skillbox sandbox executor.
3
+
4
+ This module handles downloading, installing, and managing the Rust-based
5
+ sandbox binary, similar to how Playwright manages browser binaries.
6
+ """
7
+
8
+ import hashlib
9
+ import os
10
+ import platform
11
+ import shutil
12
+ import stat
13
+ import sys
14
+ import tarfile
15
+ import tempfile
16
+ import urllib.request
17
+ import zipfile
18
+ from pathlib import Path
19
+ from typing import Optional, Tuple
20
+
21
+ # Version of the binary to download
22
+ BINARY_VERSION = "0.1.0"
23
+
24
+ # GitHub repository for releases
25
+ GITHUB_OWNER = "EXboys"
26
+ GITHUB_REPO = "skilllite"
27
+
28
+ # Base URL for downloading binaries
29
+ DOWNLOAD_BASE_URL = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download"
30
+
31
+ # Supported platforms: (system, machine) -> platform_name
32
+ PLATFORM_MAP = {
33
+ ("Darwin", "arm64"): "darwin-arm64",
34
+ ("Darwin", "x86_64"): "darwin-x64",
35
+ ("Linux", "x86_64"): "linux-x64",
36
+ ("Linux", "aarch64"): "linux-arm64",
37
+ ("Linux", "arm64"): "linux-arm64",
38
+ }
39
+
40
+ # Binary name per platform
41
+ BINARY_NAME = "skillbox"
42
+
43
+
44
+ def get_install_dir() -> Path:
45
+ """
46
+ Get the installation directory for the skillbox binary.
47
+
48
+ Returns:
49
+ Path to ~/.skillbox/bin/
50
+ """
51
+ return Path.home() / ".skillbox" / "bin"
52
+
53
+
54
+ def get_binary_path() -> Path:
55
+ """
56
+ Get the full path to the installed binary.
57
+
58
+ Returns:
59
+ Path to ~/.skillbox/bin/skillbox
60
+ """
61
+ return get_install_dir() / BINARY_NAME
62
+
63
+
64
+ def get_version_file() -> Path:
65
+ """
66
+ Get the path to the version file.
67
+
68
+ Returns:
69
+ Path to ~/.skillbox/.version
70
+ """
71
+ return Path.home() / ".skillbox" / ".version"
72
+
73
+
74
+ def get_platform() -> str:
75
+ """
76
+ Detect the current platform.
77
+
78
+ Returns:
79
+ Platform string like 'darwin-arm64', 'linux-x64', etc.
80
+
81
+ Raises:
82
+ RuntimeError: If the platform is not supported.
83
+ """
84
+ system = platform.system()
85
+ machine = platform.machine()
86
+
87
+ key = (system, machine)
88
+ if key not in PLATFORM_MAP:
89
+ raise RuntimeError(
90
+ f"Unsupported platform: {system} {machine}. "
91
+ f"Supported platforms: macOS (x64, arm64), Linux (x64, arm64)"
92
+ )
93
+
94
+ return PLATFORM_MAP[key]
95
+
96
+
97
+ def get_download_url(version: Optional[str] = None) -> str:
98
+ """
99
+ Get the download URL for the current platform.
100
+
101
+ Args:
102
+ version: Version to download. Defaults to BINARY_VERSION.
103
+
104
+ Returns:
105
+ Full download URL for the binary.
106
+ """
107
+ version = version or BINARY_VERSION
108
+ plat = get_platform()
109
+
110
+ # Binary naming convention: skillbox-{platform}.tar.gz
111
+ filename = f"skillbox-{plat}.tar.gz"
112
+
113
+ return f"{DOWNLOAD_BASE_URL}/v{version}/{filename}"
114
+
115
+
116
+ def is_installed() -> bool:
117
+ """
118
+ Check if the skillbox binary is installed.
119
+
120
+ Returns:
121
+ True if the binary exists and is executable.
122
+ """
123
+ binary_path = get_binary_path()
124
+ return binary_path.exists() and os.access(binary_path, os.X_OK)
125
+
126
+
127
+ def get_installed_version() -> Optional[str]:
128
+ """
129
+ Get the version of the installed binary.
130
+
131
+ Returns:
132
+ Version string, or None if not installed or version unknown.
133
+ """
134
+ version_file = get_version_file()
135
+ if version_file.exists():
136
+ return version_file.read_text().strip()
137
+ return None
138
+
139
+
140
+ def needs_update(target_version: Optional[str] = None) -> bool:
141
+ """
142
+ Check if the binary needs to be updated.
143
+
144
+ Args:
145
+ target_version: Target version to check against.
146
+
147
+ Returns:
148
+ True if update is needed.
149
+ """
150
+ if not is_installed():
151
+ return True
152
+
153
+ target_version = target_version or BINARY_VERSION
154
+ installed_version = get_installed_version()
155
+
156
+ if installed_version is None:
157
+ return True
158
+
159
+ return installed_version != target_version
160
+
161
+
162
+ def download_with_progress(url: str, dest: Path, show_progress: bool = True) -> None:
163
+ """
164
+ Download a file with optional progress display.
165
+
166
+ Args:
167
+ url: URL to download from.
168
+ dest: Destination path.
169
+ show_progress: Whether to show progress bar.
170
+ """
171
+ def report_progress(block_num: int, block_size: int, total_size: int) -> None:
172
+ if not show_progress or total_size <= 0:
173
+ return
174
+
175
+ downloaded = block_num * block_size
176
+ percent = min(100, downloaded * 100 // total_size)
177
+ bar_length = 40
178
+ filled = int(bar_length * percent // 100)
179
+ bar = "█" * filled + "░" * (bar_length - filled)
180
+
181
+ sys.stdout.write(f"\r Downloading: [{bar}] {percent}%")
182
+ sys.stdout.flush()
183
+
184
+ if downloaded >= total_size:
185
+ sys.stdout.write("\n")
186
+ sys.stdout.flush()
187
+
188
+ try:
189
+ urllib.request.urlretrieve(url, dest, reporthook=report_progress if show_progress else None)
190
+ except urllib.error.HTTPError as e:
191
+ if e.code == 404:
192
+ raise RuntimeError(
193
+ f"Binary not found at {url}. "
194
+ f"Please check if version {BINARY_VERSION} has been released."
195
+ ) from e
196
+ raise
197
+
198
+
199
+ def extract_archive(archive_path: Path, dest_dir: Path) -> Path:
200
+ """
201
+ Extract a tar.gz or zip archive.
202
+
203
+ Args:
204
+ archive_path: Path to the archive.
205
+ dest_dir: Directory to extract to.
206
+
207
+ Returns:
208
+ Path to the extracted binary.
209
+ """
210
+ if archive_path.suffix == ".gz" or str(archive_path).endswith(".tar.gz"):
211
+ with tarfile.open(archive_path, "r:gz") as tar:
212
+ tar.extractall(dest_dir)
213
+ elif archive_path.suffix == ".zip":
214
+ with zipfile.ZipFile(archive_path, "r") as zip_ref:
215
+ zip_ref.extractall(dest_dir)
216
+ else:
217
+ raise RuntimeError(f"Unknown archive format: {archive_path}")
218
+
219
+ # Find the binary in extracted files
220
+ binary_path = dest_dir / BINARY_NAME
221
+ if binary_path.exists():
222
+ return binary_path
223
+
224
+ # Check if it's in a subdirectory
225
+ for item in dest_dir.iterdir():
226
+ if item.is_dir():
227
+ nested_binary = item / BINARY_NAME
228
+ if nested_binary.exists():
229
+ return nested_binary
230
+
231
+ raise RuntimeError(f"Binary not found in archive: {archive_path}")
232
+
233
+
234
+ def install(
235
+ version: Optional[str] = None,
236
+ force: bool = False,
237
+ show_progress: bool = True
238
+ ) -> Path:
239
+ """
240
+ Download and install the skillbox binary.
241
+
242
+ Args:
243
+ version: Version to install. Defaults to BINARY_VERSION.
244
+ force: Force reinstall even if already installed.
245
+ show_progress: Show download progress.
246
+
247
+ Returns:
248
+ Path to the installed binary.
249
+ """
250
+ version = version or BINARY_VERSION
251
+
252
+ if not force and is_installed() and not needs_update(version):
253
+ installed_version = get_installed_version()
254
+ print(f"✓ skillbox v{installed_version} is already installed")
255
+ return get_binary_path()
256
+
257
+ plat = get_platform()
258
+ print(f"Installing skillbox v{version} for {plat}...")
259
+
260
+ # Create install directory
261
+ install_dir = get_install_dir()
262
+ install_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ # Download to temp directory
265
+ with tempfile.TemporaryDirectory() as temp_dir:
266
+ temp_path = Path(temp_dir)
267
+ archive_name = f"skillbox-{plat}.tar.gz"
268
+ archive_path = temp_path / archive_name
269
+
270
+ # Download
271
+ url = get_download_url(version)
272
+ print(f" Downloading from: {url}")
273
+ download_with_progress(url, archive_path, show_progress)
274
+
275
+ # Extract
276
+ print(" Extracting...")
277
+ extracted_binary = extract_archive(archive_path, temp_path)
278
+
279
+ # Move to install location
280
+ dest_binary = get_binary_path()
281
+ if dest_binary.exists():
282
+ dest_binary.unlink()
283
+
284
+ shutil.move(str(extracted_binary), str(dest_binary))
285
+
286
+ # Make executable
287
+ dest_binary.chmod(dest_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
288
+
289
+ # Write version file
290
+ version_file = get_version_file()
291
+ version_file.parent.mkdir(parents=True, exist_ok=True)
292
+ version_file.write_text(version)
293
+
294
+ print(f"✓ Successfully installed skillbox v{version}")
295
+ print(f" Location: {dest_binary}")
296
+
297
+ return dest_binary
298
+
299
+
300
+ def uninstall() -> bool:
301
+ """
302
+ Uninstall the skillbox binary.
303
+
304
+ Returns:
305
+ True if uninstalled, False if not installed.
306
+ """
307
+ binary_path = get_binary_path()
308
+ version_file = get_version_file()
309
+
310
+ if not binary_path.exists():
311
+ print("skillbox is not installed")
312
+ return False
313
+
314
+ binary_path.unlink()
315
+ if version_file.exists():
316
+ version_file.unlink()
317
+
318
+ print("✓ Successfully uninstalled skillbox")
319
+ return True
320
+
321
+
322
+ def find_binary() -> Optional[str]:
323
+ """
324
+ Find the skillbox binary.
325
+
326
+ Search order:
327
+ 1. ~/.skillbox/bin/skillbox (installed by this package)
328
+ 2. System PATH
329
+ 3. ~/.cargo/bin/skillbox (cargo install)
330
+ 4. Common system locations
331
+ 5. Development build locations
332
+
333
+ Returns:
334
+ Path to the binary, or None if not found.
335
+ """
336
+ # 1. Check our install location first
337
+ our_binary = get_binary_path()
338
+ if our_binary.exists() and os.access(our_binary, os.X_OK):
339
+ return str(our_binary)
340
+
341
+ # 2. Check PATH
342
+ path_binary = shutil.which(BINARY_NAME)
343
+ if path_binary:
344
+ return path_binary
345
+
346
+ # 3. Check cargo install location
347
+ cargo_binary = Path.home() / ".cargo" / "bin" / BINARY_NAME
348
+ if cargo_binary.exists():
349
+ return str(cargo_binary)
350
+
351
+ # 4. Check common system locations
352
+ system_locations = [
353
+ Path("/usr/local/bin") / BINARY_NAME,
354
+ Path("/usr/bin") / BINARY_NAME,
355
+ ]
356
+
357
+ for loc in system_locations:
358
+ if loc.exists() and os.access(loc, os.X_OK):
359
+ return str(loc)
360
+
361
+ # 5. Check development build locations (relative to common project structures)
362
+ dev_locations = [
363
+ Path("skillbox/target/release") / BINARY_NAME,
364
+ Path("../skillbox/target/release") / BINARY_NAME,
365
+ Path("../../skillbox/target/release") / BINARY_NAME,
366
+ ]
367
+
368
+ for loc in dev_locations:
369
+ if loc.exists() and os.access(loc, os.X_OK):
370
+ return str(loc.resolve())
371
+
372
+ return None
373
+
374
+
375
+ def ensure_installed(
376
+ auto_install: bool = True,
377
+ show_progress: bool = True
378
+ ) -> str:
379
+ """
380
+ Ensure the skillbox binary is installed and return its path.
381
+
382
+ This is the main entry point for getting a working binary path.
383
+ It will:
384
+ 1. Try to find an existing binary
385
+ 2. If not found and auto_install is True, download and install it
386
+ 3. Raise an error if binary cannot be found or installed
387
+
388
+ Args:
389
+ auto_install: Automatically install if not found.
390
+ show_progress: Show download progress during installation.
391
+
392
+ Returns:
393
+ Path to the binary.
394
+
395
+ Raises:
396
+ FileNotFoundError: If binary not found and auto_install is False.
397
+ RuntimeError: If installation fails.
398
+ """
399
+ # First, try to find existing binary
400
+ existing = find_binary()
401
+ if existing:
402
+ return existing
403
+
404
+ # Not found - try to install if allowed
405
+ if auto_install:
406
+ try:
407
+ installed_path = install(show_progress=show_progress)
408
+ return str(installed_path)
409
+ except Exception as e:
410
+ raise RuntimeError(
411
+ f"Failed to install skillbox binary: {e}\n"
412
+ f"You can manually install it with: skilllite install"
413
+ ) from e
414
+
415
+ # Not found and not allowed to install
416
+ raise FileNotFoundError(
417
+ "skillbox binary not found. Install it with:\n"
418
+ " skilllite install\n"
419
+ "Or build from source:\n"
420
+ " cd skillbox && cargo build --release"
421
+ )