esprit-cli 0.7.0 → 0.7.1

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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ Esprit is an autonomous security assessment tool that uses AI agents to perform
11
11
  ### Option 1: Install with curl
12
12
 
13
13
  ```bash
14
- curl -fsSL https://esprit.dev/install.sh | bash
14
+ curl -fsSL https://raw.githubusercontent.com/improdead/Esprit/main/scripts/install.sh | bash
15
15
  ```
16
16
 
17
17
  ### Option 2: Homebrew
@@ -24,7 +24,7 @@ brew install esprit
24
24
  ### Option 3: npm
25
25
 
26
26
  ```bash
27
- npm install -g @dekai/esprit
27
+ npm install -g github:improdead/Esprit
28
28
  ```
29
29
 
30
30
  ### Option 4: From Source
@@ -38,6 +38,20 @@ poetry install
38
38
 
39
39
  ---
40
40
 
41
+ ## Interactive Onboarding
42
+
43
+ Run `esprit` with no arguments to open the launchpad onboarding UI.
44
+
45
+ - Guided setup for provider, model, target, and scan mode
46
+ - Unified theme across onboarding and scanning TUI
47
+ - Local directory scan input with tab autocomplete
48
+
49
+ ```bash
50
+ esprit
51
+ ```
52
+
53
+ ---
54
+
41
55
  ## Choose Your Setup
42
56
 
43
57
  Esprit supports two runtime modes depending on how you want to run scans.
@@ -153,6 +167,12 @@ esprit provider status # Check all connected providers
153
167
  esprit provider logout <provider> # Disconnect a provider
