use std::path::PathBuf;
pub const NPM_PI_MISSING_HINT: &str = "pi backend requires the TypeScript/npm Pi agent \
(@earendil-works/pi-coding-agent). Set MALVIN_PI to its cli.js or rpc-entry.js, or install the package.";
pub fn resolve_npm_pi_entry() -> Result<PathBuf, String> {
if let Some(path) = std::env::var_os("MALVIN_PI").filter(|v| !v.is_empty()) {
let path = PathBuf::from(path);
if path.is_file() {
return Ok(path);
}
return Err(format!(
"MALVIN_PI points to a missing file ({}); {NPM_PI_MISSING_HINT}",
path.display()
));
}
first_entry_candidate().ok_or_else(|| NPM_PI_MISSING_HINT.to_string())
}
pub fn resolve_npm_pi_cli_entry() -> Result<PathBuf, String> {
if let Some(path) = std::env::var_os("MALVIN_PI").filter(|v| !v.is_empty()) {
let path = PathBuf::from(path);
if !path.is_file() {
return Err(format!(
"MALVIN_PI points to a missing file ({}); {NPM_PI_MISSING_HINT}",
path.display()
));
}
if path.file_name().is_some_and(|n| n == "cli.js") {
return Ok(path);
}
if let Some(sibling) = sibling_cli_candidate(&path) {
return Ok(sibling);
}
return Ok(path);
}
first_cli_entry_candidate().ok_or_else(|| NPM_PI_MISSING_HINT.to_string())
}
fn sibling_cli_candidate(path: &std::path::Path) -> Option<PathBuf> {
let parent = path.parent()?;
[parent.join("cli.js"), parent.join("bundle/cli.js")]
.into_iter()
.find(|candidate| candidate.is_file())
}
fn first_cli_entry_candidate() -> Option<PathBuf> {
for root in candidate_package_roots() {
for rel in [
"dist/bundle/cli.js",
"dist/cli.js",
"dist/bundle/rpc-entry.js",
"dist/rpc-entry.js",
] {
let candidate = root.join(rel);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
fn first_entry_candidate() -> Option<PathBuf> {
for root in candidate_package_roots() {
for rel in [
"dist/bundle/rpc-entry.js",
"dist/rpc-entry.js",
"dist/bundle/cli.js",
"dist/cli.js",
] {
let candidate = root.join(rel);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
fn candidate_package_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
push_scoped(&mut roots, PathBuf::from("node_modules"));
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
push_scoped(
&mut roots,
home.join(".malvin_home/sdk-bridges/node_modules"),
);
push_npx_cache(&mut roots, &home.join(".npm/_npx"));
}
roots
}
fn push_scoped(out: &mut Vec<PathBuf>, modules: PathBuf) {
for scope in ["@earendil-works", "@mariozechner"] {
let pkg = modules.join(scope).join("pi-coding-agent");
if pkg.is_dir() {
out.push(pkg);
}
}
}
fn push_npx_cache(out: &mut Vec<PathBuf>, npx_root: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(npx_root) else {
return;
};
for entry in entries.flatten().take(24) {
push_scoped(out, entry.path().join("node_modules"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn malvin_pi_missing_file_errors() {
let _lock = crate::test_utils::test_env_lock();
crate::acp::with_env("MALVIN_PI", Some("/no/such/pi-entry.js"), || {
let err = resolve_npm_pi_entry().expect_err("missing");
assert!(err.contains("MALVIN_PI") && err.contains("missing file"));
});
}
#[test]
fn hint_mentions_package() {
assert!(NPM_PI_MISSING_HINT.contains("@earendil-works/pi-coding-agent"));
}
}