use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
const BUILTIN_HARNESSES: &[(&str, &str)] = &[
("claude-code", include_str!("../profiles/harnesses/claude-code.toml")),
("codex", include_str!("../profiles/harnesses/codex.toml")),
("pi", include_str!("../profiles/harnesses/pi.toml")),
("opencode", include_str!("../profiles/harnesses/opencode.toml")),
("hermes", include_str!("../profiles/harnesses/hermes.toml")),
("cursor-agent", include_str!("../profiles/harnesses/cursor-agent.toml")),
("gemini-cli", include_str!("../profiles/harnesses/gemini-cli.toml")),
("copilot-cli", include_str!("../profiles/harnesses/copilot-cli.toml")),
("goose", include_str!("../profiles/harnesses/goose.toml")),
("amp", include_str!("../profiles/harnesses/amp.toml")),
("pydantic-ai", include_str!("../profiles/harnesses/pydantic-ai.toml")),
("deepagents", include_str!("../profiles/harnesses/deepagents.toml")),
("langgraph", include_str!("../profiles/harnesses/langgraph.toml")),
("openai-agents", include_str!("../profiles/harnesses/openai-agents.toml")),
("claude-agent-sdk", include_str!("../profiles/harnesses/claude-agent-sdk.toml")),
("google-adk", include_str!("../profiles/harnesses/google-adk.toml")),
("smolagents", include_str!("../profiles/harnesses/smolagents.toml")),
("crewai", include_str!("../profiles/harnesses/crewai.toml")),
];
const BUILTIN_RUNTIMES: &[(&str, &str)] = &[
("ollama", include_str!("../profiles/runtimes/ollama.toml")),
("lmstudio", include_str!("../profiles/runtimes/lmstudio.toml")),
("llama-cpp", include_str!("../profiles/runtimes/llama-cpp.toml")),
("vllm", include_str!("../profiles/runtimes/vllm.toml")),
("sglang", include_str!("../profiles/runtimes/sglang.toml")),
("mlx-lm", include_str!("../profiles/runtimes/mlx-lm.toml")),
("localai", include_str!("../profiles/runtimes/localai.toml")),
("docker-model-runner", include_str!("../profiles/runtimes/docker-model-runner.toml")),
("jan", include_str!("../profiles/runtimes/jan.toml")),
];
#[derive(Debug, Clone)]
pub struct Ctx {
pub home: PathBuf,
pub cwd: PathBuf,
pub env: BTreeMap<String, String>,
pub path: Vec<PathBuf>,
}
impl Ctx {
pub fn from_process() -> Ctx {
let env: BTreeMap<String, String> = std::env::vars().collect();
let home = env.get("HOME").or_else(|| env.get("USERPROFILE")).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
let path = env.get("PATH").map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
Ctx {
home,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
env,
path,
}
}
pub fn var(&self, key: &str) -> Option<&str> {
self.env.get(key).map(String::as_str).filter(|v| !v.is_empty())
}
pub fn expand(&self, template: &str, root: Option<&Path>) -> PathBuf {
let mut s = template.to_string();
if let Some(r) = root {
s = s.replace("{root}", &r.to_string_lossy());
}
s = s.replace("{home}", &self.home.to_string_lossy());
if s == "~" {
return self.home.clone();
}
if let Some(rest) = s.strip_prefix("~/") {
return self.home.join(rest);
}
let p = PathBuf::from(&s);
if p.is_absolute() {
p
} else {
self.cwd.join(p)
}
}
pub fn which(&self, name: &str) -> Option<PathBuf> {
if name.contains('/') {
let p = PathBuf::from(name);
return p.is_file().then_some(p);
}
for dir in &self.path {
let cand = dir.join(name);
if cand.is_file() {
return Some(cand);
}
}
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct VersionProbe {
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct ConfigRoot {
pub env: Option<String>,
pub suffix: Option<String>,
pub default: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct InstallMethod {
pub method: String,
pub package: Option<String>,
pub command: Option<String>,
pub path_hints: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct FileSpec {
pub path: String,
pub isolated: Option<String>,
pub format: String,
pub restore: String,
pub pointers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct ModelSpec {
pub default: Vec<String>,
pub small: Vec<String>,
pub provider: Vec<String>,
pub base_url: Vec<String>,
pub format: Option<String>,
pub default_provider: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct ProviderSpec {
pub source: Vec<String>,
pub base_url: Vec<String>,
pub models: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct McpSpec {
pub sources: Vec<String>,
pub write: Option<String>,
pub shape: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct SkillsSpec {
pub dirs: Vec<String>,
pub write: Option<String>,
pub builtin_dirs: Vec<String>,
pub builtin_manifest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct BundleSpec {
pub kind: String,
pub extractor: String,
pub source: String,
pub lockfile: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct LaunchSpec {
pub args: Vec<String>,
pub model_flag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct LocalProvider {
pub file: String,
pub set: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct CatalogModel {
pub id: String,
pub params_b: Option<f64>,
pub min_memory_gb: Option<f64>,
pub tier: u8,
pub tools: bool,
pub roles: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct RunSpec {
pub entrypoint: Option<String>,
pub command: Vec<String>,
pub python: Option<String>,
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct HarnessProfile {
pub id: String,
pub name: String,
pub kind: String,
pub aliases: Vec<String>,
pub vendor: Option<String>,
pub homepage: Option<String>,
pub support: String,
pub open_source: Option<bool>,
pub local_models: Option<bool>,
pub local_provider: Vec<LocalProvider>,
pub framework: Option<String>,
pub packages: Vec<String>,
pub run: RunSpec,
pub binaries: Vec<String>,
pub version: VersionProbe,
pub active_env: Vec<String>,
pub config_root: ConfigRoot,
pub instructions: Vec<String>,
pub install: Vec<InstallMethod>,
pub files: BTreeMap<String, FileSpec>,
pub model: ModelSpec,
pub providers: Option<ProviderSpec>,
pub mcp: McpSpec,
pub skills: SkillsSpec,
pub bundles: Vec<BundleSpec>,
pub launch: LaunchSpec,
#[serde(skip_deserializing)]
pub origin: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct RuntimeModels {
pub layout: String,
pub dirs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct RuntimeProfile {
pub id: String,
pub name: String,
pub aliases: Vec<String>,
pub homepage: Option<String>,
pub binaries: Vec<String>,
pub version: VersionProbe,
pub ports: Vec<u16>,
pub provider_names: Vec<String>,
pub pull: Option<String>,
pub serve: Option<String>,
pub models: RuntimeModels,
pub install: Vec<InstallMethod>,
pub catalog: Vec<CatalogModel>,
#[serde(skip_deserializing)]
pub origin: String,
}
impl HarnessProfile {
pub fn matches(&self, name: &str) -> bool {
let n = name.to_ascii_lowercase();
self.id == n || self.aliases.iter().any(|a| a.to_ascii_lowercase() == n)
}
pub fn config_root(&self, ctx: &Ctx) -> PathBuf {
let cr = &self.config_root;
if let Some(var) = &cr.env {
if let Some(v) = ctx.var(var) {
let base = ctx.expand(v, None);
return match &cr.suffix {
Some(sfx) => base.join(sfx),
None => base,
};
}
}
ctx.expand(&cr.default, None)
}
pub fn root_relocated(&self, ctx: &Ctx) -> bool {
self.config_root.env.as_deref().and_then(|v| ctx.var(v)).is_some()
}
pub fn file_path(&self, id: &str, ctx: &Ctx) -> Option<PathBuf> {
let f = self.files.get(id)?;
let root = self.config_root(ctx);
let tpl = match (&f.isolated, self.root_relocated(ctx)) {
(Some(iso), true) => iso,
_ => &f.path,
};
Some(ctx.expand(tpl, Some(&root)))
}
pub fn isolation_env(&self, dir: &Path) -> Option<(String, PathBuf)> {
let var = self.config_root.env.clone()?;
let value = match &self.config_root.suffix {
Some(_) => dir.parent().map(Path::to_path_buf).unwrap_or_else(|| dir.to_path_buf()),
None => dir.to_path_buf(),
};
Some((var, value))
}
pub fn is_active(&self, ctx: &Ctx) -> bool {
self.active_env.iter().any(|v| ctx.var(v).is_some())
}
}
impl RuntimeProfile {
pub fn matches(&self, name: &str) -> bool {
let n = name.to_ascii_lowercase();
self.id == n || self.aliases.iter().any(|a| a.to_ascii_lowercase() == n)
}
pub fn models_dir(&self, ctx: &Ctx) -> Option<PathBuf> {
let mut first = None;
for d in &self.models.dirs {
let p = match d.strip_prefix("env:") {
Some(var) => match ctx.var(var) {
Some(v) => ctx.expand(v, None),
None => continue,
},
None => ctx.expand(d, None),
};
if p.is_dir() {
return Some(p);
}
first.get_or_insert(p);
}
first
}
}
#[derive(Debug, Clone, Default)]
pub struct Registry {
pub harnesses: Vec<HarnessProfile>,
pub runtimes: Vec<RuntimeProfile>,
}
pub fn profile_dirs(ctx: &Ctx) -> Vec<PathBuf> {
let mut dirs = vec![ctx.expand("~/.config/hlock/profiles", None)];
if let Some(p) = ctx.var("HLOCK_PROFILE_PATH") {
dirs.extend(std::env::split_paths(p));
}
dirs.push(ctx.cwd.join(".hlock/profiles"));
dirs
}
pub fn parse_harness(text: &str, origin: &str) -> Result<HarnessProfile> {
let mut p: HarnessProfile =
toml::from_str(text).map_err(|e| Error::Profile(format!("invalid harness profile {origin}: {e}")))?;
if p.id.is_empty() {
return Err(Error::Profile(format!("harness profile {origin} has no id")));
}
if p.name.is_empty() {
p.name = p.id.clone();
}
if p.support.is_empty() {
p.support = "basic".into();
}
if p.kind.is_empty() {
p.kind = if p.run.entrypoint.is_some() || !p.run.command.is_empty() { "custom" } else { "cli" }.into();
}
p.origin = origin.to_string();
Ok(p)
}
pub fn parse_runtime(text: &str, origin: &str) -> Result<RuntimeProfile> {
let mut p: RuntimeProfile =
toml::from_str(text).map_err(|e| Error::Profile(format!("invalid runtime profile {origin}: {e}")))?;
if p.id.is_empty() {
return Err(Error::Profile(format!("runtime profile {origin} has no id")));
}
if p.name.is_empty() {
p.name = p.id.clone();
}
p.origin = origin.to_string();
Ok(p)
}
impl Registry {
pub fn builtin() -> Registry {
let harnesses = BUILTIN_HARNESSES
.iter()
.map(|(id, text)| parse_harness(text, "builtin").unwrap_or_else(|e| panic!("builtin profile {id}: {e}")))
.collect();
let runtimes = BUILTIN_RUNTIMES
.iter()
.map(|(id, text)| parse_runtime(text, "builtin").unwrap_or_else(|e| panic!("builtin profile {id}: {e}")))
.collect();
Registry { harnesses, runtimes }
}
pub fn load(ctx: &Ctx) -> Result<Registry> {
let mut reg = Registry::builtin();
for dir in profile_dirs(ctx) {
reg.extend_from_dir(&dir)?;
}
Ok(reg)
}
pub fn extend_from_dir(&mut self, dir: &Path) -> Result<()> {
for path in toml_files(&dir.join("harnesses")) {
let text = std::fs::read_to_string(&path)?;
let p = parse_harness(&text, &path.to_string_lossy())?;
self.harnesses.retain(|h| h.id != p.id);
self.harnesses.push(p);
}
for path in toml_files(&dir.join("runtimes")) {
let text = std::fs::read_to_string(&path)?;
let p = parse_runtime(&text, &path.to_string_lossy())?;
self.runtimes.retain(|r| r.id != p.id);
self.runtimes.push(p);
}
Ok(())
}
pub fn load_for(ctx: &Ctx, manifest: Option<&crate::manifest::Manifest>) -> Result<Registry> {
let mut reg = Registry::load(ctx)?;
if let Some(m) = manifest {
reg.add_project_harnesses(m)?;
}
Ok(reg)
}
pub fn add_project_harnesses(&mut self, m: &crate::manifest::Manifest) -> Result<()> {
let origin = m
.source_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| crate::MANIFEST_PATH.to_string());
for (id, table) in &m.harnesses {
let mut v = table.clone();
if let Some(obj) = v.as_object_mut() {
obj.entry("id").or_insert_with(|| serde_json::json!(id));
let mut run = obj.remove("run").and_then(|r| r.as_object().cloned()).unwrap_or_default();
for key in ["entrypoint", "command", "python", "env"] {
if let Some(val) = obj.remove(key) {
run.insert(key.to_string(), val);
}
}
obj.insert("run".into(), serde_json::Value::Object(run));
}
let mut p: HarnessProfile = serde_json::from_value(v)
.map_err(|e| Error::Profile(format!("invalid [harnesses.{id}] in {origin}: {e}")))?;
if p.name.is_empty() {
p.name = id.clone();
}
if p.kind.is_empty() {
p.kind = "custom".into();
}
if p.support.is_empty() {
p.support = "basic".into();
}
if let Some(fw) = &p.framework {
let base = self
.harness(fw)
.filter(|h| h.kind == "framework")
.ok_or_else(|| Error::Profile(format!("[harnesses.{id}] framework {fw:?} is not a known framework")))?;
for pkg in &base.packages {
if !p.packages.contains(pkg) {
p.packages.push(pkg.clone());
}
}
if p.homepage.is_none() {
p.homepage = base.homepage.clone();
}
}
if p.kind == "custom" && p.run.entrypoint.is_none() && p.run.command.is_empty() {
return Err(Error::Profile(format!(
"[harnesses.{id}] needs an `entrypoint` (python module:callable) or a `command`"
)));
}
p.origin = origin.clone();
self.harnesses.retain(|h| h.id != p.id);
self.harnesses.push(p);
}
Ok(())
}
pub fn harness(&self, name: &str) -> Option<&HarnessProfile> {
self.harnesses.iter().find(|h| h.matches(name))
}
pub fn runtime(&self, name: &str) -> Option<&RuntimeProfile> {
self.runtimes.iter().find(|r| r.matches(name))
}
pub fn require_harness(&self, name: &str) -> Result<&HarnessProfile> {
self.harness(name).ok_or_else(|| {
let known: Vec<&str> = self.harnesses.iter().map(|h| h.id.as_str()).collect();
Error::Profile(format!("unknown harness {name:?} (known: {})", known.join(", ")))
})
}
pub fn active(&self, ctx: &Ctx) -> Option<&HarnessProfile> {
self.harnesses.iter().find(|h| h.is_active(ctx))
}
pub fn select(&self, explicit: Option<&str>, manifest_target: Option<&str>, ctx: &Ctx) -> Result<Selection> {
let named = explicit
.map(|n| (n.to_string(), "--harness"))
.or_else(|| ctx.var("HLOCK_HARNESS").map(|n| (n.to_string(), "HLOCK_HARNESS")))
.or_else(|| manifest_target.map(|n| (n.to_string(), "harness.toml [target]")));
if let Some((name, why)) = named {
let p = self.require_harness(&name)?;
return Ok(Selection { id: p.id.clone(), reason: why.to_string() });
}
if let Some(p) = self.active(ctx) {
return Ok(Selection { id: p.id.clone(), reason: "running inside it".into() });
}
let installed: Vec<&HarnessProfile> = self
.harnesses
.iter()
.filter(|h| h.kind == "cli" && h.binaries.iter().any(|b| ctx.which(b).is_some()))
.collect();
match installed.as_slice() {
[one] => Ok(Selection { id: one.id.clone(), reason: "the only installed harness".into() }),
[] => Err(Error::Profile("no supported harness found on PATH".into())),
many => Err(Error::Profile(format!(
"several harnesses are installed ({}); pick one with --harness",
many.iter().map(|h| h.id.as_str()).collect::<Vec<_>>().join(", ")
))),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Selection {
pub id: String,
pub reason: String,
}
fn toml_files(dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect()
})
.unwrap_or_default();
out.sort();
out
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct Probe {
pub id: String,
pub installed: bool,
pub binary: Option<String>,
pub version: Option<String>,
pub method: Option<String>,
pub source: Option<String>,
pub config_root: Option<String>,
pub active: bool,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub packages: BTreeMap<String, String>,
}
const VERSION_TIMEOUT: Duration = Duration::from_secs(8);
pub fn probe_harness(p: &HarnessProfile, ctx: &Ctx) -> Probe {
if p.kind != "cli" {
return probe_code_harness(p, ctx);
}
let mut out = probe_binary(&p.id, &p.binaries, &p.version, &p.install, ctx);
out.config_root = Some(p.config_root(ctx).to_string_lossy().into_owned());
out.active = p.is_active(ctx);
out
}
fn probe_code_harness(p: &HarnessProfile, ctx: &Ctx) -> Probe {
let mut out = Probe { id: p.id.clone(), active: p.is_active(ctx), ..Default::default() };
out.packages = python_package_versions(&p.packages, p.run.python.as_deref(), ctx);
let first = p.packages.iter().find_map(|pkg| out.packages.get(pkg).map(|v| (pkg, v)));
if let Some((pkg, v)) = first {
out.version = Some(v.clone());
out.method = Some("pip".into());
out.source = Some(format!("pypi:{}=={v}", pkg.trim_start_matches("pypi:")));
}
out.installed = match (p.kind.as_str(), p.run.command.first()) {
("custom", Some(cmd)) => {
out.binary = ctx.which(cmd).or_else(|| Some(ctx.expand(cmd, None)).filter(|x| x.is_file())).map(|b| b.to_string_lossy().into_owned());
out.binary.is_some()
}
("custom", None) => p.run.entrypoint.is_some(),
_ => first.is_some(),
};
out
}
pub fn python_for(explicit: Option<&str>, ctx: &Ctx) -> Option<PathBuf> {
let name = explicit.map(str::to_string).or_else(|| ctx.var("HL_PYTHON").map(str::to_string)).unwrap_or_else(|| "python3".into());
ctx.which(&name)
}
pub fn python_package_versions(packages: &[String], python: Option<&str>, ctx: &Ctx) -> BTreeMap<String, String> {
let names: Vec<&str> = packages.iter().filter_map(|p| p.strip_prefix("pypi:")).collect();
let mut out = BTreeMap::new();
if names.is_empty() {
return out;
}
let Some(py) = python_for(python, ctx) else { return out };
let script = format!(
"import json, importlib.metadata as m\nout = {{}}\nfor n in {names:?}:\n try: out[n] = m.version(n)\n except Exception: pass\nprint(json.dumps(out))"
);
let Some(text) = run_capture(&py, &["-c".to_string(), script], ctx, VERSION_TIMEOUT) else { return out };
let Some(line) = text.lines().find(|l| l.trim_start().starts_with('{')) else { return out };
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(line) {
for (k, v) in map {
if let Some(v) = v.as_str() {
out.insert(format!("pypi:{k}"), v.to_string());
}
}
}
out
}
pub fn probe_runtime(p: &RuntimeProfile, ctx: &Ctx) -> Probe {
probe_binary(&p.id, &p.binaries, &p.version, &p.install, ctx)
}
fn probe_binary(id: &str, binaries: &[String], version: &VersionProbe, install: &[InstallMethod], ctx: &Ctx) -> Probe {
let mut out = Probe { id: id.to_string(), ..Default::default() };
let Some(bin) = binaries.iter().find_map(|b| ctx.which(b)) else {
return out;
};
out.installed = true;
out.binary = Some(bin.to_string_lossy().into_owned());
if !version.args.is_empty() {
out.version = run_capture(&bin, &version.args, ctx, VERSION_TIMEOUT).and_then(|t| extract_version(&t));
}
let real = std::fs::canonicalize(&bin).unwrap_or(bin.clone());
let (method, package) = detect_install(&real, install);
out.source = method.as_deref().map(|m| source_spec(m, package.as_deref(), out.version.as_deref()));
out.method = method;
out
}
pub fn source_spec(method: &str, package: Option<&str>, version: Option<&str>) -> String {
match (method, package, version) {
("pip", Some(p), Some(v)) => format!("pypi:{p}=={v}"),
("uv-tool", Some(p), Some(v)) => format!("pypi:{p}=={v}"),
(m, Some(p), Some(v)) => format!("{m}:{p}@{v}"),
(m, Some(p), None) => format!("{m}:{p}"),
(m, None, Some(v)) => format!("{m}@{v}"),
(m, None, None) => m.to_string(),
}
}
pub fn detect_install(real: &Path, install: &[InstallMethod]) -> (Option<String>, Option<String>) {
let s = real.to_string_lossy().replace('\\', "/");
if s.contains("/node_modules/") {
let pkg = npm_package_name(real).or_else(|| find_method(install, "npm").and_then(|m| m.package.clone()));
return (Some("npm".into()), pkg);
}
if let Some(rest) = s.split("/Caskroom/").nth(1) {
return (Some("brew-cask".into()), rest.split('/').next().map(str::to_string));
}
if let Some(rest) = s.split("/Cellar/").nth(1) {
return (Some("brew".into()), rest.split('/').next().map(str::to_string));
}
if s.contains("/uv/tools/") {
let pkg = s.split("/uv/tools/").nth(1).and_then(|r| r.split('/').next()).map(str::to_string);
return (Some("uv-tool".into()), pkg);
}
if s.contains("/site-packages/") || s.contains("/pipx/venvs/") {
return (Some("pip".into()), find_method(install, "pip").and_then(|m| m.package.clone()));
}
for m in install {
if m.path_hints.iter().any(|h| s.contains(h.as_str())) {
return (Some(m.method.clone()), m.package.clone());
}
}
(None, None)
}
fn find_method<'a>(install: &'a [InstallMethod], method: &str) -> Option<&'a InstallMethod> {
install.iter().find(|m| m.method == method)
}
fn npm_package_name(file: &Path) -> Option<String> {
let mut dir = file.parent();
while let Some(d) = dir {
let pj = d.join("package.json");
if pj.is_file() {
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&pj).ok()?).ok()?;
if let Some(name) = v.get("name").and_then(|n| n.as_str()) {
return Some(name.to_string());
}
}
if d.file_name().is_some_and(|n| n == "node_modules") {
break;
}
dir = d.parent();
}
None
}
pub fn run_capture(bin: &Path, args: &[String], ctx: &Ctx, timeout: Duration) -> Option<String> {
let mut child = Command::new(bin)
.args(args)
.current_dir(&ctx.cwd)
.env_clear()
.envs(&ctx.env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed() > timeout => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
Err(_) => return None,
}
}
let out = child.wait_with_output().ok()?;
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push('\n');
text.push_str(&String::from_utf8_lossy(&out.stderr));
Some(text)
}
pub fn extract_version(text: &str) -> Option<String> {
let clean = strip_ansi(text);
let b = clean.as_bytes();
let mut i = 0;
while i < b.len() {
let boundary = i == 0
|| !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'.')
|| (matches!(b[i - 1], b'v' | b'V') && (i == 1 || !b[i - 2].is_ascii_alphanumeric()));
if b[i].is_ascii_digit() && boundary {
let start = i;
let mut dots = 0;
while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && i + 1 < b.len() && b[i + 1].is_ascii_digit())) {
if b[i] == b'.' {
dots += 1;
}
i += 1;
}
if dots >= 1 {
if i < b.len() && (b[i] == b'-' || b[i] == b'+') && i + 1 < b.len() && b[i + 1].is_ascii_alphanumeric() {
i += 1;
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') {
i += 1;
}
}
return Some(clean[start..i].trim_end_matches('.').to_string());
}
}
i += 1;
}
None
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for n in chars.by_ref() {
if n.is_ascii_alphabetic() {
break;
}
}
}
continue;
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtins_parse() {
let r = Registry::builtin();
assert!(r.harnesses.len() >= 10);
for id in ["claude-code", "codex", "pi", "opencode", "hermes", "cursor-agent"] {
let h = r.harness(id).unwrap();
assert_eq!(h.support, "full", "{id}");
for (fid, f) in &h.files {
assert!(["json", "jsonc", "toml", "yaml"].contains(&f.format.as_str()), "{id}.{fid}");
assert!(["whole", "pointers", "record"].contains(&f.restore.as_str()), "{id}.{fid}");
}
let refs = h.model.default.iter().chain(&h.model.small).chain(&h.model.provider).chain(&h.mcp.sources);
for r in refs {
if let Some((file, _)) = r.split_once('#') {
assert!(h.files.contains_key(file), "{id}: {r}");
}
}
}
for fw in ["pydantic-ai", "deepagents", "langgraph", "openai-agents", "claude-agent-sdk"] {
let h = r.harness(fw).unwrap();
assert_eq!(h.kind, "framework", "{fw}");
assert!(h.packages.iter().all(|p| p.starts_with("pypi:")), "{fw}");
}
assert!(r.harnesses.iter().filter(|h| h.kind == "cli").count() >= 10);
assert!(r.runtime("ollama").is_some());
assert_eq!(r.harness("claude").unwrap().id, "claude-code");
assert_eq!(r.runtime("lms").unwrap().id, "lmstudio");
}
#[test]
fn versions() {
assert_eq!(extract_version("2.1.285 (Claude Code)").as_deref(), Some("2.1.285"));
assert_eq!(extract_version("Hermes Agent v0.20.0 (2026.8.3)").as_deref(), Some("0.20.0"));
assert_eq!(extract_version("2026.05.28-a70ca7c").as_deref(), Some("2026.05.28-a70ca7c"));
assert_eq!(extract_version("Warning: client version is 0.35.1").as_deref(), Some("0.35.1"));
assert_eq!(extract_version("\u{1b}[38;5;166m art \u{1b}[0m\nCLI commit: 1.2.3").as_deref(), Some("1.2.3"));
assert_eq!(extract_version("no version"), None);
}
#[test]
fn install_detection() {
let install = vec![InstallMethod {
method: "script".into(),
path_hints: vec!["/.local/share/cursor-agent/".into()],
..Default::default()
}];
let (m, p) = detect_install(Path::new("/opt/homebrew/Caskroom/claude-code/2.1.285/claude"), &[]);
assert_eq!((m.as_deref(), p.as_deref()), (Some("brew-cask"), Some("claude-code")));
let (m, p) = detect_install(Path::new("/opt/homebrew/Cellar/opencode/1.18.18/bin/opencode"), &[]);
assert_eq!((m.as_deref(), p.as_deref()), (Some("brew"), Some("opencode")));
let (m, _) = detect_install(Path::new("/home/u/.local/share/cursor-agent/versions/x/cursor-agent"), &install);
assert_eq!(m.as_deref(), Some("script"));
assert_eq!(source_spec("npm", Some("@openai/codex"), Some("0.5.0")), "npm:@openai/codex@0.5.0");
assert_eq!(source_spec("pip", Some("vllm"), Some("0.11.2")), "pypi:vllm==0.11.2");
}
fn ctx_with(env: &[(&str, &str)]) -> Ctx {
Ctx {
home: PathBuf::from("/home/u"),
cwd: PathBuf::from("/work"),
env: env.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
path: vec![],
}
}
#[test]
fn config_roots_and_isolation() {
let r = Registry::builtin();
let claude = r.harness("claude-code").unwrap();
let ctx = ctx_with(&[]);
assert_eq!(claude.config_root(&ctx), PathBuf::from("/home/u/.claude"));
assert_eq!(claude.file_path("state", &ctx).unwrap(), PathBuf::from("/home/u/.claude.json"));
assert_eq!(claude.file_path("project-mcp", &ctx).unwrap(), PathBuf::from("/work/.mcp.json"));
let iso = ctx_with(&[("CLAUDE_CONFIG_DIR", "/tmp/env/claude")]);
assert_eq!(claude.file_path("state", &iso).unwrap(), PathBuf::from("/tmp/env/claude/.claude.json"));
let oc = r.harness("opencode").unwrap();
let xdg = ctx_with(&[("XDG_CONFIG_HOME", "/tmp/env/xdg")]);
assert_eq!(oc.config_root(&xdg), PathBuf::from("/tmp/env/xdg/opencode"));
let (var, val) = oc.isolation_env(Path::new("/tmp/env/xdg/opencode")).unwrap();
assert_eq!((var.as_str(), val), ("XDG_CONFIG_HOME", PathBuf::from("/tmp/env/xdg")));
}
#[test]
fn selection_precedence() {
let r = Registry::builtin();
let ctx = ctx_with(&[("CLAUDECODE", "1"), ("HLOCK_HARNESS", "pi")]);
assert_eq!(r.select(Some("codex"), Some("opencode"), &ctx).unwrap().id, "codex");
assert_eq!(r.select(None, Some("opencode"), &ctx).unwrap().id, "pi");
let ctx = ctx_with(&[("CLAUDECODE", "1")]);
assert_eq!(r.select(None, Some("opencode"), &ctx).unwrap().id, "opencode");
let s = r.select(None, None, &ctx).unwrap();
assert_eq!((s.id.as_str(), s.reason.as_str()), ("claude-code", "running inside it"));
assert!(r.select(Some("nope"), None, &ctx).unwrap_err().to_string().contains("known:"));
}
#[test]
fn project_harnesses() {
let m = crate::manifest::Manifest::from_str(
r#"
[target]
harness = "triage"
[harnesses.triage]
framework = "pydantic-ai"
entrypoint = "triage.agent:run"
[harnesses.loop]
command = ["./bin/agent", "{prompt}"]
packages = ["pypi:httpx"]
"#,
)
.unwrap();
let mut r = Registry::builtin();
r.add_project_harnesses(&m).unwrap();
let t = r.harness("triage").unwrap();
assert_eq!(t.kind, "custom");
assert_eq!(t.run.entrypoint.as_deref(), Some("triage.agent:run"));
assert!(t.packages.contains(&"pypi:pydantic-ai-slim".to_string()));
assert_eq!(r.harness("loop").unwrap().run.command[0], "./bin/agent");
assert_eq!(r.select(None, m.target_harness().as_deref(), &ctx_with(&[])).unwrap().id, "triage");
let bad = crate::manifest::Manifest::from_str("[harnesses.x]\nframework = \"nope\"\nentrypoint = \"a:b\"\n").unwrap();
assert!(Registry::builtin().add_project_harnesses(&bad).unwrap_err().to_string().contains("not a known framework"));
let bare = crate::manifest::Manifest::from_str("[harnesses.x]\nframework = \"langgraph\"\n").unwrap();
assert!(Registry::builtin().add_project_harnesses(&bare).unwrap_err().to_string().contains("entrypoint"));
}
#[test]
fn user_profiles_override_and_extend() {
let dir = std::env::temp_dir().join(format!("hlock-prof-{}", std::process::id()));
std::fs::create_dir_all(dir.join("harnesses")).unwrap();
std::fs::write(dir.join("harnesses/my.toml"), "id = \"my-agent\"\nbinaries = [\"my-agent\"]\n").unwrap();
std::fs::write(dir.join("harnesses/pi.toml"), "id = \"pi\"\nname = \"Pi (fork)\"\n").unwrap();
let mut r = Registry::builtin();
r.extend_from_dir(&dir).unwrap();
assert_eq!(r.harness("my-agent").unwrap().support, "basic");
assert_eq!(r.harness("pi").unwrap().name, "Pi (fork)");
assert_eq!(r.harnesses.iter().filter(|h| h.id == "pi").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
}