154
168
  ```
155
169
 
170
+ OpenCode notes:
171
+
172
+ - Public OpenCode models can be used without credentials when available on your machine.
173
+ - If a public OpenCode model hits rate limits or upstream instability, Esprit auto-falls back to another healthy public model.
174
+ - Recommended public models: `opencode/minimax-m2.5-free`, `opencode/kimi-k2.5-free`, `opencode/gpt-5-nano`.
175
+
156
176
  ---
157
177
 
158
178
  ## Configuration
package/bin/esprit.js CHANGED
@@ -1,22 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const fs = require("node:fs");
4
+ const os = require("node:os");
4
5
  const path = require("node:path");
5
6
  const { spawnSync } = require("node:child_process");
6
7
 
7
8
  const isWindows = process.platform === "win32";
8
9
  const binaryName = isWindows ? "esprit.exe" : "esprit";
9
- const installDir = path.resolve(__dirname, "..", "npm", ".esprit-bin");
10
+ const installDir = path.join(os.homedir(), ".esprit", "bin");
10
11
  const binaryPath = path.join(installDir, binaryName);
11
- const installerPath = path.resolve(__dirname, "..", "npm", "postinstall.mjs");
12
+ const installerPath = path.resolve(__dirname, "..", "scripts", "install.sh");
12
13
 
13
14
  function ensureInstalled() {
14
15
  if (fs.existsSync(binaryPath)) {
15
16
  return;
16
17
  }
17
18
 
18
- const bootstrap = spawnSync(process.execPath, [installerPath], {
19
+ if (isWindows) {
20
+ console.error("[esprit] installer currently supports macOS/Linux. Use WSL on Windows.");
21
+ process.exit(1);
22
+ }
23
+
24
+ const bootstrap = spawnSync("bash", [installerPath], {
19
25
  stdio: "inherit",
26
+ env: {
27
+ ...process.env,
28
+ ESPRIT_SKIP_DOCKER_WARM: process.env.ESPRIT_SKIP_DOCKER_WARM || "1",
29
+ },
20
30
  });
21
31
  if (bootstrap.status !== 0) {
22
32
  process.exit(bootstrap.status || 1);
@@ -1,126 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from "node:fs/promises";
4
3
  import path from "node:path";
5
- import os from "node:os";
6
4
  import { spawnSync } from "node:child_process";
7
5
  import { fileURLToPath } from "node:url";
8
6
 
9
7
  const __filename = fileURLToPath(import.meta.url);
10
8
  const __dirname = path.dirname(__filename);
11
- const projectRoot = path.resolve(__dirname, "..");
12
- const packageJsonPath = path.join(projectRoot, "package.json");
13
- const installDir = path.join(projectRoot, "npm", ".esprit-bin");
14
- const repo = "improdead/Esprit";
9
+ const installerPath = path.resolve(__dirname, "..", "scripts", "install.sh");
15
10
 
16
- function getTarget() {
17
- let platform = os.platform();
18
- let arch = os.arch();
19
-
20
- if (platform === "darwin") {
21
- platform = "macos";
22
- } else if (platform === "linux") {
23
- platform = "linux";
24
- } else if (platform === "win32") {
25
- platform = "windows";
26
- } else {
27
- throw new Error(`Unsupported platform: ${platform}`);
28
- }
29
-
30
- if (arch === "arm64") {
31
- arch = "arm64";
32
- } else if (arch === "x64") {
33
- arch = "x86_64";
34
- } else {
35
- throw new Error(`Unsupported architecture: ${arch}`);
36
- }
37
-
38
- if (platform === "windows" && arch !== "x86_64") {
39
- throw new Error(`Unsupported platform/arch: ${platform}/${arch}`);
40
- }
41
-
42
- return { platform, arch, target: `${platform}-${arch}` };
43
- }
44
-
45
- function buildAssetName(version, target) {
46
- const ext = target.startsWith("windows-") ? ".zip" : ".tar.gz";
47
- return `esprit-${version}-${target}${ext}`;
48
- }
49
-
50
- async function downloadFile(url, destPath) {
51
- const response = await fetch(url);
52
- if (!response.ok) {
53
- throw new Error(`Download failed (${response.status}) ${url}`);
54
- }
55
- const arrayBuffer = await response.arrayBuffer();
56
- await fs.writeFile(destPath, Buffer.from(arrayBuffer));
57
- }
58
-
59
- async function ensureDir(dir) {
60
- await fs.mkdir(dir, { recursive: true });
61
- }
62
-
63
- function runCommand(command, args) {
64
- const result = spawnSync(command, args, { stdio: "inherit" });
65
- if (result.status !== 0) {
66
- throw new Error(`Command failed: ${command} ${args.join(" ")}`);
67
- }
11
+ if (process.platform === "win32") {
12
+ process.stdout.write("[esprit] npm install currently supports macOS/Linux. Use WSL on Windows.\n");
13
+ process.exit(0);
68
14
  }
69
15
 
70
- function extractArchive(archivePath, extractDir) {
71
- if (archivePath.endsWith(".zip")) {
72
- if (process.platform === "win32") {
73
- runCommand("powershell", [
74
- "-NoProfile",
75
- "-Command",
76
- `Expand-Archive -Path "${archivePath}" -DestinationPath "${extractDir}" -Force`,
77
- ]);
78
- } else {
79
- runCommand("unzip", ["-o", archivePath, "-d", extractDir]);
80
- }
81
- return;
82
- }
83
-
84
- runCommand("tar", ["-xzf", archivePath, "-C", extractDir]);
85
- }
86
-
87
- async function installBinary() {
88
- const pkg = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
89
- const version = process.env.ESPRIT_VERSION || pkg.version;
90
- const { target } = getTarget();
91
- const assetName = buildAssetName(version, target);
92
- const downloadUrl = `https://github.com/${repo}/releases/download/v${version}/${assetName}`;
93
-
94
- const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "esprit-npm-"));
95
- const archivePath = path.join(tempRoot, assetName);
96
- const extractDir = path.join(tempRoot, "extract");
97
- await ensureDir(extractDir);
98
-
99
- try {
100
- process.stdout.write(`[esprit] downloading ${assetName}\n`);
101
- await downloadFile(downloadUrl, archivePath);
102
- extractArchive(archivePath, extractDir);
103
-
104
- const binaryName = target.startsWith("windows-") ? "esprit.exe" : "esprit";
105
- const extractedPath = path.join(extractDir, binaryName);
106
- await fs.access(extractedPath);
107
-
108
- await ensureDir(installDir);
109
- const outputPath = path.join(installDir, binaryName);
110
- await fs.copyFile(extractedPath, outputPath);
111
-
112
- if (!target.startsWith("windows-")) {
113
- await fs.chmod(outputPath, 0o755);
114
- }
16
+ const result = spawnSync("bash", [installerPath], {
17
+ stdio: "inherit",
18
+ env: {
19
+ ...process.env,
20
+ ESPRIT_SKIP_DOCKER_WARM: process.env.ESPRIT_SKIP_DOCKER_WARM || "1",
21
+ },
22
+ });
115
23
 
116
- await fs.writeFile(path.join(installDir, "VERSION"), `${version}\n`, "utf8");
117
- process.stdout.write(`[esprit] installed ${binaryName}\n`);
118
- } finally {
119
- await fs.rm(tempRoot, { recursive: true, force: true });
120
- }
24
+ if (result.status !== 0) {
25
+ process.exit(result.status || 1);
121
26
  }
