1use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::time::{Duration, Instant};
15
16use serde::{Deserialize, Serialize};
17
18use crate::error::{Error, Result};
19
20const BUILTIN_HARNESSES: &[(&str, &str)] = &[
25 ("claude-code", include_str!("../profiles/harnesses/claude-code.toml")),
26 ("codex", include_str!("../profiles/harnesses/codex.toml")),
27 ("pi", include_str!("../profiles/harnesses/pi.toml")),
28 ("opencode", include_str!("../profiles/harnesses/opencode.toml")),
29 ("hermes", include_str!("../profiles/harnesses/hermes.toml")),
30 ("cursor-agent", include_str!("../profiles/harnesses/cursor-agent.toml")),
31 ("gemini-cli", include_str!("../profiles/harnesses/gemini-cli.toml")),
32 ("copilot-cli", include_str!("../profiles/harnesses/copilot-cli.toml")),
33 ("goose", include_str!("../profiles/harnesses/goose.toml")),
34 ("amp", include_str!("../profiles/harnesses/amp.toml")),
35 ("pydantic-ai", include_str!("../profiles/harnesses/pydantic-ai.toml")),
37 ("deepagents", include_str!("../profiles/harnesses/deepagents.toml")),
38 ("langgraph", include_str!("../profiles/harnesses/langgraph.toml")),
39 ("openai-agents", include_str!("../profiles/harnesses/openai-agents.toml")),
40 ("claude-agent-sdk", include_str!("../profiles/harnesses/claude-agent-sdk.toml")),
41 ("google-adk", include_str!("../profiles/harnesses/google-adk.toml")),
42 ("smolagents", include_str!("../profiles/harnesses/smolagents.toml")),
43 ("crewai", include_str!("../profiles/harnesses/crewai.toml")),
44];
45
46const BUILTIN_RUNTIMES: &[(&str, &str)] = &[
47 ("ollama", include_str!("../profiles/runtimes/ollama.toml")),
48 ("lmstudio", include_str!("../profiles/runtimes/lmstudio.toml")),
49 ("llama-cpp", include_str!("../profiles/runtimes/llama-cpp.toml")),
50 ("vllm", include_str!("../profiles/runtimes/vllm.toml")),
51 ("sglang", include_str!("../profiles/runtimes/sglang.toml")),
52 ("mlx-lm", include_str!("../profiles/runtimes/mlx-lm.toml")),
53 ("localai", include_str!("../profiles/runtimes/localai.toml")),
54 ("docker-model-runner", include_str!("../profiles/runtimes/docker-model-runner.toml")),
55 ("jan", include_str!("../profiles/runtimes/jan.toml")),
56];
57
58#[derive(Debug, Clone)]
66pub struct Ctx {
67 pub home: PathBuf,
68 pub cwd: PathBuf,
69 pub env: BTreeMap<String, String>,
70 pub path: Vec<PathBuf>,
71}
72
73impl Ctx {
74 pub fn from_process() -> Ctx {
75 let mut env: BTreeMap<String, String> = std::env::vars().collect();
76 crate::meta::restore_env(&mut env);
77 let home = env.get("HOME").or_else(|| env.get("USERPROFILE")).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
78 let path = env.get("PATH").map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
79 Ctx {
80 home,
81 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
82 env,
83 path,
84 }
85 }
86
87 pub fn at(root: &Path) -> Ctx {
90 let mut c = Ctx::from_process();
91 c.cwd = if root.is_absolute() { root.to_path_buf() } else { c.cwd.join(root) };
92 c
93 }
94
95 pub fn var(&self, key: &str) -> Option<&str> {
97 self.env.get(key).map(String::as_str).filter(|v| !v.is_empty())
98 }
99
100 pub fn expand(&self, template: &str, root: Option<&Path>) -> PathBuf {
102 let mut s = template.to_string();
103 if let Some(r) = root {
104 s = s.replace("{root}", &r.to_string_lossy());
105 }
106 s = s.replace("{home}", &self.home.to_string_lossy());
107 if s == "~" {
108 return self.home.clone();
109 }
110 if let Some(rest) = s.strip_prefix("~/") {
111 return self.home.join(rest);
112 }
113 let p = PathBuf::from(&s);
114 if p.is_absolute() {
115 p
116 } else {
117 self.cwd.join(p)
118 }
119 }
120
121 pub fn which(&self, name: &str) -> Option<PathBuf> {
123 if name.contains('/') {
124 let p = PathBuf::from(name);
125 return p.is_file().then_some(p);
126 }
127 for dir in &self.path {
128 let cand = dir.join(name);
129 if cand.is_file() {
130 return Some(cand);
131 }
132 }
133 None
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142#[serde(default, rename_all = "kebab-case")]
143pub struct VersionProbe {
144 pub args: Vec<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149#[serde(default, rename_all = "kebab-case")]
150pub struct ConfigRoot {
151 pub env: Option<String>,
153 pub suffix: Option<String>,
155 pub default: String,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, Default)]
159#[serde(default, rename_all = "kebab-case")]
160pub struct InstallMethod {
161 pub method: String,
163 pub package: Option<String>,
164 pub command: Option<String>,
166 pub path_hints: Vec<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Default)]
171#[serde(default, rename_all = "kebab-case")]
172pub struct FileSpec {
173 pub path: String,
174 pub isolated: Option<String>,
176 pub format: String,
178 pub restore: String,
180 pub pointers: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
184#[serde(default, rename_all = "kebab-case")]
185pub struct ModelSpec {
186 pub default: Vec<String>,
188 pub small: Vec<String>,
189 pub provider: Vec<String>,
190 pub base_url: Vec<String>,
191 pub format: Option<String>,
193 pub default_provider: Option<String>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, Default)]
197#[serde(default, rename_all = "kebab-case")]
198pub struct ProviderSpec {
199 pub source: Vec<String>,
200 pub base_url: Vec<String>,
202 pub models: Vec<String>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, Default)]
206#[serde(default, rename_all = "kebab-case")]
207pub struct McpSpec {
208 pub sources: Vec<String>,
209 pub write: Option<String>,
210 pub shape: Option<String>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, Default)]
216#[serde(default, rename_all = "kebab-case")]
217pub struct SkillsSpec {
218 pub dirs: Vec<String>,
219 pub write: Option<String>,
220 pub builtin_dirs: Vec<String>,
222 pub builtin_manifest: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, Default)]
227#[serde(default, rename_all = "kebab-case")]
228pub struct BundleSpec {
229 pub kind: String,
231 pub extractor: String,
233 pub source: String,
234 pub lockfile: Option<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, Default)]
238#[serde(default, rename_all = "kebab-case")]
239pub struct LaunchSpec {
240 pub args: Vec<String>,
241 pub model_flag: Option<String>,
242 pub prompt: Vec<String>,
246 pub interactive: Vec<String>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, Default)]
255#[serde(default, rename_all = "kebab-case")]
256pub struct LocalProvider {
257 pub file: String,
258 pub set: BTreeMap<String, serde_json::Value>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, Default)]
263#[serde(default, rename_all = "kebab-case")]
264pub struct ApiKey {
265 pub provider: String,
266 pub env: String,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, Default)]
271#[serde(default, rename_all = "kebab-case")]
272pub struct CatalogModel {
273 pub id: String,
274 pub params_b: Option<f64>,
275 pub min_memory_gb: Option<f64>,
277 pub tier: u8,
279 pub tools: bool,
281 pub roles: Vec<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, Default)]
287#[serde(default, rename_all = "kebab-case")]
288pub struct RunSpec {
289 pub entrypoint: Option<String>,
291 pub command: Vec<String>,
293 pub python: Option<String>,
295 pub env: BTreeMap<String, String>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, Default)]
300#[serde(default, rename_all = "kebab-case")]
301pub struct HarnessProfile {
302 pub id: String,
303 pub name: String,
304 pub kind: String,
307 pub aliases: Vec<String>,
308 pub vendor: Option<String>,
309 pub homepage: Option<String>,
310 pub support: String,
312 pub open_source: Option<bool>,
314 pub local_models: Option<bool>,
316 pub local_provider: Vec<LocalProvider>,
318 pub api_keys: Vec<ApiKey>,
320 pub api_provider: Vec<LocalProvider>,
323 pub framework: Option<String>,
325 pub packages: Vec<String>,
327 pub run: RunSpec,
329 pub binaries: Vec<String>,
330 pub version: VersionProbe,
331 pub active_env: Vec<String>,
333 pub config_root: ConfigRoot,
334 pub instructions: Vec<String>,
335 pub install: Vec<InstallMethod>,
336 pub files: BTreeMap<String, FileSpec>,
337 pub model: ModelSpec,
338 pub providers: Option<ProviderSpec>,
339 pub mcp: McpSpec,
340 pub skills: SkillsSpec,
341 pub bundles: Vec<BundleSpec>,
342 pub launch: LaunchSpec,
343 #[serde(skip_deserializing)]
345 pub origin: String,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, Default)]
349#[serde(default, rename_all = "kebab-case")]
350pub struct RuntimeModels {
351 pub layout: String,
353 pub dirs: Vec<String>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, Default)]
359#[serde(default, rename_all = "kebab-case")]
360pub struct RuntimeProfile {
361 pub id: String,
362 pub name: String,
363 pub aliases: Vec<String>,
364 pub homepage: Option<String>,
365 pub binaries: Vec<String>,
366 pub version: VersionProbe,
367 pub ports: Vec<u16>,
369 pub provider_names: Vec<String>,
371 pub pull: Option<String>,
373 pub serve: Option<String>,
374 pub models: RuntimeModels,
375 pub install: Vec<InstallMethod>,
376 pub catalog: Vec<CatalogModel>,
378 #[serde(skip_deserializing)]
379 pub origin: String,
380}
381
382impl HarnessProfile {
383 pub fn matches(&self, name: &str) -> bool {
384 let n = name.to_ascii_lowercase();
385 self.id == n || self.aliases.iter().any(|a| a.to_ascii_lowercase() == n)
386 }
387
388 pub fn config_root(&self, ctx: &Ctx) -> PathBuf {
390 let cr = &self.config_root;
391 if let Some(var) = &cr.env {
392 if let Some(v) = ctx.var(var) {
393 let base = ctx.expand(v, None);
394 return match &cr.suffix {
395 Some(sfx) => base.join(sfx),
396 None => base,
397 };
398 }
399 }
400 ctx.expand(&cr.default, None)
401 }
402
403 pub fn root_relocated(&self, ctx: &Ctx) -> bool {
405 self.config_root.env.as_deref().and_then(|v| ctx.var(v)).is_some()
406 }
407
408 pub fn file_path(&self, id: &str, ctx: &Ctx) -> Option<PathBuf> {
410 let f = self.files.get(id)?;
411 let root = self.config_root(ctx);
412 let tpl = match (&f.isolated, self.root_relocated(ctx)) {
413 (Some(iso), true) => iso,
414 _ => &f.path,
415 };
416 Some(ctx.expand(tpl, Some(&root)))
417 }
418
419 pub fn isolation_env(&self, dir: &Path) -> Option<(String, PathBuf)> {
422 let var = self.config_root.env.clone()?;
423 let value = match &self.config_root.suffix {
424 Some(_) => dir.parent().map(Path::to_path_buf).unwrap_or_else(|| dir.to_path_buf()),
426 None => dir.to_path_buf(),
427 };
428 Some((var, value))
429 }
430
431 pub fn is_active(&self, ctx: &Ctx) -> bool {
433 self.active_env.iter().any(|v| ctx.var(v).is_some())
434 }
435}
436
437impl RuntimeProfile {
438 pub fn matches(&self, name: &str) -> bool {
439 let n = name.to_ascii_lowercase();
440 self.id == n || self.aliases.iter().any(|a| a.to_ascii_lowercase() == n)
441 }
442
443 pub fn models_dir(&self, ctx: &Ctx) -> Option<PathBuf> {
445 let mut first = None;
446 for d in &self.models.dirs {
447 let p = match d.strip_prefix("env:") {
448 Some(var) => match ctx.var(var) {
449 Some(v) => ctx.expand(v, None),
450 None => continue,
451 },
452 None => ctx.expand(d, None),
453 };
454 if p.is_dir() {
455 return Some(p);
456 }
457 first.get_or_insert(p);
458 }
459 first
460 }
461}
462
463#[derive(Debug, Clone, Default)]
469pub struct Registry {
470 pub harnesses: Vec<HarnessProfile>,
471 pub runtimes: Vec<RuntimeProfile>,
472}
473
474pub fn profile_dirs(ctx: &Ctx) -> Vec<PathBuf> {
479 let mut dirs = vec![ctx.expand("~/.config/hlock/profiles", None)];
480 if let Some(p) = ctx.var("HLOCK_PROFILE_PATH") {
481 dirs.extend(std::env::split_paths(p));
482 }
483 dirs.push(ctx.cwd.join(".hlock/profiles"));
484 dirs
485}
486
487pub fn parse_harness(text: &str, origin: &str) -> Result<HarnessProfile> {
488 let mut p: HarnessProfile =
489 toml::from_str(text).map_err(|e| Error::Profile(format!("invalid harness profile {origin}: {e}")))?;
490 if p.id.is_empty() {
491 return Err(Error::Profile(format!("harness profile {origin} has no id")));
492 }
493 if p.name.is_empty() {
494 p.name = p.id.clone();
495 }
496 if p.support.is_empty() {
497 p.support = "basic".into();
498 }
499 if p.kind.is_empty() {
500 p.kind = if p.run.entrypoint.is_some() || !p.run.command.is_empty() { "custom" } else { "cli" }.into();
501 }
502 p.origin = origin.to_string();
503 Ok(p)
504}
505
506pub fn parse_runtime(text: &str, origin: &str) -> Result<RuntimeProfile> {
507 let mut p: RuntimeProfile =
508 toml::from_str(text).map_err(|e| Error::Profile(format!("invalid runtime profile {origin}: {e}")))?;
509 if p.id.is_empty() {
510 return Err(Error::Profile(format!("runtime profile {origin} has no id")));
511 }
512 if p.name.is_empty() {
513 p.name = p.id.clone();
514 }
515 p.origin = origin.to_string();
516 Ok(p)
517}
518
519impl Registry {
520 pub fn builtin() -> Registry {
522 let harnesses = BUILTIN_HARNESSES
523 .iter()
524 .map(|(id, text)| parse_harness(text, "builtin").unwrap_or_else(|e| panic!("builtin profile {id}: {e}")))
525 .collect();
526 let runtimes = BUILTIN_RUNTIMES
527 .iter()
528 .map(|(id, text)| parse_runtime(text, "builtin").unwrap_or_else(|e| panic!("builtin profile {id}: {e}")))
529 .collect();
530 Registry { harnesses, runtimes }
531 }
532
533 pub fn load(ctx: &Ctx) -> Result<Registry> {
535 let mut reg = Registry::builtin();
536 for dir in profile_dirs(ctx) {
537 reg.extend_from_dir(&dir)?;
538 }
539 Ok(reg)
540 }
541
542 pub fn extend_from_dir(&mut self, dir: &Path) -> Result<()> {
544 for path in toml_files(&dir.join("harnesses")) {
545 let text = std::fs::read_to_string(&path)?;
546 let p = parse_harness(&text, &path.to_string_lossy())?;
547 self.harnesses.retain(|h| h.id != p.id);
548 self.harnesses.push(p);
549 }
550 for path in toml_files(&dir.join("runtimes")) {
551 let text = std::fs::read_to_string(&path)?;
552 let p = parse_runtime(&text, &path.to_string_lossy())?;
553 self.runtimes.retain(|r| r.id != p.id);
554 self.runtimes.push(p);
555 }
556 Ok(())
557 }
558
559 pub fn load_for(ctx: &Ctx, manifest: Option<&crate::manifest::Manifest>) -> Result<Registry> {
561 let mut reg = Registry::load(ctx)?;
562 if let Some(m) = manifest {
563 reg.add_project_harnesses(m)?;
564 }
565 Ok(reg)
566 }
567
568 pub fn add_project_harnesses(&mut self, m: &crate::manifest::Manifest) -> Result<()> {
571 let origin = m
572 .source_path
573 .as_ref()
574 .map(|p| p.to_string_lossy().into_owned())
575 .unwrap_or_else(|| crate::MANIFEST_PATH.to_string());
576 for (id, table) in &m.harnesses {
577 let mut v = table.clone();
578 if let Some(obj) = v.as_object_mut() {
579 obj.entry("id").or_insert_with(|| serde_json::json!(id));
580 let mut run = obj.remove("run").and_then(|r| r.as_object().cloned()).unwrap_or_default();
582 for key in ["entrypoint", "command", "python", "env"] {
583 if let Some(val) = obj.remove(key) {
584 run.insert(key.to_string(), val);
585 }
586 }
587 obj.insert("run".into(), serde_json::Value::Object(run));
588 }
589 let mut p: HarnessProfile = serde_json::from_value(v)
590 .map_err(|e| Error::Profile(format!("invalid [harnesses.{id}] in {origin}: {e}")))?;
591 if p.name.is_empty() {
592 p.name = id.clone();
593 }
594 if p.kind.is_empty() {
595 p.kind = "custom".into();
596 }
597 if p.support.is_empty() {
598 p.support = "basic".into();
599 }
600 if let Some(fw) = &p.framework {
601 let base = self
602 .harness(fw)
603 .filter(|h| h.kind == "framework")
604 .ok_or_else(|| Error::Profile(format!("[harnesses.{id}] framework {fw:?} is not a known framework")))?;
605 for pkg in &base.packages {
606 if !p.packages.contains(pkg) {
607 p.packages.push(pkg.clone());
608 }
609 }
610 if p.homepage.is_none() {
611 p.homepage = base.homepage.clone();
612 }
613 }
614 if p.kind == "custom" && p.run.entrypoint.is_none() && p.run.command.is_empty() {
615 return Err(Error::Profile(format!(
616 "[harnesses.{id}] needs an `entrypoint` (python module:callable) or a `command`"
617 )));
618 }
619 p.origin = origin.clone();
620 self.harnesses.retain(|h| h.id != p.id);
621 self.harnesses.push(p);
622 }
623 Ok(())
624 }
625
626 pub fn harness(&self, name: &str) -> Option<&HarnessProfile> {
627 self.harnesses.iter().find(|h| h.matches(name))
628 }
629
630 pub fn runtime(&self, name: &str) -> Option<&RuntimeProfile> {
631 self.runtimes.iter().find(|r| r.matches(name))
632 }
633
634 pub fn require_harness(&self, name: &str) -> Result<&HarnessProfile> {
636 self.harness(name).ok_or_else(|| {
637 let known: Vec<&str> = self.harnesses.iter().map(|h| h.id.as_str()).collect();
638 Error::Profile(format!("unknown harness {name:?} (known: {})", known.join(", ")))
639 })
640 }
641
642 pub fn active(&self, ctx: &Ctx) -> Option<&HarnessProfile> {
644 self.harnesses.iter().find(|h| h.is_active(ctx))
645 }
646
647 pub fn select(&self, explicit: Option<&str>, manifest_target: Option<&str>, ctx: &Ctx) -> Result<Selection> {
651 let named = explicit
652 .map(|n| (n.to_string(), "--harness"))
653 .or_else(|| ctx.var("HLOCK_HARNESS").map(|n| (n.to_string(), "HLOCK_HARNESS")))
654 .or_else(|| manifest_target.map(|n| (n.to_string(), "harness.toml [target]")));
655 if let Some((name, why)) = named {
656 let p = self.require_harness(&name)?;
657 return Ok(Selection { id: p.id.clone(), reason: why.to_string() });
658 }
659 if let Some(p) = self.active(ctx) {
660 return Ok(Selection { id: p.id.clone(), reason: "running inside it".into() });
661 }
662 let installed: Vec<&HarnessProfile> = self
663 .harnesses
664 .iter()
665 .filter(|h| h.kind == "cli" && h.binaries.iter().any(|b| ctx.which(b).is_some()))
666 .collect();
667 match installed.as_slice() {
668 [one] => Ok(Selection { id: one.id.clone(), reason: "the only installed harness".into() }),
669 [] => Err(Error::Profile("no supported harness found on PATH".into())),
670 many => Err(Error::Profile(format!(
671 "several harnesses are installed ({}); pick one with --harness",
672 many.iter().map(|h| h.id.as_str()).collect::<Vec<_>>().join(", ")
673 ))),
674 }
675 }
676}
677
678#[derive(Debug, Clone, Serialize)]
680pub struct Selection {
681 pub id: String,
682 pub reason: String,
683}
684
685fn toml_files(dir: &Path) -> Vec<PathBuf> {
686 let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
687 .map(|rd| {
688 rd.flatten()
689 .map(|e| e.path())
690 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
691 .collect()
692 })
693 .unwrap_or_default();
694 out.sort();
695 out
696}
697
698#[derive(Debug, Clone, Serialize, Default)]
704pub struct Probe {
705 pub id: String,
706 pub installed: bool,
707 pub binary: Option<String>,
708 pub version: Option<String>,
709 pub method: Option<String>,
711 pub source: Option<String>,
713 pub config_root: Option<String>,
714 pub active: bool,
715 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
717 pub packages: BTreeMap<String, String>,
718}
719
720const VERSION_TIMEOUT: Duration = Duration::from_secs(8);
721
722pub fn probe_harness(p: &HarnessProfile, ctx: &Ctx) -> Probe {
723 if p.kind != "cli" {
724 return probe_code_harness(p, ctx);
725 }
726 let mut out = probe_binary(&p.id, &p.binaries, &p.version, &p.install, ctx);
727 out.config_root = Some(p.config_root(ctx).to_string_lossy().into_owned());
728 out.active = p.is_active(ctx);
729 out
730}
731
732fn probe_code_harness(p: &HarnessProfile, ctx: &Ctx) -> Probe {
735 let mut out = Probe { id: p.id.clone(), active: p.is_active(ctx), ..Default::default() };
736 out.packages = python_package_versions(&p.packages, p.run.python.as_deref(), ctx);
737 let first = p.packages.iter().find_map(|pkg| out.packages.get(pkg).map(|v| (pkg, v)));
738 if let Some((pkg, v)) = first {
739 out.version = Some(v.clone());
740 out.method = Some("pip".into());
741 out.source = Some(format!("pypi:{}=={v}", pkg.trim_start_matches("pypi:")));
742 }
743 out.installed = match (p.kind.as_str(), p.run.command.first()) {
744 ("custom", Some(cmd)) => {
745 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());
746 out.binary.is_some()
747 }
748 ("custom", None) => p.run.entrypoint.is_some(),
749 _ => first.is_some(),
750 };
751 out
752}
753
754pub fn python_for(explicit: Option<&str>, ctx: &Ctx) -> Option<PathBuf> {
756 let name = explicit.map(str::to_string).or_else(|| ctx.var("HL_PYTHON").map(str::to_string)).unwrap_or_else(|| "python3".into());
757 ctx.which(&name)
758}
759
760pub fn python_package_versions(packages: &[String], python: Option<&str>, ctx: &Ctx) -> BTreeMap<String, String> {
762 let names: Vec<&str> = packages.iter().filter_map(|p| p.strip_prefix("pypi:")).collect();
763 let mut out = BTreeMap::new();
764 if names.is_empty() {
765 return out;
766 }
767 let Some(py) = python_for(python, ctx) else { return out };
768 let script = format!(
769 "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))"
770 );
771 let Some(text) = run_capture(&py, &["-c".to_string(), script], ctx, VERSION_TIMEOUT) else { return out };
772 let Some(line) = text.lines().find(|l| l.trim_start().starts_with('{')) else { return out };
773 if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(line) {
774 for (k, v) in map {
775 if let Some(v) = v.as_str() {
776 out.insert(format!("pypi:{k}"), v.to_string());
777 }
778 }
779 }
780 out
781}
782
783pub fn probe_runtime(p: &RuntimeProfile, ctx: &Ctx) -> Probe {
784 probe_binary(&p.id, &p.binaries, &p.version, &p.install, ctx)
785}
786
787fn probe_binary(id: &str, binaries: &[String], version: &VersionProbe, install: &[InstallMethod], ctx: &Ctx) -> Probe {
788 let mut out = Probe { id: id.to_string(), ..Default::default() };
789 let Some(bin) = binaries.iter().find_map(|b| ctx.which(b)) else {
790 return out;
791 };
792 out.installed = true;
793 out.binary = Some(bin.to_string_lossy().into_owned());
794 if !version.args.is_empty() {
795 out.version = run_capture(&bin, &version.args, ctx, VERSION_TIMEOUT).and_then(|t| extract_version(&t));
796 }
797 let real = std::fs::canonicalize(&bin).unwrap_or(bin.clone());
798 let (method, package) = detect_install(&real, install);
799 out.source = method.as_deref().map(|m| source_spec(m, package.as_deref(), out.version.as_deref()));
800 out.method = method;
801 out
802}
803
804pub fn source_spec(method: &str, package: Option<&str>, version: Option<&str>) -> String {
806 match (method, package, version) {
807 ("pip", Some(p), Some(v)) => format!("pypi:{p}=={v}"),
808 ("uv-tool", Some(p), Some(v)) => format!("pypi:{p}=={v}"),
809 (m, Some(p), Some(v)) => format!("{m}:{p}@{v}"),
810 (m, Some(p), None) => format!("{m}:{p}"),
811 (m, None, Some(v)) => format!("{m}@{v}"),
812 (m, None, None) => m.to_string(),
813 }
814}
815
816pub fn detect_install(real: &Path, install: &[InstallMethod]) -> (Option<String>, Option<String>) {
818 let s = real.to_string_lossy().replace('\\', "/");
819 if s.contains("/node_modules/") {
820 let pkg = npm_package_name(real).or_else(|| find_method(install, "npm").and_then(|m| m.package.clone()));
821 return (Some("npm".into()), pkg);
822 }
823 if let Some(rest) = s.split("/Caskroom/").nth(1) {
824 return (Some("brew-cask".into()), rest.split('/').next().map(str::to_string));
825 }
826 if let Some(rest) = s.split("/Cellar/").nth(1) {
827 return (Some("brew".into()), rest.split('/').next().map(str::to_string));
828 }
829 if s.contains("/uv/tools/") {
830 let pkg = s.split("/uv/tools/").nth(1).and_then(|r| r.split('/').next()).map(str::to_string);
831 return (Some("uv-tool".into()), pkg);
832 }
833 if s.contains("/site-packages/") || s.contains("/pipx/venvs/") {
834 return (Some("pip".into()), find_method(install, "pip").and_then(|m| m.package.clone()));
835 }
836 for m in install {
837 if m.path_hints.iter().any(|h| s.contains(h.as_str())) {
838 return (Some(m.method.clone()), m.package.clone());
839 }
840 }
841 (None, None)
842}
843
844fn find_method<'a>(install: &'a [InstallMethod], method: &str) -> Option<&'a InstallMethod> {
845 install.iter().find(|m| m.method == method)
846}
847
848fn npm_package_name(file: &Path) -> Option<String> {
850 let mut dir = file.parent();
851 while let Some(d) = dir {
852 let pj = d.join("package.json");
853 if pj.is_file() {
854 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&pj).ok()?).ok()?;
855 if let Some(name) = v.get("name").and_then(|n| n.as_str()) {
856 return Some(name.to_string());
857 }
858 }
859 if d.file_name().is_some_and(|n| n == "node_modules") {
860 break;
861 }
862 dir = d.parent();
863 }
864 None
865}
866
867pub fn run_capture(bin: &Path, args: &[String], ctx: &Ctx, timeout: Duration) -> Option<String> {
869 let mut child = Command::new(bin)
870 .args(args)
871 .current_dir(&ctx.cwd)
872 .env_clear()
873 .envs(&ctx.env)
874 .stdin(Stdio::null())
875 .stdout(Stdio::piped())
876 .stderr(Stdio::piped())
877 .spawn()
878 .ok()?;
879 let start = Instant::now();
880 loop {
881 match child.try_wait() {
882 Ok(Some(_)) => break,
883 Ok(None) if start.elapsed() > timeout => {
884 let _ = child.kill();
885 let _ = child.wait();
886 return None;
887 }
888 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
889 Err(_) => return None,
890 }
891 }
892 let out = child.wait_with_output().ok()?;
893 let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
894 text.push('\n');
895 text.push_str(&String::from_utf8_lossy(&out.stderr));
896 Some(text)
897}
898
899pub fn extract_version(text: &str) -> Option<String> {
902 let clean = strip_ansi(text);
903 let b = clean.as_bytes();
904 let mut i = 0;
905 while i < b.len() {
906 let boundary = i == 0
907 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'.')
908 || (matches!(b[i - 1], b'v' | b'V') && (i == 1 || !b[i - 2].is_ascii_alphanumeric()));
909 if b[i].is_ascii_digit() && boundary {
910 let start = i;
911 let mut dots = 0;
912 while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && i + 1 < b.len() && b[i + 1].is_ascii_digit())) {
913 if b[i] == b'.' {
914 dots += 1;
915 }
916 i += 1;
917 }
918 if dots >= 1 {
919 if i < b.len() && (b[i] == b'-' || b[i] == b'+') && i + 1 < b.len() && b[i + 1].is_ascii_alphanumeric() {
921 i += 1;
922 while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') {
923 i += 1;
924 }
925 }
926 return Some(clean[start..i].trim_end_matches('.').to_string());
927 }
928 }
929 i += 1;
930 }
931 None
932}
933
934fn strip_ansi(s: &str) -> String {
935 let mut out = String::with_capacity(s.len());
936 let mut chars = s.chars().peekable();
937 while let Some(c) = chars.next() {
938 if c == '\u{1b}' {
939 if chars.peek() == Some(&'[') {
940 chars.next();
941 for n in chars.by_ref() {
942 if n.is_ascii_alphabetic() {
943 break;
944 }
945 }
946 }
947 continue;
948 }
949 out.push(c);
950 }
951 out
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957
958 #[test]
959 fn builtins_parse() {
960 let r = Registry::builtin();
961 assert!(r.harnesses.len() >= 10);
962 for id in ["claude-code", "codex", "pi", "opencode", "hermes", "cursor-agent"] {
963 let h = r.harness(id).unwrap();
964 assert_eq!(h.support, "full", "{id}");
965 for (fid, f) in &h.files {
966 assert!(["json", "jsonc", "toml", "yaml"].contains(&f.format.as_str()), "{id}.{fid}");
967 assert!(["whole", "pointers", "record"].contains(&f.restore.as_str()), "{id}.{fid}");
968 }
969 let refs = h.model.default.iter().chain(&h.model.small).chain(&h.model.provider).chain(&h.mcp.sources);
971 for r in refs {
972 if let Some((file, _)) = r.split_once('#') {
973 assert!(h.files.contains_key(file), "{id}: {r}");
974 }
975 }
976 }
977 for fw in ["pydantic-ai", "deepagents", "langgraph", "openai-agents", "claude-agent-sdk"] {
978 let h = r.harness(fw).unwrap();
979 assert_eq!(h.kind, "framework", "{fw}");
980 assert!(h.packages.iter().all(|p| p.starts_with("pypi:")), "{fw}");
981 }
982 assert!(r.harnesses.iter().filter(|h| h.kind == "cli").count() >= 10);
983 for h in r.harnesses.iter().filter(|h| h.kind == "cli") {
985 assert!(h.launch.prompt.iter().any(|a| a.contains("{prompt}")), "{}", h.id);
986 }
987 assert!(r.runtime("ollama").is_some());
988 assert_eq!(r.harness("claude").unwrap().id, "claude-code");
989 assert_eq!(r.runtime("lms").unwrap().id, "lmstudio");
990 }
991
992 #[test]
993 fn versions() {
994 assert_eq!(extract_version("2.1.285 (Claude Code)").as_deref(), Some("2.1.285"));
995 assert_eq!(extract_version("Hermes Agent v0.20.0 (2026.8.3)").as_deref(), Some("0.20.0"));
996 assert_eq!(extract_version("2026.05.28-a70ca7c").as_deref(), Some("2026.05.28-a70ca7c"));
997 assert_eq!(extract_version("Warning: client version is 0.35.1").as_deref(), Some("0.35.1"));
998 assert_eq!(extract_version("\u{1b}[38;5;166m art \u{1b}[0m\nCLI commit: 1.2.3").as_deref(), Some("1.2.3"));
999 assert_eq!(extract_version("no version"), None);
1000 }
1001
1002 #[test]
1003 fn install_detection() {
1004 let install = vec![InstallMethod {
1005 method: "script".into(),
1006 path_hints: vec!["/.local/share/cursor-agent/".into()],
1007 ..Default::default()
1008 }];
1009 let (m, p) = detect_install(Path::new("/opt/homebrew/Caskroom/claude-code/2.1.285/claude"), &[]);
1010 assert_eq!((m.as_deref(), p.as_deref()), (Some("brew-cask"), Some("claude-code")));
1011 let (m, p) = detect_install(Path::new("/opt/homebrew/Cellar/opencode/1.18.18/bin/opencode"), &[]);
1012 assert_eq!((m.as_deref(), p.as_deref()), (Some("brew"), Some("opencode")));
1013 let (m, _) = detect_install(Path::new("/home/u/.local/share/cursor-agent/versions/x/cursor-agent"), &install);
1014 assert_eq!(m.as_deref(), Some("script"));
1015 assert_eq!(source_spec("npm", Some("@openai/codex"), Some("0.5.0")), "npm:@openai/codex@0.5.0");
1016 assert_eq!(source_spec("pip", Some("vllm"), Some("0.11.2")), "pypi:vllm==0.11.2");
1017 }
1018
1019 fn ctx_with(env: &[(&str, &str)]) -> Ctx {
1020 Ctx {
1021 home: PathBuf::from("/home/u"),
1022 cwd: PathBuf::from("/work"),
1023 env: env.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
1024 path: vec![],
1025 }
1026 }
1027
1028 #[test]
1029 fn config_roots_and_isolation() {
1030 let r = Registry::builtin();
1031 let claude = r.harness("claude-code").unwrap();
1032 let ctx = ctx_with(&[]);
1033 assert_eq!(claude.config_root(&ctx), PathBuf::from("/home/u/.claude"));
1034 assert_eq!(claude.file_path("state", &ctx).unwrap(), PathBuf::from("/home/u/.claude.json"));
1035 assert_eq!(claude.file_path("project-mcp", &ctx).unwrap(), PathBuf::from("/work/.mcp.json"));
1036 let iso = ctx_with(&[("CLAUDE_CONFIG_DIR", "/tmp/env/claude")]);
1037 assert_eq!(claude.file_path("state", &iso).unwrap(), PathBuf::from("/tmp/env/claude/.claude.json"));
1038
1039 let oc = r.harness("opencode").unwrap();
1040 let xdg = ctx_with(&[("XDG_CONFIG_HOME", "/tmp/env/xdg")]);
1041 assert_eq!(oc.config_root(&xdg), PathBuf::from("/tmp/env/xdg/opencode"));
1042 let (var, val) = oc.isolation_env(Path::new("/tmp/env/xdg/opencode")).unwrap();
1043 assert_eq!((var.as_str(), val), ("XDG_CONFIG_HOME", PathBuf::from("/tmp/env/xdg")));
1044 }
1045
1046 #[test]
1047 fn selection_precedence() {
1048 let r = Registry::builtin();
1049 let ctx = ctx_with(&[("CLAUDECODE", "1"), ("HLOCK_HARNESS", "pi")]);
1050 assert_eq!(r.select(Some("codex"), Some("opencode"), &ctx).unwrap().id, "codex");
1051 assert_eq!(r.select(None, Some("opencode"), &ctx).unwrap().id, "pi");
1052 let ctx = ctx_with(&[("CLAUDECODE", "1")]);
1053 assert_eq!(r.select(None, Some("opencode"), &ctx).unwrap().id, "opencode");
1054 let s = r.select(None, None, &ctx).unwrap();
1055 assert_eq!((s.id.as_str(), s.reason.as_str()), ("claude-code", "running inside it"));
1056 assert!(r.select(Some("nope"), None, &ctx).unwrap_err().to_string().contains("known:"));
1057 }
1058
1059 #[test]
1060 fn project_harnesses() {
1061 let m = crate::manifest::Manifest::from_str(
1062 r#"
1063[target]
1064harness = "triage"
1065
1066[harnesses.triage]
1067framework = "pydantic-ai"
1068entrypoint = "triage.agent:run"
1069
1070[harnesses.loop]
1071command = ["./bin/agent", "{prompt}"]
1072packages = ["pypi:httpx"]
1073"#,
1074 )
1075 .unwrap();
1076 let mut r = Registry::builtin();
1077 r.add_project_harnesses(&m).unwrap();
1078 let t = r.harness("triage").unwrap();
1079 assert_eq!(t.kind, "custom");
1080 assert_eq!(t.run.entrypoint.as_deref(), Some("triage.agent:run"));
1081 assert!(t.packages.contains(&"pypi:pydantic-ai-slim".to_string()));
1082 assert_eq!(r.harness("loop").unwrap().run.command[0], "./bin/agent");
1083 assert_eq!(r.select(None, m.target_harness().as_deref(), &ctx_with(&[])).unwrap().id, "triage");
1084
1085 let bad = crate::manifest::Manifest::from_str("[harnesses.x]\nframework = \"nope\"\nentrypoint = \"a:b\"\n").unwrap();
1086 assert!(Registry::builtin().add_project_harnesses(&bad).unwrap_err().to_string().contains("not a known framework"));
1087 let bare = crate::manifest::Manifest::from_str("[harnesses.x]\nframework = \"langgraph\"\n").unwrap();
1088 assert!(Registry::builtin().add_project_harnesses(&bare).unwrap_err().to_string().contains("entrypoint"));
1089 }
1090
1091 #[test]
1092 fn user_profiles_override_and_extend() {
1093 let dir = std::env::temp_dir().join(format!("hlock-prof-{}", std::process::id()));
1094 std::fs::create_dir_all(dir.join("harnesses")).unwrap();
1095 std::fs::write(dir.join("harnesses/my.toml"), "id = \"my-agent\"\nbinaries = [\"my-agent\"]\n").unwrap();
1096 std::fs::write(dir.join("harnesses/pi.toml"), "id = \"pi\"\nname = \"Pi (fork)\"\n").unwrap();
1097 let mut r = Registry::builtin();
1098 r.extend_from_dir(&dir).unwrap();
1099 assert_eq!(r.harness("my-agent").unwrap().support, "basic");
1100 assert_eq!(r.harness("pi").unwrap().name, "Pi (fork)");
1101 assert_eq!(r.harnesses.iter().filter(|h| h.id == "pi").count(), 1);
1102 let _ = std::fs::remove_dir_all(&dir);
1103 }
1104}