1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
fn main() {
println!("cargo:rerun-if-changed=src/version.rs");
println!("cargo:rerun-if-changed=build.rs");
#[cfg(feature = "bundled-cli")]
bundled_cli::download();
}
#[cfg(feature = "bundled-cli")]
mod bundled_cli {
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Download the Claude Code CLI binary to the bundled directory.
///
/// This runs during `cargo build` when the `bundled-cli` feature is enabled.
/// The CLI is downloaded to `~/.claude/sdk/bundled/{version}/claude` so it
/// persists across `cargo clean` and can be shared by multiple projects.
pub fn download() {
let version = parse_cli_version();
let bundled_dir = get_bundled_dir(&version);
let cli_path = bundled_dir.join(cli_name());
// Incremental build: skip if already downloaded
if cli_path.exists() {
return;
}
fs::create_dir_all(&bundled_dir).unwrap_or_else(|e| {
panic!(
"Failed to create bundled CLI directory {}: {}",
bundled_dir.display(),
e
)
});
eprintln!("cargo:warning=Downloading Claude Code CLI v{version}...");
#[cfg(not(target_os = "windows"))]
download_unix(&version, &bundled_dir);
#[cfg(target_os = "windows")]
download_windows(&version, &bundled_dir);
assert!(
cli_path.exists(),
"CLI binary not found after download at: {}. \
Please check your network connection and that curl is installed.",
cli_path.display()
);
eprintln!(
"cargo:warning=Claude CLI v{version} downloaded to: {}",
cli_path.display()
);
}
/// Download CLI on Unix-like systems using the official install script.
///
/// install.sh accepts a version argument via `$1`:
/// `curl -fsSL https://claude.ai/install.sh | bash -s -- <version>`
/// It installs the CLI to `~/.local/bin/claude` by default.
/// We then copy the installed binary to the bundled directory atomically.
///
/// **Note**: install.sh will overwrite `~/.local/bin/claude` as a side effect.
/// If you have a different CLI version installed there, it will be replaced
/// with the version specified by `CLI_VERSION`. After copying to the bundled
/// directory, we restore the original binary if one existed.
#[cfg(not(target_os = "windows"))]
fn download_unix(version: &str, bundled_dir: &Path) {
let home = dirs_home();
let default_install_path = home.join(".local/bin/claude");
// Backup existing CLI binary to avoid overwriting user's installation
let had_existing = default_install_path.exists();
let backup_path = home.join(".local/bin/.claude.sdk-backup");
if had_existing {
let _ = fs::copy(&default_install_path, &backup_path);
}
// Run install.sh with the pinned version
let install_cmd = format!(
"curl -fsSL https://claude.ai/install.sh | bash -s -- '{}'",
version
);
let status = Command::new("bash")
.args(["-c", &install_cmd])
.status()
.unwrap_or_else(|e| {
// Restore backup before panicking
if had_existing {
let _ = fs::rename(&backup_path, &default_install_path);
}
panic!(
"Failed to execute install script. Is curl installed? Error: {}",
e
)
});
if !status.success() {
// Restore backup before panicking
if had_existing {
let _ = fs::rename(&backup_path, &default_install_path);
}
panic!(
"Claude CLI install script failed with exit code: {:?}. \
Check your network connection.",
status.code()
);
}
// Find the installed binary from known locations
let search_paths = [
default_install_path.clone(),
PathBuf::from("/usr/local/bin/claude"),
];
let installed_cli = search_paths.iter().find(|p| p.exists()).unwrap_or_else(|| {
if had_existing {
let _ = fs::rename(&backup_path, &default_install_path);
}
panic!(
"Could not find installed CLI binary after install.sh. Checked: {:?}",
search_paths
)
});
// Atomic write: copy to a temp file in the same directory, then rename.
// This prevents concurrent builds from reading a half-written binary.
let tmp_path = bundled_dir.join(".claude.tmp");
fs::copy(installed_cli, &tmp_path).unwrap_or_else(|e| {
panic!(
"Failed to copy CLI from {} to {}: {}",
installed_cli.display(),
tmp_path.display(),
e
)
});
set_executable(&tmp_path);
fs::rename(&tmp_path, bundled_dir.join("claude")).unwrap_or_else(|e| {
// rename failed (e.g., cross-device), fall back to copy
let _ = fs::copy(&tmp_path, bundled_dir.join("claude"));
let _ = fs::remove_file(&tmp_path);
if !bundled_dir.join("claude").exists() {
panic!(
"Failed to move CLI to final path {}: {}",
bundled_dir.join("claude").display(),
e
);
}
});
// Clean up temp file
let _ = fs::remove_file(&tmp_path);
// Restore the user's original CLI binary if we backed it up
if had_existing {
let _ = fs::rename(&backup_path, &default_install_path);
}
// Clean up backup file if it still exists
let _ = fs::remove_file(&backup_path);
}
/// Download CLI on Windows using PowerShell.
#[cfg(target_os = "windows")]
fn download_windows(version: &str, bundled_dir: &Path) {
// $ErrorActionPreference='Stop' ensures PowerShell aborts on any error,
// preventing 'claude install' from running if irm/iex fails.
let install_cmd = format!(
"$ErrorActionPreference='Stop'; irm https://claude.ai/install.ps1 | iex; claude install '{}'",
version
);
let status = Command::new("powershell")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &install_cmd])
.status()
.unwrap_or_else(|e| {
panic!("Failed to execute PowerShell install script: {}", e)
});
if !status.success() {
panic!(
"Claude CLI install script failed with exit code: {:?}",
status.code()
);
}
// Find the installed binary
let home = dirs_home();
let possible_paths = [
home.join("AppData\\Local\\Programs\\Claude\\claude.exe"),
home.join("AppData\\Roaming\\npm\\claude.cmd"),
home.join(".local\\bin\\claude.exe"),
];
let installed_cli = possible_paths.iter().find(|p| p.exists()).unwrap_or_else(|| {
panic!(
"Could not find installed CLI binary. Checked: {:?}",
possible_paths
)
});
// Atomic write: copy to temp, then rename
let tmp_path = bundled_dir.join(".claude.exe.tmp");
fs::copy(installed_cli, &tmp_path).unwrap_or_else(|e| {
panic!(
"Failed to copy CLI from {} to {}: {}",
installed_cli.display(),
tmp_path.display(),
e
)
});
fs::rename(&tmp_path, bundled_dir.join("claude.exe")).unwrap_or_else(|e| {
let _ = fs::copy(&tmp_path, bundled_dir.join("claude.exe"));
let _ = fs::remove_file(&tmp_path);
if !bundled_dir.join("claude.exe").exists() {
panic!(
"Failed to move CLI to final path {}: {}",
bundled_dir.join("claude.exe").display(),
e
);
}
});
let _ = fs::remove_file(&tmp_path);
}
/// Set executable permission on Unix.
#[cfg(unix)]
fn set_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.unwrap_or_else(|e| panic!("Failed to read metadata for {}: {}", path.display(), e))
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
.unwrap_or_else(|e| panic!("Failed to set permissions on {}: {}", path.display(), e));
}
/// Get the bundled CLI directory: `~/.claude/sdk/bundled/{version}/`
fn get_bundled_dir(version: &str) -> PathBuf {
dirs_home().join(".claude/sdk/bundled").join(version)
}
/// Get the home directory, panicking if not available.
fn dirs_home() -> PathBuf {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.expect(
"HOME or USERPROFILE environment variable must be set to download bundled CLI",
);
PathBuf::from(home)
}
/// Get the platform-appropriate CLI binary name.
fn cli_name() -> &'static str {
if cfg!(target_os = "windows") {
"claude.exe"
} else {
"claude"
}
}
/// Parse CLI_VERSION from `src/version.rs`.
///
/// We parse the source file directly instead of depending on the crate,
/// because build.rs runs before the crate is compiled.
fn parse_cli_version() -> String {
let version_rs = std::fs::read_to_string("src/version.rs")
.expect("Failed to read src/version.rs");
for line in version_rs.lines() {
let trimmed = line.trim();
// Match exactly: pub const CLI_VERSION: &str = "x.y.z";
if trimmed.starts_with("pub const CLI_VERSION:") {
if let Some(start) = trimmed.find('"') {
if let Some(end) = trimmed[start + 1..].find('"') {
let version = trimmed[start + 1..start + 1 + end].to_string();
// Validate: must be digits and dots only (semver x.y.z)
assert!(
!version.is_empty()
&& version
.chars()
.all(|c| c.is_ascii_digit() || c == '.'),
"CLI_VERSION contains invalid characters: '{}'",
version
);
return version;
}
}
}
}
panic!("Could not parse CLI_VERSION from src/version.rs");
}
}