1use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::time::Duration;
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 ("openclaw", include_str!("../profiles/harnesses/openclaw.toml")),
37 ("nanobot", include_str!("../profiles/harnesses/nanobot.toml")),
38 ("picoclaw", include_str!("../profiles/harnesses/picoclaw.toml")),
39 ("zeroclaw", include_str!("../profiles/harnesses/zeroclaw.toml")),
40 ("ironclaw", include_str!("../profiles/harnesses/ironclaw.toml")),
41 ("pydantic-ai", include_str!("../profiles/harnesses/pydantic-ai.toml")),
43 ("deepagents", include_str!("../profiles/harnesses/deepagents.toml")),
44 ("langgraph", include_str!("../profiles/harnesses/langgraph.toml")),
45 ("openai-agents", include_str!("../profiles/harnesses/openai-agents.toml")),
46 ("claude-agent-sdk", include_str!("../profiles/harnesses/claude-agent-sdk.toml")),
47 ("google-adk", include_str!("../profiles/harnesses/google-adk.toml")),
48 ("smolagents", include_str!("../profiles/harnesses/smolagents.toml")),
49 ("crewai", include_str!("../profiles/harnesses/crewai.toml")),
50];
51
52const BUILTIN_RUNTIMES: &[(&str, &str)] = &[
53 ("ollama", include_str!("../profiles/runtimes/ollama.toml")),
54 ("lmstudio", include_str!("../profiles/runtimes/lmstudio.toml")),
55 ("llama-cpp", include_str!("../profiles/runtimes/llama-cpp.toml")),
56 ("vllm", include_str!("../profiles/runtimes/vllm.toml")),
57 ("sglang", include_str!("../profiles/runtimes/sglang.toml")),
58 ("mlx-lm", include_str!("../profiles/runtimes/mlx-lm.toml")),
59 ("localai", include_str!("../profiles/runtimes/localai.toml")),
60 ("docker-model-runner", include_str!("../profiles/runtimes/docker-model-runner.toml")),
61 ("jan", include_str!("../profiles/runtimes/jan.toml")),
62 ("ollaya", include_str!("../profiles/runtimes/ollaya.toml")),
63];
64
65#[derive(Debug, Clone)]
73pub struct Ctx {
74 pub home: PathBuf,
75 pub cwd: PathBuf,
76 pub env: BTreeMap<String, String>,
77 pub path: Vec<PathBuf>,
78}
79
80impl Ctx {
81 pub fn from_process() -> Ctx {
82 let mut env: BTreeMap<String, String> = std::env::vars().collect();
83 crate::meta::restore_env(&mut env);
84 let home = env.get("HOME").or_else(|| env.get("USERPROFILE")).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
85 let path = env.get("PATH").map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
86 Ctx {
87 home,
88 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
89 env,
90 path,
91 }
92 }
93
94 pub fn at(root: &Path) -> Ctx {
97 let mut c = Ctx::from_process();
98 c.cwd = if root.is_absolute() { root.to_path_buf() } else { c.cwd.join(root) };
99 c
100 }
101
102 pub fn var(&self, key: &str) -> Option<&str> {
104 self.env.get(key).map(String::as_str).filter(|v| !v.is_empty())
105 }
106
107 pub fn expand(&self, template: &str, root: Option<&Path>) -> PathBuf {
109 let mut s = template.to_string();
110 if let Some(r) = root {
111 s = s.replace("{root}", &r.to_string_lossy());
112 }
113 s = s.replace("{home}", &self.home.to_string_lossy());
114 if s == "~" {
115 return self.home.clone();
116 }
117 if let Some(rest) = s.strip_prefix("~/") {
118 return self.home.join(rest);
119 }
120 let p = PathBuf::from(&s);
121 if p.is_absolute() {
122 p
123 } else {
124 self.cwd.join(p)
125 }
126 }
127
128 pub fn which(&self, name: &str) -> Option<PathBuf> {
130 if name.contains('/') {
131 let p = PathBuf::from(name);
132 return p.is_file().then_some(p);
133 }
134 for dir in &self.path {
135 let cand = dir.join(name);
136 if cand.is_file() {
137 return Some(cand);
138 }
139 }
140 None
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149#[serde(default, rename_all = "kebab-case")]
150pub struct VersionProbe {
151 pub args: Vec<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156#[serde(default, rename_all = "kebab-case")]
157pub struct ConfigRoot {
158 pub env: Option<String>,
160 pub suffix: Option<String>,
162 pub default: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Default)]
166#[serde(default, rename_all = "kebab-case")]
167pub struct InstallMethod {
168 pub method: String,
170 pub package: Option<String>,
171 pub command: Option<String>,
173 pub path_hints: Vec<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, Default)]
178#[serde(default, rename_all = "kebab-case")]
179pub struct FileSpec {
180 pub path: String,
181 pub isolated: Option<String>,
183 pub format: String,
185 pub restore: String,
187 pub pointers: Vec<String>,
188 pub env_ref: Option<String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, Default)]
196#[serde(default, rename_all = "kebab-case")]
197pub struct ModelSpec {
198 pub default: Vec<String>,
200 pub small: Vec<String>,
201 pub provider: Vec<String>,
202 pub base_url: Vec<String>,
203 pub format: Option<String>,
205 pub default_provider: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, Default)]
209#[serde(default, rename_all = "kebab-case")]
210pub struct ProviderSpec {
211 pub source: Vec<String>,
212 pub base_url: Vec<String>,
214 pub models: Vec<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, Default)]
218#[serde(default, rename_all = "kebab-case")]
219pub struct McpSpec {
220 pub sources: Vec<String>,
221 pub write: Option<String>,
222 pub shape: Option<String>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, Default)]
228#[serde(default, rename_all = "kebab-case")]
229pub struct SkillsSpec {
230 pub dirs: Vec<String>,
231 pub write: Option<String>,
232 pub builtin_dirs: Vec<String>,
234 pub builtin_manifest: Option<String>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, Default)]
239#[serde(default, rename_all = "kebab-case")]
240pub struct BundleSpec {
241 pub kind: String,
243 pub extractor: String,
245 pub source: String,
246 pub lockfile: Option<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, Default)]
250#[serde(default, rename_all = "kebab-case")]
251pub struct LaunchSpec {
252 pub args: Vec<String>,
253 pub model_flag: Option<String>,
254 pub prompt: Vec<String>,
258 pub interactive: Vec<String>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267#[serde(default, rename_all = "kebab-case")]
268pub struct LocalProvider {
269 pub file: String,
270 pub set: BTreeMap<String, serde_json::Value>,
271 pub runtimes: Vec<String>,
274}
275
276impl LocalProvider {
277 pub fn applies_to(&self, runtime: Option<&str>) -> bool {
279 self.runtimes.is_empty() || runtime.is_some_and(|rt| self.runtimes.iter().any(|r| r == rt))
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, Default)]
285#[serde(default, rename_all = "kebab-case")]
286pub struct ApiKey {
287 pub provider: String,
288 pub env: String,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, Default)]
293#[serde(default, rename_all = "kebab-case")]
294pub struct CatalogModel {
295 pub id: String,
296 pub params_b: Option<f64>,
297 pub min_memory_gb: Option<f64>,
299 pub tier: u8,
301 pub tools: bool,
303 pub roles: Vec<String>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, Default)]
309#[serde(default, rename_all = "kebab-case")]
310pub struct RunSpec {
311 pub entrypoint: Option<String>,
313 pub command: Vec<String>,
315 pub python: Option<String>,
317 pub env: BTreeMap<String, String>,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
322#[serde(default, rename_all = "kebab-case")]
323pub struct DeciderWiringSpec {
324 pub hook: Option<HookSpec>,
327 pub native: Option<NativeDeciderSpec>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
333#[serde(default, rename_all = "kebab-case")]
334pub struct HookSpec {
335 pub file: String,
337 pub path: Option<String>,
341 pub pointer: Option<String>,
342 pub events: Vec<String>,
343 pub format: String,
345}
346
347impl HookSpec {
348 pub fn target(&self) -> String {
350 self.path.clone().unwrap_or_else(|| self.file.clone())
351 }
352
353 pub fn owned(&self) -> bool {
355 self.path.is_some() && matches!(self.format.as_str(), "copilot" | "amp" | "opencode" | "pi")
356 }
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
360#[serde(default, rename_all = "kebab-case")]
361pub struct NativeDeciderSpec {
362 pub file: String,
363 pub pointer: String,
364 pub provider_key: Option<String>,
365 pub model_key: String,
366 pub base_url_key: Option<String>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize, Default)]
371#[serde(default, rename_all = "kebab-case")]
372pub struct HarnessProfile {
373 pub id: String,
374 pub name: String,
375 pub kind: String,
378 pub autonomous: bool,
382 pub aliases: Vec<String>,
383 pub vendor: Option<String>,
384 pub homepage: Option<String>,
385 pub support: String,
387 pub open_source: Option<bool>,
389 pub local_models: Option<bool>,
391 pub local_provider: Vec<LocalProvider>,
393 pub api_keys: Vec<ApiKey>,
395 pub api_provider: Vec<LocalProvider>,
398 pub framework: Option<String>,
400 pub packages: Vec<String>,
402 pub run: RunSpec,
404 pub binaries: Vec<String>,
405 pub version: VersionProbe,
406 pub active_env: Vec<String>,
408 pub config_root: ConfigRoot,
409 pub instructions: Vec<String>,
410 pub install: Vec<InstallMethod>,
411 pub files: BTreeMap<String, FileSpec>,
412 pub model: ModelSpec,
413 pub providers: Option<ProviderSpec>,
414 pub mcp: McpSpec,
415 pub skills: SkillsSpec,
416 pub bundles: Vec<BundleSpec>,
417 pub launch: LaunchSpec,
418 pub deciders: BTreeMap<String, DeciderWiringSpec>,
420 pub decider_env: Vec<(String, String)>,
423 #[serde(skip_deserializing)]
425 pub origin: String,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, Default)]
429#[serde(default, rename_all = "kebab-case")]
430pub struct RuntimeModels {
431 pub layout: String,
433 pub dirs: Vec<String>,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, Default)]
439#[serde(default, rename_all = "kebab-case")]
440pub struct RuntimeProfile {
441 pub id: String,
442 pub name: String,
443 pub aliases: Vec<String>,
444 pub homepage: Option<String>,
445 pub binaries: Vec<String>,
446 pub version: VersionProbe,
447 pub ports: Vec<u16>,
449 pub provider_names: Vec<String>,
451 pub pull: Option<String>,
453 pub serve: Option<String>,
454 pub models: RuntimeModels,
455 pub install: Vec<InstallMethod>,
456 pub catalog: Vec<CatalogModel>,
458 #[serde(skip_deserializing)]
459 pub origin: String,
460}
461
462impl HarnessProfile {
463 pub fn matches(&self, name: &str) -> bool {
464 let n = name.to_ascii_lowercase();
465 self.id == n || self.aliases.iter().any(|a| a.to_ascii_lowercase() == n)
466 }
467
468 pub fn config_root(&self, ctx: &Ctx) -> PathBuf {
470 let cr = &self.config_root;
471 if let Some(var) = &cr.env {
472 if let Some(v) = ctx.var(var) {
473 let base = ctx.expand(v, None);
474 return match &cr.suffix {
475 Some(sfx) => base.join(sfx),
476 None => base,
477 };
478 }
479 }
480 ctx.expand(&cr.default, None)
481 }
482
483 pub fn root_relocated(&self, ctx: &Ctx) -> bool {
485 self.config_root.env.as_deref().and_then(|v| ctx.var(v)).is_some()
486 }
487
488 pub fn file_path(&self, id: &str, ctx: &Ctx) -> Option<PathBuf> {
490 let f = self.files.get(id)?;
491 let root = self.config_root(ctx);
492 let tpl = match (&f.isolated, self.root_relocated(ctx)) {
493 (Some(iso), true) => iso,
494 _ => &f.path,
495 };
496 Some(ctx.expand(tpl, Some(&root)))
497 }
498
499 pub fn isolation_env(&self, dir: &Path) -> Option<(String, PathBuf)> {
502 let var = self.config_root.env.clone()?;
503 let value = match &self.config_root.suffix {
504 Some(_) => dir.parent().map(Path::to_path_buf).unwrap_or_else(|| dir.to_path_buf()),
506 None => dir.to_path_buf(),
507 };
508 Some((var, value))
509 }
510
511 pub fn is_active(&self, ctx: &Ctx) -> bool {
513 self.active_env.iter().any(|v| ctx.var(v).is_some())
514 }
515}
516
517impl RuntimeProfile {
518 pub fn matches(&self, name: &str) -> bool {
519 let n = name.to_ascii_lowercase();
520 self.id == n || self.aliases.iter().any(|a| a.to_ascii_lowercase() == n)
521 }
522
523 pub fn models_dir(&self, ctx: &Ctx) -> Option<PathBuf> {
525 let mut first = None;
526 for d in &self.models.dirs {
527 let p = match d.strip_prefix("env:") {
528 Some(var) => match ctx.var(var) {
529 Some(v) => ctx.expand(v, None),
530 None => continue,
531 },
532 None => ctx.expand(d, None),
533 };
534 if p.is_dir() {
535 return Some(p);
536 }
537 first.get_or_insert(p);
538 }
539 first
540 }
541}
542
543#[derive(Debug, Clone, Default)]
549pub struct Registry {
550 pub harnesses: Vec<HarnessProfile>,
551 pub runtimes: Vec<RuntimeProfile>,
552 pub notes: Vec<String>,
554}
555
556pub fn profile_dirs(ctx: &Ctx) -> Vec<PathBuf> {
562 let mut dirs = vec![ctx.expand("~/.config/hlock/profiles", None)];
563 if let Some(p) = ctx.var("HLOCK_PROFILE_PATH") {
564 dirs.extend(std::env::split_paths(p));
565 }
566 dirs.push(project_profile_dir(ctx));
567 dirs
568}
569
570pub fn project_profile_dir(ctx: &Ctx) -> PathBuf {
573 ctx.cwd.join(".hlock/profiles")
574}
575
576const VERSION_FLAGS: &[&str] = &["--version", "-version", "-V", "-v", "version"];
578
579fn confine(notes: &mut Vec<String>, origin: &Path, binaries: &mut Vec<String>, version: &mut VersionProbe, python: Option<&mut Option<String>>) {
582 let by_path = |b: &str| b.contains(['/', '\\']);
583 for b in binaries.iter().filter(|b| by_path(b)) {
584 notes.push(format!("{}: ignored binary {b:?}; a project profile names programs on PATH, not by path", origin.display()));
585 }
586 binaries.retain(|b| !by_path(b));
587 if !version.args.iter().all(|a| VERSION_FLAGS.contains(&a.as_str())) {
588 notes.push(format!("{}: ignored version args {:?}; a project profile may only probe with a version flag", origin.display(), version.args));
589 version.args.clear();
590 }
591 if let Some(py) = python {
592 if py.as_deref().is_some_and(by_path) {
593 notes.push(format!("{}: ignored python {:?}; a project profile names an interpreter on PATH, not by path", origin.display(), py.as_deref().unwrap_or_default()));
594 *py = None;
595 }
596 }
597}
598
599pub fn parse_harness(text: &str, origin: &str) -> Result<HarnessProfile> {
600 let mut p: HarnessProfile =
601 toml::from_str(text).map_err(|e| Error::Profile(format!("invalid harness profile {origin}: {e}")))?;
602 if p.id.is_empty() {
603 return Err(Error::Profile(format!("harness profile {origin} has no id")));
604 }
605 if p.name.is_empty() {
606 p.name = p.id.clone();
607 }
608 if p.support.is_empty() {
609 p.support = "basic".into();
610 }
611 if p.kind.is_empty() {
612 p.kind = if p.run.entrypoint.is_some() || !p.run.command.is_empty() { "custom" } else { "cli" }.into();
613 }
614 p.origin = origin.to_string();
615 Ok(p)
616}
617
618pub fn parse_runtime(text: &str, origin: &str) -> Result<RuntimeProfile> {
619 let mut p: RuntimeProfile =
620 toml::from_str(text).map_err(|e| Error::Profile(format!("invalid runtime profile {origin}: {e}")))?;
621 if p.id.is_empty() {
622 return Err(Error::Profile(format!("runtime profile {origin} has no id")));
623 }
624 if p.name.is_empty() {
625 p.name = p.id.clone();
626 }
627 p.origin = origin.to_string();
628 Ok(p)
629}
630
631impl Registry {
632 pub fn builtin() -> Registry {
634 let harnesses = BUILTIN_HARNESSES
635 .iter()
636 .map(|(id, text)| parse_harness(text, "builtin").unwrap_or_else(|e| panic!("builtin profile {id}: {e}")))
637 .collect();
638 let runtimes = BUILTIN_RUNTIMES
639 .iter()
640 .map(|(id, text)| parse_runtime(text, "builtin").unwrap_or_else(|e| panic!("builtin profile {id}: {e}")))
641 .collect();
642 Registry { harnesses, runtimes, notes: Vec::new() }
643 }
644
645 pub fn load(ctx: &Ctx) -> Result<Registry> {
647 let mut reg = Registry::builtin();
648 let project = project_profile_dir(ctx);
649 for dir in profile_dirs(ctx) {
650 if dir == project {
651 reg.extend_from_project_dir(&dir)?;
652 } else {
653 reg.extend_from_dir(&dir)?;
654 }
655 }
656 Ok(reg)
657 }
658
659 pub fn extend_from_project_dir(&mut self, dir: &Path) -> Result<()> {
664 let taken = |notes: &mut Vec<String>, path: &Path, kind: &str, id: &str| {
665 notes.push(format!("{}: skipped; a project profile can't replace the {kind} profile {id:?} (override it in ~/.config/hlock/profiles)", path.display()));
666 };
667 for path in toml_files(&dir.join("harnesses")) {
668 let text = std::fs::read_to_string(&path)?;
669 let mut p = parse_harness(&text, &path.to_string_lossy())?;
670 if self.harnesses.iter().any(|h| h.id == p.id) {
671 taken(&mut self.notes, &path, "harness", &p.id);
672 continue;
673 }
674 confine(&mut self.notes, &path, &mut p.binaries, &mut p.version, Some(&mut p.run.python));
675 self.harnesses.push(p);
676 }
677 for path in toml_files(&dir.join("runtimes")) {
678 let text = std::fs::read_to_string(&path)?;
679 let mut p = parse_runtime(&text, &path.to_string_lossy())?;
680 if self.runtimes.iter().any(|r| r.id == p.id) {
681 taken(&mut self.notes, &path, "runtime", &p.id);
682 continue;
683 }
684 confine(&mut self.notes, &path, &mut p.binaries, &mut p.version, None);
685 self.runtimes.push(p);
686 }
687 Ok(())
688 }
689
690 pub fn extend_from_dir(&mut self, dir: &Path) -> Result<()> {
692 for path in toml_files(&dir.join("harnesses")) {
693 let text = std::fs::read_to_string(&path)?;
694 let p = parse_harness(&text, &path.to_string_lossy())?;
695 self.harnesses.retain(|h| h.id != p.id);
696 self.harnesses.push(p);
697 }
698 for path in toml_files(&dir.join("runtimes")) {
699 let text = std::fs::read_to_string(&path)?;
700 let p = parse_runtime(&text, &path.to_string_lossy())?;
701 self.runtimes.retain(|r| r.id != p.id);
702 self.runtimes.push(p);
703 }
704 Ok(())
705 }
706
707 pub fn load_for(ctx: &Ctx, manifest: Option<&crate::manifest::Manifest>) -> Result<Registry> {
709 let mut reg = Registry::load(ctx)?;
710 if let Some(m) = manifest {
711 reg.add_project_harnesses(m)?;
712 }
713 Ok(reg)
714 }
715
716 pub fn add_project_harnesses(&mut self, m: &crate::manifest::Manifest) -> Result<()> {
719 let origin = m
720 .source_path
721 .as_ref()
722 .map(|p| p.to_string_lossy().into_owned())
723 .unwrap_or_else(|| crate::MANIFEST_PATH.to_string());
724 for (id, table) in &m.harnesses {
725 let mut v = table.clone();
726 if let Some(obj) = v.as_object_mut() {
727 obj.entry("id").or_insert_with(|| serde_json::json!(id));
728 let mut run = obj.remove("run").and_then(|r| r.as_object().cloned()).unwrap_or_default();
730 for key in ["entrypoint", "command", "python", "env"] {
731 if let Some(val) = obj.remove(key) {
732 run.insert(key.to_string(), val);
733 }
734 }
735 obj.insert("run".into(), serde_json::Value::Object(run));
736 }
737 let mut p: HarnessProfile = serde_json::from_value(v)
738 .map_err(|e| Error::Profile(format!("invalid [harnesses.{id}] in {origin}: {e}")))?;
739 if p.name.is_empty() {
740 p.name = id.clone();
741 }
742 if p.kind.is_empty() {
743 p.kind = "custom".into();
744 }
745 if p.support.is_empty() {
746 p.support = "basic".into();
747 }
748 if let Some(fw) = &p.framework {
749 let base = self
750 .harness(fw)
751 .filter(|h| h.kind == "framework")
752 .ok_or_else(|| Error::Profile(format!("[harnesses.{id}] framework {fw:?} is not a known framework")))?;
753 for pkg in &base.packages {
754 if !p.packages.contains(pkg) {
755 p.packages.push(pkg.clone());
756 }
757 }
758 if p.homepage.is_none() {
759 p.homepage = base.homepage.clone();
760 }
761 if p.decider_env.is_empty() {
762 p.decider_env = base.decider_env.clone();
763 }
764 }
765 if p.kind == "custom" && p.run.entrypoint.is_none() && p.run.command.is_empty() {
766 return Err(Error::Profile(format!(
767 "[harnesses.{id}] needs an `entrypoint` (python module:callable) or a `command`"
768 )));
769 }
770 p.origin = origin.clone();
771 self.harnesses.retain(|h| h.id != p.id);
772 self.harnesses.push(p);
773 }
774 Ok(())
775 }
776
777 pub fn harness(&self, name: &str) -> Option<&HarnessProfile> {
778 self.harnesses.iter().find(|h| h.matches(name))
779 }
780
781 pub fn runtime(&self, name: &str) -> Option<&RuntimeProfile> {
782 self.runtimes.iter().find(|r| r.matches(name))
783 }
784
785 pub fn require_harness(&self, name: &str) -> Result<&HarnessProfile> {
787 self.harness(name).ok_or_else(|| {
788 let known: Vec<&str> = self.harnesses.iter().map(|h| h.id.as_str()).collect();
789 Error::Profile(format!("unknown harness {name:?} (known: {})", known.join(", ")))
790 })
791 }
792
793 pub fn active(&self, ctx: &Ctx) -> Option<&HarnessProfile> {
795 self.harnesses.iter().find(|h| h.is_active(ctx))
796 }
797
798 pub fn select(&self, explicit: Option<&str>, manifest_target: Option<&str>, ctx: &Ctx) -> Result<Selection> {
802 let named = explicit
803 .map(|n| (n.to_string(), "--harness"))
804 .or_else(|| ctx.var("HLOCK_HARNESS").map(|n| (n.to_string(), "HLOCK_HARNESS")))
805 .or_else(|| manifest_target.map(|n| (n.to_string(), "harness.toml [target]")));
806 if let Some((name, why)) = named {
807 let p = self.require_harness(&name)?;
808 return Ok(Selection { id: p.id.clone(), reason: why.to_string() });
809 }
810 if let Some(p) = self.active(ctx) {
811 return Ok(Selection { id: p.id.clone(), reason: "running inside it".into() });
812 }
813 let installed: Vec<&HarnessProfile> = self
814 .harnesses
815 .iter()
816 .filter(|h| h.kind == "cli" && h.binaries.iter().any(|b| ctx.which(b).is_some()))
817 .collect();
818 match installed.as_slice() {
819 [one] => Ok(Selection { id: one.id.clone(), reason: "the only installed harness".into() }),
820 [] => Err(Error::Profile("no supported harness found on PATH".into())),
821 many => Err(Error::Profile(format!(
822 "several harnesses are installed ({}); pick one with --harness",
823 many.iter().map(|h| h.id.as_str()).collect::<Vec<_>>().join(", ")
824 ))),
825 }
826 }
827}
828
829#[derive(Debug, Clone, Serialize)]
831pub struct Selection {
832 pub id: String,
833 pub reason: String,
834}
835
836fn toml_files(dir: &Path) -> Vec<PathBuf> {
837 let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
838 .map(|rd| {
839 rd.flatten()
840 .map(|e| e.path())
841 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
842 .collect()
843 })
844 .unwrap_or_default();
845 out.sort();
846 out
847}
848
849#[derive(Debug, Clone, Serialize, Default)]
855pub struct Probe {
856 pub id: String,
857 pub installed: bool,
858 pub binary: Option<String>,
859 pub version: Option<String>,
860 pub method: Option<String>,
862 pub source: Option<String>,
864 pub config_root: Option<String>,
865 pub active: bool,
866 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
868 pub packages: BTreeMap<String, String>,
869}
870
871const VERSION_TIMEOUT: Duration = Duration::from_secs(8);
872
873pub fn probe_harness(p: &HarnessProfile, ctx: &Ctx) -> Probe {
874 if p.kind != "cli" {
875 return probe_code_harness(p, ctx);
876 }
877 let mut out = probe_binary(&p.id, &p.binaries, &p.version, &p.install, ctx);
878 out.config_root = Some(p.config_root(ctx).to_string_lossy().into_owned());
879 out.active = p.is_active(ctx);
880 out
881}
882
883fn probe_code_harness(p: &HarnessProfile, ctx: &Ctx) -> Probe {
886 let mut out = Probe { id: p.id.clone(), active: p.is_active(ctx), ..Default::default() };
887 out.packages = python_package_versions(&p.packages, p.run.python.as_deref(), ctx);
888 let first = p.packages.iter().find_map(|pkg| out.packages.get(pkg).map(|v| (pkg, v)));
889 if let Some((pkg, v)) = first {
890 out.version = Some(v.clone());
891 out.method = Some("pip".into());
892 out.source = Some(format!("pypi:{}=={v}", pkg.trim_start_matches("pypi:")));
893 }
894 out.installed = match (p.kind.as_str(), p.run.command.first()) {
895 ("custom", Some(cmd)) => {
896 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());
897 out.binary.is_some()
898 }
899 ("custom", None) => p.run.entrypoint.is_some(),
900 _ => first.is_some(),
901 };
902 out
903}
904
905pub fn python_for(explicit: Option<&str>, ctx: &Ctx) -> Option<PathBuf> {
907 let name = explicit.map(str::to_string).or_else(|| ctx.var("HL_PYTHON").map(str::to_string)).unwrap_or_else(|| "python3".into());
908 ctx.which(&name)
909}
910
911pub fn python_package_versions(packages: &[String], python: Option<&str>, ctx: &Ctx) -> BTreeMap<String, String> {
913 let names: Vec<&str> = packages.iter().filter_map(|p| p.strip_prefix("pypi:")).collect();
914 let mut out = BTreeMap::new();
915 if names.is_empty() {
916 return out;
917 }
918 let Some(py) = python_for(python, ctx) else { return out };
919 let script = format!(
920 "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))"
921 );
922 let Some(text) = run_capture(&py, &["-c".to_string(), script], ctx, VERSION_TIMEOUT) else { return out };
923 let Some(line) = text.lines().find(|l| l.trim_start().starts_with('{')) else { return out };
924 if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(line) {
925 for (k, v) in map {
926 if let Some(v) = v.as_str() {
927 out.insert(format!("pypi:{k}"), v.to_string());
928 }
929 }
930 }
931 out
932}
933
934pub fn probe_runtime(p: &RuntimeProfile, ctx: &Ctx) -> Probe {
935 probe_binary(&p.id, &p.binaries, &p.version, &p.install, ctx)
936}
937
938fn probe_binary(id: &str, binaries: &[String], version: &VersionProbe, install: &[InstallMethod], ctx: &Ctx) -> Probe {
939 let mut out = Probe { id: id.to_string(), ..Default::default() };
940 let Some(bin) = binaries.iter().find_map(|b| ctx.which(b)) else {
941 return out;
942 };
943 out.installed = true;
944 out.binary = Some(bin.to_string_lossy().into_owned());
945 if !version.args.is_empty() {
946 out.version = run_capture(&bin, &version.args, ctx, VERSION_TIMEOUT).and_then(|t| extract_version(&t));
947 }
948 let real = std::fs::canonicalize(&bin).unwrap_or(bin.clone());
949 let (method, package) = detect_install(&real, install);
950 out.source = method.as_deref().map(|m| source_spec(m, package.as_deref(), out.version.as_deref()));
951 out.method = method;
952 out
953}
954
955pub fn source_spec(method: &str, package: Option<&str>, version: Option<&str>) -> String {
957 match (method, package, version) {
958 ("pip", Some(p), Some(v)) => format!("pypi:{p}=={v}"),
959 ("uv-tool", Some(p), Some(v)) => format!("pypi:{p}=={v}"),
960 (m, Some(p), Some(v)) => format!("{m}:{p}@{v}"),
961 (m, Some(p), None) => format!("{m}:{p}"),
962 (m, None, Some(v)) => format!("{m}@{v}"),
963 (m, None, None) => m.to_string(),
964 }
965}
966
967pub fn detect_install(real: &Path, install: &[InstallMethod]) -> (Option<String>, Option<String>) {
969 let s = real.to_string_lossy().replace('\\', "/");
970 if s.contains("/node_modules/") {
971 let pkg = npm_package_name(real).or_else(|| find_method(install, "npm").and_then(|m| m.package.clone()));
972 return (Some("npm".into()), pkg);
973 }
974 if let Some(rest) = s.split("/Caskroom/").nth(1) {
975 return (Some("brew-cask".into()), rest.split('/').next().map(str::to_string));
976 }
977 if let Some(rest) = s.split("/Cellar/").nth(1) {
978 return (Some("brew".into()), rest.split('/').next().map(str::to_string));
979 }
980 if s.contains("/uv/tools/") {
981 let pkg = s.split("/uv/tools/").nth(1).and_then(|r| r.split('/').next()).map(str::to_string);
982 return (Some("uv-tool".into()), pkg);
983 }
984 if s.contains("/site-packages/") || s.contains("/pipx/venvs/") {
985 return (Some("pip".into()), find_method(install, "pip").and_then(|m| m.package.clone()));
986 }
987 for m in install {
988 if m.path_hints.iter().any(|h| s.contains(h.as_str())) {
989 return (Some(m.method.clone()), m.package.clone());
990 }
991 }
992 (None, None)
993}
994
995fn find_method<'a>(install: &'a [InstallMethod], method: &str) -> Option<&'a InstallMethod> {
996 install.iter().find(|m| m.method == method)
997}
998
999fn npm_package_name(file: &Path) -> Option<String> {
1001 let mut dir = file.parent();
1002 while let Some(d) = dir {
1003 let pj = d.join("package.json");
1004 if pj.is_file() {
1005 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&pj).ok()?).ok()?;
1006 if let Some(name) = v.get("name").and_then(|n| n.as_str()) {
1007 return Some(name.to_string());
1008 }
1009 }
1010 if d.file_name().is_some_and(|n| n == "node_modules") {
1011 break;
1012 }
1013 dir = d.parent();
1014 }
1015 None
1016}
1017
1018pub fn run_capture(bin: &Path, args: &[String], ctx: &Ctx, timeout: Duration) -> Option<String> {
1021 let mut cmd = Command::new(bin);
1022 cmd.args(args).current_dir(&ctx.cwd).env_clear().envs(&ctx.env).stdin(Stdio::null());
1023 let out = crate::process::run(cmd, &crate::process::Limits::timeout(timeout)).ok()?;
1024 if !out.success {
1025 return None;
1026 }
1027 let mut text = out.stdout_text();
1028 text.push('\n');
1029 text.push_str(&out.stderr_text());
1030 Some(text)
1031}
1032
1033pub fn extract_version(text: &str) -> Option<String> {
1036 let clean = strip_ansi(text);
1037 let b = clean.as_bytes();
1038 let mut i = 0;
1039 while i < b.len() {
1040 let boundary = i == 0
1041 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'.')
1042 || (matches!(b[i - 1], b'v' | b'V') && (i == 1 || !b[i - 2].is_ascii_alphanumeric()));
1043 if b[i].is_ascii_digit() && boundary {
1044 let start = i;
1045 let mut dots = 0;
1046 while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && i + 1 < b.len() && b[i + 1].is_ascii_digit())) {
1047 if b[i] == b'.' {
1048 dots += 1;
1049 }
1050 i += 1;
1051 }
1052 if dots >= 1 {
1053 if i < b.len() && (b[i] == b'-' || b[i] == b'+') && i + 1 < b.len() && b[i + 1].is_ascii_alphanumeric() {
1055 i += 1;
1056 while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') {
1057 i += 1;
1058 }
1059 }
1060 return Some(clean[start..i].trim_end_matches('.').to_string());
1061 }
1062 }
1063 i += 1;
1064 }
1065 None
1066}
1067
1068fn strip_ansi(s: &str) -> String {
1069 let mut out = String::with_capacity(s.len());
1070 let mut chars = s.chars().peekable();
1071 while let Some(c) = chars.next() {
1072 if c == '\u{1b}' {
1073 if chars.peek() == Some(&'[') {
1074 chars.next();
1075 for n in chars.by_ref() {
1076 if n.is_ascii_alphabetic() {
1077 break;
1078 }
1079 }
1080 }
1081 continue;
1082 }
1083 out.push(c);
1084 }
1085 out
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091
1092 #[test]
1093 fn builtins_parse() {
1094 let r = Registry::builtin();
1095 assert!(r.harnesses.len() >= 10);
1096 for id in ["claude-code", "codex", "pi", "opencode", "hermes", "cursor-agent"] {
1097 assert_eq!(r.harness(id).unwrap().support, "full", "{id}");
1098 }
1099 for h in &r.harnesses {
1100 let id = &h.id;
1101 for (fid, f) in &h.files {
1102 assert!(["json", "jsonc", "json5", "toml", "yaml"].contains(&f.format.as_str()), "{id}.{fid}");
1103 assert!(["whole", "pointers", "record"].contains(&f.restore.as_str()), "{id}.{fid}");
1104 }
1105 let refs = h.model.default.iter().chain(&h.model.small).chain(&h.model.provider).chain(&h.model.base_url).chain(&h.mcp.sources).chain(&h.mcp.write);
1107 for r in refs {
1108 if let Some((file, _)) = r.split_once('#') {
1109 assert!(h.files.contains_key(file), "{id}: {r}");
1110 }
1111 }
1112 for lp in &h.local_provider {
1113 assert!(h.files.contains_key(&lp.file), "{id}: local-provider file {}", lp.file);
1114 assert!(lp.runtimes.iter().all(|rt| r.runtime(rt).is_some()), "{id}: {:?}", lp.runtimes);
1115 }
1116 }
1117 for id in ["hermes", "openclaw", "nanobot", "picoclaw", "zeroclaw", "ironclaw"] {
1119 let h = r.harness(id).unwrap();
1120 assert!(h.autonomous && h.kind == "cli" && h.open_source == Some(true), "{id}");
1121 }
1122 assert_eq!(r.harness("clawdbot").unwrap().id, "openclaw");
1123 assert!(!r.harness("claude-code").unwrap().autonomous);
1124 for fw in ["pydantic-ai", "deepagents", "langgraph", "openai-agents", "claude-agent-sdk"] {
1125 let h = r.harness(fw).unwrap();
1126 assert_eq!(h.kind, "framework", "{fw}");
1127 assert!(h.packages.iter().all(|p| p.starts_with("pypi:")), "{fw}");
1128 }
1129 assert!(r.harnesses.iter().filter(|h| h.kind == "cli").count() >= 10);
1130 for h in r.harnesses.iter().filter(|h| h.kind == "cli") {
1132 assert!(h.launch.prompt.iter().any(|a| a.contains("{prompt}")), "{}", h.id);
1133 }
1134 assert!(r.runtime("ollama").is_some());
1135 assert_eq!(r.harness("claude").unwrap().id, "claude-code");
1136 assert_eq!(r.runtime("lms").unwrap().id, "lmstudio");
1137 }
1138
1139 #[test]
1140 fn versions() {
1141 assert_eq!(extract_version("2.1.285 (Claude Code)").as_deref(), Some("2.1.285"));
1142 assert_eq!(extract_version("Hermes Agent v0.20.0 (2026.8.3)").as_deref(), Some("0.20.0"));
1143 assert_eq!(extract_version("2026.05.28-a70ca7c").as_deref(), Some("2026.05.28-a70ca7c"));
1144 assert_eq!(extract_version("Warning: client version is 0.35.1").as_deref(), Some("0.35.1"));
1145 assert_eq!(extract_version("\u{1b}[38;5;166m art \u{1b}[0m\nCLI commit: 1.2.3").as_deref(), Some("1.2.3"));
1146 assert_eq!(extract_version("no version"), None);
1147 }
1148
1149 #[test]
1150 fn install_detection() {
1151 let install = vec![InstallMethod {
1152 method: "script".into(),
1153 path_hints: vec!["/.local/share/cursor-agent/".into()],
1154 ..Default::default()
1155 }];
1156 let (m, p) = detect_install(Path::new("/opt/homebrew/Caskroom/claude-code/2.1.285/claude"), &[]);
1157 assert_eq!((m.as_deref(), p.as_deref()), (Some("brew-cask"), Some("claude-code")));
1158 let (m, p) = detect_install(Path::new("/opt/homebrew/Cellar/opencode/1.18.18/bin/opencode"), &[]);
1159 assert_eq!((m.as_deref(), p.as_deref()), (Some("brew"), Some("opencode")));
1160 let (m, _) = detect_install(Path::new("/home/u/.local/share/cursor-agent/versions/x/cursor-agent"), &install);
1161 assert_eq!(m.as_deref(), Some("script"));
1162 assert_eq!(source_spec("npm", Some("@openai/codex"), Some("0.5.0")), "npm:@openai/codex@0.5.0");
1163 assert_eq!(source_spec("pip", Some("vllm"), Some("0.11.2")), "pypi:vllm==0.11.2");
1164 }
1165
1166 fn ctx_with(env: &[(&str, &str)]) -> Ctx {
1167 Ctx {
1168 home: PathBuf::from("/home/u"),
1169 cwd: PathBuf::from("/work"),
1170 env: env.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
1171 path: vec![],
1172 }
1173 }
1174
1175 #[test]
1176 fn config_roots_and_isolation() {
1177 let r = Registry::builtin();
1178 let claude = r.harness("claude-code").unwrap();
1179 let ctx = ctx_with(&[]);
1180 assert_eq!(claude.config_root(&ctx), PathBuf::from("/home/u/.claude"));
1181 assert_eq!(claude.file_path("state", &ctx).unwrap(), PathBuf::from("/home/u/.claude.json"));
1182 assert_eq!(claude.file_path("project-mcp", &ctx).unwrap(), PathBuf::from("/work/.mcp.json"));
1183 let iso = ctx_with(&[("CLAUDE_CONFIG_DIR", "/tmp/env/claude")]);
1184 assert_eq!(claude.file_path("state", &iso).unwrap(), PathBuf::from("/tmp/env/claude/.claude.json"));
1185
1186 let oc = r.harness("opencode").unwrap();
1187 let xdg = ctx_with(&[("XDG_CONFIG_HOME", "/tmp/env/xdg")]);
1188 assert_eq!(oc.config_root(&xdg), PathBuf::from("/tmp/env/xdg/opencode"));
1189 let (var, val) = oc.isolation_env(Path::new("/tmp/env/xdg/opencode")).unwrap();
1190 assert_eq!((var.as_str(), val), ("XDG_CONFIG_HOME", PathBuf::from("/tmp/env/xdg")));
1191 }
1192
1193 #[test]
1194 fn selection_precedence() {
1195 let r = Registry::builtin();
1196 let ctx = ctx_with(&[("CLAUDECODE", "1"), ("HLOCK_HARNESS", "pi")]);
1197 assert_eq!(r.select(Some("codex"), Some("opencode"), &ctx).unwrap().id, "codex");
1198 assert_eq!(r.select(None, Some("opencode"), &ctx).unwrap().id, "pi");
1199 let ctx = ctx_with(&[("CLAUDECODE", "1")]);
1200 assert_eq!(r.select(None, Some("opencode"), &ctx).unwrap().id, "opencode");
1201 let s = r.select(None, None, &ctx).unwrap();
1202 assert_eq!((s.id.as_str(), s.reason.as_str()), ("claude-code", "running inside it"));
1203 assert!(r.select(Some("nope"), None, &ctx).unwrap_err().to_string().contains("known:"));
1204 }
1205
1206 #[test]
1207 fn project_harnesses() {
1208 let m = crate::manifest::Manifest::from_str(
1209 r#"
1210[target]
1211harness = "triage"
1212
1213[harnesses.triage]
1214framework = "pydantic-ai"
1215entrypoint = "triage.agent:run"
1216
1217[harnesses.loop]
1218command = ["./bin/agent", "{prompt}"]
1219packages = ["pypi:httpx"]
1220"#,
1221 )
1222 .unwrap();
1223 let mut r = Registry::builtin();
1224 r.add_project_harnesses(&m).unwrap();
1225 let t = r.harness("triage").unwrap();
1226 assert_eq!(t.kind, "custom");
1227 assert_eq!(t.run.entrypoint.as_deref(), Some("triage.agent:run"));
1228 assert!(t.packages.contains(&"pypi:pydantic-ai-slim".to_string()));
1229 assert_eq!(r.harness("loop").unwrap().run.command[0], "./bin/agent");
1230 assert_eq!(r.select(None, m.target_harness().as_deref(), &ctx_with(&[])).unwrap().id, "triage");
1231
1232 let bad = crate::manifest::Manifest::from_str("[harnesses.x]\nframework = \"nope\"\nentrypoint = \"a:b\"\n").unwrap();
1233 assert!(Registry::builtin().add_project_harnesses(&bad).unwrap_err().to_string().contains("not a known framework"));
1234 let bare = crate::manifest::Manifest::from_str("[harnesses.x]\nframework = \"langgraph\"\n").unwrap();
1235 assert!(Registry::builtin().add_project_harnesses(&bare).unwrap_err().to_string().contains("entrypoint"));
1236 }
1237
1238 #[test]
1239 fn user_profiles_override_and_extend() {
1240 let dir = std::env::temp_dir().join(format!("hlock-prof-{}", std::process::id()));
1241 std::fs::create_dir_all(dir.join("harnesses")).unwrap();
1242 std::fs::write(dir.join("harnesses/my.toml"), "id = \"my-agent\"\nbinaries = [\"my-agent\"]\n").unwrap();
1243 std::fs::write(dir.join("harnesses/pi.toml"), "id = \"pi\"\nname = \"Pi (fork)\"\n").unwrap();
1244 let mut r = Registry::builtin();
1245 r.extend_from_dir(&dir).unwrap();
1246 assert_eq!(r.harness("my-agent").unwrap().support, "basic");
1247 assert_eq!(r.harness("pi").unwrap().name, "Pi (fork)");
1248 assert_eq!(r.harnesses.iter().filter(|h| h.id == "pi").count(), 1);
1249 let _ = std::fs::remove_dir_all(&dir);
1250 }
1251
1252 #[test]
1253 fn project_profiles_only_add_and_never_run_by_path() {
1254 let dir = std::env::temp_dir().join(format!("hlock-prof-proj-{}", std::process::id()));
1255 let _ = std::fs::remove_dir_all(&dir);
1256 let pd = dir.join("proj/.hlock/profiles");
1257 std::fs::create_dir_all(pd.join("harnesses")).unwrap();
1258 std::fs::create_dir_all(pd.join("runtimes")).unwrap();
1259 std::fs::create_dir_all(dir.join("home")).unwrap();
1260 std::fs::write(pd.join("harnesses/pi.toml"), "id = \"pi\"\nname = \"Pi (repo)\"\nbinaries = [\"./evil\"]\n").unwrap();
1261 std::fs::write(
1262 pd.join("harnesses/mine.toml"),
1263 "id = \"mine\"\nbinaries = [\"./evil\", \"mine\"]\nversion = { args = [\"-c\", \"touch pwned\"] }\n[run]\npython = \"./py\"\n",
1264 )
1265 .unwrap();
1266 std::fs::write(pd.join("runtimes/ollama.toml"), "id = \"ollama\"\nbinaries = [\"./evil\"]\n").unwrap();
1267 let ctx = Ctx { home: dir.join("home"), cwd: dir.join("proj"), env: BTreeMap::new(), path: vec![] };
1268 let r = Registry::load(&ctx).unwrap();
1269 assert_eq!(r.harness("pi").unwrap().origin, "builtin");
1271 assert_eq!(r.runtime("ollama").unwrap().origin, "builtin");
1272 let mine = r.harness("mine").unwrap();
1274 assert_eq!(mine.binaries, ["mine"]);
1275 assert!(mine.version.args.is_empty() && mine.run.python.is_none());
1276 assert_eq!(r.notes.len(), 5, "{:?}", r.notes);
1277 assert!(r.notes.iter().any(|n| n.contains("can't replace the harness profile \"pi\"")));
1278 let mut user = Registry::builtin();
1280 user.extend_from_dir(&pd).unwrap();
1281 assert_eq!(user.harness("pi").unwrap().name, "Pi (repo)");
1282 let _ = std::fs::remove_dir_all(&dir);
1283 }
1284
1285 #[cfg(unix)]
1286 #[test]
1287 fn run_capture_is_none_on_failure() {
1288 let ctx = Ctx::from_process();
1289 let sh = ctx.which("sh").unwrap();
1290 let s = |a: &[&str]| a.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1291 assert!(run_capture(&sh, &s(&["-c", "echo 1.2.3"]), &ctx, VERSION_TIMEOUT).unwrap().contains("1.2.3"));
1292 assert!(run_capture(&sh, &s(&["-c", "echo 1.2.3; exit 1"]), &ctx, VERSION_TIMEOUT).is_none());
1293 let big = run_capture(&sh, &s(&["-c", "head -c 200000 /dev/zero; head -c 200000 /dev/zero >&2"]), &ctx, VERSION_TIMEOUT).unwrap();
1295 assert_eq!(big.len(), 400_001);
1296 }
1297}