use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::core::fs_secure;
use crate::hooks::cline::operator_supplied;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum HostClass {
Hub,
VsCode,
Cli,
}
impl HostClass {
pub const ALL: [HostClass; 3] = [HostClass::Hub, HostClass::VsCode, HostClass::Cli];
pub fn as_str(self) -> &'static str {
match self {
HostClass::Hub => "hub",
HostClass::VsCode => "vscode",
HostClass::Cli => "cli",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Host {
pub class: HostClass,
pub runtime: PathBuf,
pub runtime_var: &'static str,
pub core_version: Option<semver::Version>,
pub source: PathBuf,
}
pub const HOST_FLOOR: &str = "0.0.42";
pub const BUN_VAR: &str = "BUN_BE_BUN";
pub const ELECTRON_VAR: &str = "ELECTRON_RUN_AS_NODE";
pub const VSCODE_APP_ENV: &str = "OPENLATCH_VSCODE_APP";
pub fn meets_floor(version: &semver::Version) -> bool {
let floor = semver::Version::parse(HOST_FLOOR).unwrap_or(semver::Version::new(0, 0, 42));
semver::Version::new(version.major, version.minor, version.patch) >= floor
}
pub fn detect(ol_dir: &Path) -> Vec<Host> {
detect_with(ol_dir, crate::hooks::cline_app::detect())
}
pub(crate) fn detect_with(
ol_dir: &Path,
app: Option<crate::hooks::cline_app::ClineApp>,
) -> Vec<Host> {
let mut hosts = Vec::new();
if let Some(app) = app {
hosts.push(Host {
class: HostClass::Hub,
core_version: core_version_of(ol_dir, &app.sidecar),
runtime: app.sidecar.clone(),
runtime_var: BUN_VAR,
source: app.sidecar,
});
}
if let Some((extension, runtime)) = detect_vscode(dirs::home_dir().as_deref()) {
let source = extension.join("dist").join("extension.js");
hosts.push(Host {
class: HostClass::VsCode,
core_version: core_version_of(ol_dir, &source),
runtime,
runtime_var: ELECTRON_VAR,
source,
});
}
hosts
}
const VSCODE_ROOTS: [&str; 2] = [".vscode", ".vscode-insiders"];
fn detect_vscode(home: Option<&Path>) -> Option<(PathBuf, PathBuf)> {
let home = home?;
let seam = std::env::var_os(VSCODE_APP_ENV);
let reports = crate::hooks::cline::extension_roots(Some(home));
VSCODE_ROOTS.iter().find_map(|root_name| {
let root = home.join(root_name);
let report = reports.iter().find(|r| r.path == root)?;
let ext = highest_cline_extension(&report.matched)?;
let insiders = *root_name == ".vscode-insiders";
let binary = vscode_binary_candidates(
seam.as_deref(),
Some(home),
insiders,
operator_supplied("LOCALAPPDATA").as_deref(),
operator_supplied("ProgramFiles").as_deref(),
)
.into_iter()
.find(|p| p.is_file())?;
Some((root.join("extensions").join(ext), binary))
})
}
fn highest_cline_extension(matched: &[String]) -> Option<&str> {
matched
.iter()
.filter(|name| {
name.starts_with("saoudrizwan.claude-dev-")
|| name.to_ascii_lowercase().contains("cline")
})
.max_by(|a, b| {
let va = trailing_version(a);
let vb = trailing_version(b);
va.cmp(&vb).then_with(|| a.cmp(b))
})
.map(String::as_str)
}
fn trailing_version(name: &str) -> Option<semver::Version> {
let (_, tail) = name.rsplit_once('-')?;
semver::Version::parse(tail).ok().or_else(|| {
name.split('-')
.find_map(|part| semver::Version::parse(part).ok())
})
}
pub(crate) fn vscode_binary_candidates(
seam: Option<&OsStr>,
home: Option<&Path>,
insiders: bool,
local_appdata: Option<&Path>,
program_files: Option<&Path>,
) -> Vec<PathBuf> {
if let Some(seam) = seam.filter(|v| !v.is_empty()) {
let path = PathBuf::from(seam);
return if path.is_absolute() {
vec![path]
} else {
Vec::new()
};
}
let mut out = Vec::new();
if cfg!(target_os = "macos") {
let mut apps = vec!["Visual Studio Code.app"];
if insiders {
apps.push("Visual Studio Code - Insiders.app");
}
for app in apps {
let tail = Path::new(app).join("Contents").join("MacOS").join("Code");
out.push(Path::new("/Applications").join(&tail)); if let Some(h) = home {
out.push(h.join("Applications").join(&tail));
}
}
} else if cfg!(windows) {
if let Some(d) = local_appdata {
out.push(
d.join("Programs")
.join("Microsoft VS Code")
.join("Code.exe"),
);
}
if let Some(d) = program_files {
out.push(d.join("Microsoft VS Code").join("Code.exe"));
}
} else {
for p in [
"/usr/share/code/code", "/snap/code/current/usr/share/code/code", "/opt/visual-studio-code/code", ] {
out.push(PathBuf::from(p));
}
}
out
}
const CHUNK: usize = 1024 * 1024;
const OVERLAP: usize = 4 * 1024;
const WINDOW: usize = 512;
const NEEDLE: &[u8] = b"\"@cline/core\"";
const CACHE_FILE: &str = "cline-hosts.json";
const SCAN_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct CacheEntry {
#[serde(default)]
scan: u32,
len: u64,
mtime_ns: u128,
version: Option<String>,
}
fn cache_path(ol_dir: &Path) -> PathBuf {
ol_dir.join("state").join(CACHE_FILE)
}
pub fn core_version_of(ol_dir: &Path, path: &Path) -> Option<semver::Version> {
let meta = std::fs::metadata(path).ok()?;
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let key = canonical.to_string_lossy().into_owned();
let len = meta.len();
let mtime_ns = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut cache: BTreeMap<String, CacheEntry> = std::fs::read(cache_path(ol_dir))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
if let Some(hit) = cache.get(&key) {
if hit.scan == SCAN_VERSION && hit.len == len && hit.mtime_ns == mtime_ns {
return hit
.version
.as_deref()
.and_then(|v| semver::Version::parse(v).ok());
}
}
let version = if path.file_name() == Some(OsStr::new("package.json")) {
package_json_version(path)
} else {
std::fs::File::open(path).ok().and_then(scan_core_version)
};
cache.insert(
key,
CacheEntry {
scan: SCAN_VERSION,
len,
mtime_ns,
version: version.as_ref().map(|v| v.to_string()),
},
);
if let Err(e) = fs_secure::write_json_state(&cache_path(ol_dir), &cache) {
tracing::debug!(error = %e, "could not cache the Cline host core version");
}
version
}
fn package_json_version(path: &Path) -> Option<semver::Version> {
let meta = std::fs::metadata(path).ok()?;
if meta.len() > 1024 * 1024 {
return None;
}
let value: serde_json::Value = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?;
if value.get("name")?.as_str()? != "@cline/core" {
return None;
}
semver::Version::parse(value.get("version")?.as_str()?).ok()
}
fn scan_core_version<R: Read>(mut reader: R) -> Option<semver::Version> {
let mut data: Vec<u8> = Vec::with_capacity(CHUNK + OVERLAP);
let mut chunk = vec![0u8; CHUNK];
let mut best: Option<semver::Version> = None;
loop {
let n = read_full(&mut reader, &mut chunk);
let eof = n < CHUNK;
data.extend_from_slice(&chunk[..n]);
let mut from = 0;
let mut deferred: Option<usize> = None;
while let Some(pos) = find(&data[from..], NEEDLE).map(|p| p + from) {
let window_end = pos + NEEDLE.len() + WINDOW;
if window_end > data.len() && !eof {
deferred = Some(pos);
break;
}
if name_precedes(&data[..pos]) {
let window = &data[pos + NEEDLE.len()..window_end.min(data.len())];
if let Some(v) = version_in(window) {
best = best.max(Some(v));
}
}
from = pos + 1;
}
if eof {
return best;
}
let keep_from = match deferred {
Some(pos) => pos.saturating_sub(OVERLAP),
None => data.len().saturating_sub(OVERLAP),
};
data.drain(..keep_from);
}
}
fn read_full<R: Read>(reader: &mut R, buf: &mut [u8]) -> usize {
let mut filled = 0;
while filled < buf.len() {
match reader.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
filled
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn name_precedes(before: &[u8]) -> bool {
let mut end = before.len();
while end > 0 && before[end - 1].is_ascii_whitespace() {
end -= 1;
}
if end == 0 || before[end - 1] != b':' {
return false;
}
end -= 1;
while end > 0 && before[end - 1].is_ascii_whitespace() {
end -= 1;
}
let head = &before[..end];
head.ends_with(b"\"name\"") || (head.ends_with(b"name") && !head.ends_with(b"\"name"))
}
fn version_in(window: &[u8]) -> Option<semver::Version> {
let mut from = 0;
while let Some(pos) = find(&window[from..], b"version").map(|p| p + from) {
let mut i = pos + b"version".len();
if window.get(i) == Some(&b'"') {
i += 1;
}
while window.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
if window.get(i) == Some(&b':') {
i += 1;
while window.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
if window.get(i) == Some(&b'"') {
let start = i + 1;
if let Some(len) = window[start..].iter().position(|b| *b == b'"') {
if let Ok(text) = std::str::from_utf8(&window[start..start + len]) {
if let Ok(v) = semver::Version::parse(text) {
return Some(v);
}
}
}
}
}
from = pos + 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_class_serializes_as_the_file_keys() {
assert_eq!(serde_json::to_string(&HostClass::Hub).unwrap(), "\"hub\"");
assert_eq!(
serde_json::to_string(&HostClass::VsCode).unwrap(),
"\"vscode\""
);
assert_eq!(serde_json::to_string(&HostClass::Cli).unwrap(), "\"cli\"");
for class in [HostClass::Hub, HostClass::VsCode, HostClass::Cli] {
assert_eq!(
serde_json::to_string(&class).unwrap(),
format!("\"{}\"", class.as_str())
);
}
}
#[test]
fn core_version_scan_finds_literal_across_chunk_boundary() {
let literal =
br#"name: "@cline/core", description: "x", version: "0.0.86", type: "module""#;
for split in [3usize, 12, 20, 40, literal.len() - 5] {
let mut bytes = vec![b'a'; CHUNK - split];
bytes.extend_from_slice(literal);
bytes.extend(std::iter::repeat_n(b'z', 5000));
assert_eq!(
scan_core_version(&bytes[..]),
Some(semver::Version::new(0, 0, 86)),
"split {split}"
);
}
let json = br#"{"name":"@cline/core","version":"0.0.88-nightly.3","main":"dist/index.js"}"#;
let mut bytes = vec![b' '; 2 * CHUNK - 10];
bytes.extend_from_slice(json);
assert_eq!(
scan_core_version(&bytes[..]).map(|v| v.to_string()),
Some("0.0.88-nightly.3".to_string())
);
let placeholders = br#"x={name: "@cline/core", version: "0.0.0" };y={name: "@cline/core", description: "Cline Core SDK", version: _r4 };z={name: "@cline/core", description: "Cline Core SDK", version: "0.0.88" };w={name: "@cline/core", version: "0.0.0" }"#;
assert_eq!(
scan_core_version(&placeholders[..]),
Some(semver::Version::new(0, 0, 88))
);
let prose = br#"import x from "@cline/core"; const version = "9.9.9";"#;
assert_eq!(scan_core_version(&prose[..]), None);
let mut far = br#"name:"@cline/core","#.to_vec();
far.extend(std::iter::repeat_n(b' ', 600));
far.extend_from_slice(br#"version:"0.0.50""#);
assert_eq!(scan_core_version(&far[..]), None);
}
#[test]
fn core_version_of_reads_and_caches() {
let ol = tempfile::tempdir().expect("tempdir");
let bin = ol.path().join("fake-sidecar");
std::fs::write(&bin, br#"xx name:"@cline/core",version:"0.0.61" yy"#).expect("write");
assert_eq!(
core_version_of(ol.path(), &bin),
Some(semver::Version::new(0, 0, 61))
);
assert!(cache_path(ol.path()).is_file(), "the scan is cached");
let pkg_dir = ol.path().join("node_modules").join("@cline").join("core");
std::fs::create_dir_all(&pkg_dir).expect("mkdir");
let pkg = pkg_dir.join("package.json");
std::fs::write(&pkg, r#"{"name":"@cline/core","version":"0.0.70"}"#).expect("write");
assert_eq!(
core_version_of(ol.path(), &pkg),
Some(semver::Version::new(0, 0, 70))
);
assert_eq!(core_version_of(ol.path(), &ol.path().join("absent")), None);
}
#[test]
fn a_cache_entry_from_an_older_scanner_is_rescanned() {
let ol = tempfile::tempdir().expect("tempdir");
let bin = ol.path().join("fake-sidecar");
std::fs::write(
&bin,
br#"x={name: "@cline/core", version: "0.0.0" };z={name: "@cline/core", version: "0.0.88" }"#,
)
.expect("write");
let meta = std::fs::metadata(&bin).expect("meta");
let mtime_ns = meta
.modified()
.expect("mtime")
.duration_since(std::time::UNIX_EPOCH)
.expect("epoch")
.as_nanos();
let key = std::fs::canonicalize(&bin)
.expect("canonical")
.to_string_lossy()
.into_owned();
let old = serde_json::json!({ key.clone(): {"len": meta.len(), "mtime_ns": mtime_ns, "version": "0.0.0"} });
std::fs::create_dir_all(ol.path().join("state")).expect("mkdir");
std::fs::write(cache_path(ol.path()), old.to_string()).expect("write cache");
assert_eq!(
core_version_of(ol.path(), &bin),
Some(semver::Version::new(0, 0, 88))
);
let cache: BTreeMap<String, CacheEntry> =
serde_json::from_slice(&std::fs::read(cache_path(ol.path())).expect("read"))
.expect("parse");
assert_eq!(cache[&key].scan, SCAN_VERSION);
assert_eq!(cache[&key].version.as_deref(), Some("0.0.88"));
}
#[test]
fn host_floor_is_0_0_42() {
let v = |s: &str| semver::Version::parse(s).expect("semver");
assert!(!meets_floor(&v("0.0.41")));
assert!(meets_floor(&v("0.0.42")));
assert!(meets_floor(&v("0.0.87")));
assert!(meets_floor(&v("0.1.0")));
assert!(
meets_floor(&v("0.0.86-nightly.1")),
"a prerelease compares as its release"
);
assert!(
!meets_floor(&v("0.0.41-nightly.9")),
"and below the floor stays below it"
);
assert!(
meets_floor(&v("0.0.42-rc.1")),
"0.0.42-rc.1 compares as 0.0.42, not below it"
);
}
#[test]
fn vscode_seam_relative_is_not_detected() {
let home = Path::new("/home-for-test");
assert!(
vscode_binary_candidates(Some(OsStr::new("Code")), Some(home), false, None, None)
.is_empty()
);
assert!(vscode_binary_candidates(
Some(OsStr::new("apps/Code")),
Some(home),
true,
None,
None
)
.is_empty());
let abs = std::env::temp_dir().join("no-such-vscode-binary");
assert_eq!(
vscode_binary_candidates(Some(abs.as_os_str()), Some(home), false, None, None),
vec![abs.clone()],
"an absolute seam is the only candidate"
);
assert!(
!vscode_binary_candidates(Some(OsStr::new("")), Some(home), false, None, None)
.is_empty()
|| !cfg!(any(target_os = "macos", target_os = "linux")),
"an empty seam is unset: the default list"
);
}
#[test]
fn the_highest_cline_extension_wins() {
let names = vec![
"saoudrizwan.claude-dev-4.1.9".to_string(),
"saoudrizwan.claude-dev-4.1.22".to_string(),
"saoudrizwan.claude-dev-4.1.21".to_string(),
];
assert_eq!(
highest_cline_extension(&names),
Some("saoudrizwan.claude-dev-4.1.22")
);
assert_eq!(highest_cline_extension(&[]), None);
}
}