122
-
123
- installBinary().catch((error) => {
124
- process.stderr.write(`[esprit] install failed: ${error.message}\n`);
125
- process.exit(1);
126
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esprit-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "AI-powered penetration testing agent",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://esprit.dev",
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "bin",
19
19
  "npm",
20
+ "scripts",
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],
@@ -26,4 +27,4 @@
26
27
  "engines": {
27
28
  "node": ">=18"
28
29
  }
29
- }
30
+ }
@@ -0,0 +1,98 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
6
+
7
+ RED='\033[0;31m'
8
+ GREEN='\033[0;32m'
9
+ YELLOW='\033[1;33m'
10
+ BLUE='\033[0;34m'
11
+ NC='\033[0m' # No Color
12
+
13
+ echo -e "${BLUE}Esprit Build Script${NC}"
14
+ echo "================================"
15
+
16
+ OS="$(uname -s)"
17
+ ARCH="$(uname -m)"
18
+
19
+ case "$OS" in
20
+ Linux*) OS_NAME="linux";;
21
+ Darwin*) OS_NAME="macos";;
22
+ MINGW*|MSYS*|CYGWIN*) OS_NAME="windows";;
23
+ *) OS_NAME="unknown";;
24
+ esac
25
+
26
+ case "$ARCH" in
27
+ x86_64|amd64) ARCH_NAME="x86_64";;
28
+ arm64|aarch64) ARCH_NAME="arm64";;
29
+ *) ARCH_NAME="$ARCH";;
30
+ esac
31
+
32
+ echo -e "${YELLOW}Platform:${NC} $OS_NAME-$ARCH_NAME"
33
+
34
+ cd "$PROJECT_ROOT"
35
+
36
+ if ! command -v poetry &> /dev/null; then
37
+ echo -e "${RED}Error: Poetry is not installed${NC}"
38
+ echo "Please install Poetry first: https://python-poetry.org/docs/#installation"
39
+ exit 1
40
+ fi
41
+
42
+ echo -e "\n${BLUE}Installing dependencies...${NC}"
43
+ poetry install --with dev
44
+
45
+ VERSION=$(poetry version -s)
46
+ echo -e "${YELLOW}Version:${NC} $VERSION"
47
+
48
+ echo -e "\n${BLUE}Cleaning previous builds...${NC}"
49
+ rm -rf build/ dist/
50
+
51
+ echo -e "\n${BLUE}Building binary with PyInstaller...${NC}"
52
+ poetry run pyinstaller esprit.spec --noconfirm
53
+
54
+ RELEASE_DIR="dist/release"
55
+ mkdir -p "$RELEASE_DIR"
56
+
57
+ BINARY_NAME="esprit-${VERSION}-${OS_NAME}-${ARCH_NAME}"
58
+
59
+ if [ "$OS_NAME" = "windows" ]; then
60
+ if [ ! -f "dist/esprit.exe" ]; then
61
+ echo -e "${RED}Build failed: Binary not found${NC}"
62
+ exit 1
63
+ fi
64
+ BINARY_NAME="${BINARY_NAME}.exe"
65
+ cp "dist/esprit.exe" "$RELEASE_DIR/$BINARY_NAME"
66
+ echo -e "\n${BLUE}Creating zip...${NC}"
67
+ ARCHIVE_NAME="${BINARY_NAME%.exe}.zip"
68
+
69
+ if command -v 7z &> /dev/null; then
70
+ 7z a "$RELEASE_DIR/$ARCHIVE_NAME" "$RELEASE_DIR/$BINARY_NAME"
71
+ else
72
+ powershell -Command "Compress-Archive -Path '$RELEASE_DIR/$BINARY_NAME' -DestinationPath '$RELEASE_DIR/$ARCHIVE_NAME'"
73
+ fi
74
+ echo -e "${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME"
75
+ else
76
+ if [ ! -f "dist/esprit" ]; then
77
+ echo -e "${RED}Build failed: Binary not found${NC}"
78
+ exit 1
79
+ fi
80
+ cp "dist/esprit" "$RELEASE_DIR/$BINARY_NAME"
81
+ chmod +x "$RELEASE_DIR/$BINARY_NAME"
82
+ echo -e "\n${BLUE}Creating tarball...${NC}"
83
+ ARCHIVE_NAME="${BINARY_NAME}.tar.gz"
84
+ tar -czvf "$RELEASE_DIR/$ARCHIVE_NAME" -C "$RELEASE_DIR" "$BINARY_NAME"
85
+ echo -e "${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME"
86
+ fi
87
+
88
+ echo -e "\n${GREEN}Build successful!${NC}"
89
+ echo "================================"
90
+ echo -e "${YELLOW}Binary:${NC} $RELEASE_DIR/$BINARY_NAME"
91
+
92
+ SIZE=$(ls -lh "$RELEASE_DIR/$BINARY_NAME" | awk '{print $5}')
93
+ echo -e "${YELLOW}Size:${NC} $SIZE"
94
+
95
+ echo -e "\n${BLUE}Testing binary...${NC}"
96
+ "$RELEASE_DIR/$BINARY_NAME" --help > /dev/null 2>&1 && echo -e "${GREEN}Binary test passed!${NC}" || echo -e "${RED}Binary test failed${NC}"
97
+
98
+ echo -e "\n${GREEN}Done!${NC}"
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ APP="esprit"
6
+ REPO_URL="${ESPRIT_REPO_URL:-https://github.com/improdead/Esprit.git}"
7
+ REPO_REF="${ESPRIT_REPO_REF:-main}"
8
+ INSTALL_ROOT="${ESPRIT_HOME:-$HOME/.esprit}"
9
+ BIN_DIR="$INSTALL_ROOT/bin"
10
+ RUNTIME_DIR="$INSTALL_ROOT/runtime"
11
+ VENV_DIR="$INSTALL_ROOT/venv"
12
+ LAUNCHER_PATH="$BIN_DIR/$APP"
13
+ ESPRIT_IMAGE="${ESPRIT_IMAGE:-improdead/esprit-sandbox:latest}"
14
+
15
+ MUTED='\033[0;2m'
16
+ RED='\033[0;31m'
17
+ GREEN='\033[0;32m'
18
+ YELLOW='\033[1;33m'
19
+ CYAN='\033[0;36m'
20
+ NC='\033[0m'
21
+
22
+ FORCE=false
23
+ for arg in "$@"; do
24
+ case "$arg" in
25
+ --force|-f) FORCE=true ;;
26
+ esac
27
+ done
28
+
29
+ print_message() {
30
+ local level="$1"
31
+ local message="$2"
32
+ local color="$NC"
33
+ case "$level" in
34
+ success) color="$GREEN" ;;
35
+ warning) color="$YELLOW" ;;
36
+ error) color="$RED" ;;
37
+ esac
38
+ echo -e "${color}${message}${NC}"
39
+ }
40
+
41
+ require_command() {
42
+ local cmd="$1"
43
+ local install_hint="$2"
44
+ if ! command -v "$cmd" >/dev/null 2>&1; then
45
+ print_message error "Missing required command: $cmd"
46
+ print_message info "$install_hint"
47
+ exit 1
48
+ fi
49
+ }
50
+
51
+ choose_python() {
52
+ local candidate
53
+ for candidate in python3.13 python3.12 python3; do
54
+ if ! command -v "$candidate" >/dev/null 2>&1; then
55
+ continue
56
+ fi
57
+
58
+ if "$candidate" - <<'PY' >/dev/null 2>&1
59
+ import sys
60
+ raise SystemExit(0 if sys.version_info >= (3, 12) else 1)
61
+ PY
62
+ then
63
+ echo "$candidate"
64
+ return 0
65
+ fi
66
+ done
67
+
68
+ return 1
69
+ }
70
+
71
+ sync_runtime_repo() {
72
+ print_message info "${MUTED}Syncing Esprit runtime source...${NC}"
73
+
74
+ if [ -d "$RUNTIME_DIR/.git" ]; then
75
+ git -C "$RUNTIME_DIR" remote set-url origin "$REPO_URL"
76
+ git -C "$RUNTIME_DIR" fetch --depth 1 origin "$REPO_REF"
77
+ git -C "$RUNTIME_DIR" checkout -q FETCH_HEAD
78
+ else
79
+ rm -rf "$RUNTIME_DIR"
80
+ git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$RUNTIME_DIR"
81
+ fi
82
+
83
+ local runtime_commit
84
+ runtime_commit=$(git -C "$RUNTIME_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
85
+ print_message success "✓ Runtime ready (${runtime_commit})"
86
+ }
87
+
88
+ install_python_runtime() {
89
+ local py_bin="$1"
90
+
91
+ mkdir -p "$INSTALL_ROOT"
92
+ if [ "$FORCE" = true ] || [ ! -x "$VENV_DIR/bin/python" ]; then
93
+ print_message info "${MUTED}Creating virtual environment...${NC}"
94
+ rm -rf "$VENV_DIR"
95
+ "$py_bin" -m venv "$VENV_DIR"
96
+ fi
97
+
98
+ print_message info "${MUTED}Installing Esprit dependencies (this can take a few minutes)...${NC}"
99
+ "$VENV_DIR/bin/python" -m pip install --upgrade pip setuptools wheel >/dev/null
100
+ "$VENV_DIR/bin/pip" install --upgrade "$RUNTIME_DIR" >/dev/null
101
+ print_message success "✓ Python runtime installed"
102
+ }
103
+
104
+ write_launcher() {
105
+ mkdir -p "$BIN_DIR"
106
+
107
+ cat > "$LAUNCHER_PATH" <<'EOF'
108
+ #!/usr/bin/env bash
109
+ set -euo pipefail
110
+
111
+ ROOT="${ESPRIT_HOME:-$HOME/.esprit}"
112
+ BIN="$ROOT/venv/bin/esprit"
113
+
114
+ if [ ! -x "$BIN" ]; then
115
+ echo "Esprit runtime not found. Re-run the installer."
116
+ exit 1
117
+ fi
118
+
119
+ exec "$BIN" "$@"
120
+ EOF
121
+
122
+ chmod 755 "$LAUNCHER_PATH"
123
+ print_message success "✓ Installed launcher at $LAUNCHER_PATH"
124
+ }
125
+
126
+ setup_path() {
127
+ local shell_name
128
+ shell_name=$(basename "${SHELL:-sh}")
129
+
130
+ local config_file=""
131
+ case "$shell_name" in
132
+ zsh)
133
+ config_file="${ZDOTDIR:-$HOME}/.zshrc"
134
+ ;;
135
+ bash)
136
+ config_file="$HOME/.bashrc"
137
+ ;;
138
+ fish)
139
+ config_file="$HOME/.config/fish/config.fish"
140
+ ;;
141
+ *)
142
+ config_file="$HOME/.profile"
143
+ ;;
144
+ esac
145
+
146
+ if [ "$shell_name" = "fish" ]; then
147
+ local line="fish_add_path $BIN_DIR"
148
+ if [ -f "$config_file" ] && grep -Fxq "$line" "$config_file" 2>/dev/null; then
149
+ return
150
+ fi
151
+ if [ ! -f "$config_file" ]; then
152
+ mkdir -p "$(dirname "$config_file")"
153
+ touch "$config_file"
154
+ fi
155
+ echo "" >> "$config_file"
156
+ echo "# esprit" >> "$config_file"
157
+ echo "$line" >> "$config_file"
158
+ print_message info "${MUTED}Added esprit to PATH in ${NC}$config_file"
159
+ return
160
+ fi
161
+
162
+ local export_line="export PATH=$BIN_DIR:\$PATH"
163
+ if [ -f "$config_file" ] && grep -Fxq "$export_line" "$config_file" 2>/dev/null; then
164
+ return
165
+ fi
166
+
167
+ if [ ! -f "$config_file" ]; then
168
+ touch "$config_file"
169
+ fi
170
+ if [ -w "$config_file" ]; then
171
+ echo "" >> "$config_file"
172
+ echo "# esprit" >> "$config_file"
173
+ echo "$export_line" >> "$config_file"
174
+ print_message info "${MUTED}Added esprit to PATH in ${NC}$config_file"
175
+ else
176
+ print_message warning "Could not update $config_file automatically."
177
+ print_message info "Add this line manually: $export_line"
178
+ fi
179
+ }
180
+
181
+ warm_docker_image() {
182
+ if [ "${ESPRIT_SKIP_DOCKER_WARM:-0}" = "1" ]; then
183
+ return
184
+ fi
185
+
186
+ if ! command -v docker >/dev/null 2>&1; then
187
+ print_message warning "Docker not found (required for local/provider scans)."
188
+ print_message info "Esprit Cloud scans still work without Docker."
189
+ return
190
+ fi
191
+
192
+ if ! docker info >/dev/null 2>&1; then
193
+ print_message warning "Docker daemon is not running."
194
+ print_message info "Start Docker for local/provider scans."
195
+ return
196
+ fi
197
+
198
+ print_message info "${MUTED}Pulling sandbox image (optional warm-up)...${NC}"
199
+ local pull_output
200
+ local pull_status=0
201
+ pull_output="$(docker pull "$ESPRIT_IMAGE" 2>&1)" || pull_status=$?
202
+
203
+ if [ "$pull_status" -eq 0 ]; then
204
+ print_message success "✓ Sandbox image ready"
205
+ return
206
+ fi
207
+
208
+ echo -e "$pull_output"
209
+
210
+ if [[ "$(uname -m)" == "arm64" ]] && echo "$pull_output" | grep -qi "no matching manifest" && echo "$pull_output" | grep -qi "arm64"; then
211
+ print_message warning "Native arm64 image missing; retrying with linux/amd64 emulation..."
212
+ if docker pull --platform linux/amd64 "$ESPRIT_IMAGE" >/dev/null; then
213
+ print_message success "✓ Sandbox image ready (linux/amd64 emulation)"
214
+ return
215
+ fi
216
+ fi
217
+
218
+ print_message warning "Sandbox pull skipped (will retry at first local scan)."
219
+ }
220
+
221
+ main() {
222
+ require_command git "Install git and re-run the installer."
223
+ require_command curl "Install curl and re-run the installer."
224
+
225
+ local py_bin
226
+ py_bin=$(choose_python || true)
227
+ if [ -z "$py_bin" ]; then
228
+ print_message error "Python 3.12+ is required."
229
+ print_message info "Install Python 3.12 and re-run this installer."
230
+ exit 1
231
+ fi
232
+
233
+ print_message info "${CYAN}Installing Esprit${NC} ${MUTED}(source mode)${NC}"
234
+ print_message info "${MUTED}Runtime source:${NC} $REPO_URL@$REPO_REF"
235
+ print_message info "${MUTED}Install root:${NC} $INSTALL_ROOT"
236
+
237
+ sync_runtime_repo
238
+ install_python_runtime "$py_bin"
239
+ write_launcher
240
+ setup_path
241
+ warm_docker_image
242
+
243
+ local version
244
+ version=$("$LAUNCHER_PATH" --version 2>/dev/null || echo "unknown")
245
+ print_message success "✓ ${version} ready"
246
+
247
+ echo ""
248
+ echo -e "${MUTED}Run this now (or open a new terminal):${NC}"
249
+ echo -e " ${MUTED}export PATH=$BIN_DIR:\$PATH${NC}"
250
+ echo -e " ${MUTED}$APP --help${NC}"
251
+ }
252
+
253
+ main