Skip to main content

harness_lock_core/
profiles.rs

1//! Harness and runtime profiles: declarative descriptions of agent harnesses
2//! (Claude Code, Codex, Pi, OpenCode, …) and local inference runtimes (Ollama,
3//! LM Studio, vLLM, …).
4//!
5//! A profile says where a harness keeps its config, how to find its model, MCP
6//! servers, skills and plugins, how it was installed, and how to relocate its
7//! config root for an isolated session. Built-in profiles ship inside the crate
8//! (`profiles/`); users add or override them with TOML files in a profile
9//! directory (see [`profile_dirs`]), so new harnesses need no code change.
10
11use 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
20// --------------------------------------------------------------------------- //
21// Built-in profiles
22// --------------------------------------------------------------------------- //
23
24const 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    // frameworks: code harnesses a project builds on (`[harnesses.<id>] framework = …`)
36    ("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// --------------------------------------------------------------------------- //
59// Environment context
60// --------------------------------------------------------------------------- //
61
62/// The slice of the process environment that profiles read: home and working
63/// directories, environment variables, and the `PATH` search list. Tests build
64/// one by hand so nothing depends on the real machine.
65#[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    /// The process context with `cwd` set to a project directory (where the manifest
88    /// lives), so project-relative paths resolve there and not in a subdirectory.
89    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    /// A non-empty environment variable.
96    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    /// Expand `~`, `{root}`, `{home}` and relative (project) paths.
101    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    /// Resolve a binary name on `PATH`.
122    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// --------------------------------------------------------------------------- //
138// Profile schema
139// --------------------------------------------------------------------------- //
140
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142#[serde(default, rename_all = "kebab-case")]
143pub struct VersionProbe {
144    pub args: Vec<String>,
145}
146
147/// Where the harness keeps its user-level config, and the variable that moves it.
148#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149#[serde(default, rename_all = "kebab-case")]
150pub struct ConfigRoot {
151    /// Variable that relocates the root (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, …).
152    pub env: Option<String>,
153    /// Appended to `env` when it names a parent dir (`XDG_CONFIG_HOME` + `opencode`).
154    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    /// `npm` | `brew` | `brew-cask` | `pip` | `uv-tool` | `script` | `manual`
162    pub method: String,
163    pub package: Option<String>,
164    /// Shell command for `script` / instructions for `manual` (`{version}` is substituted).
165    pub command: Option<String>,
166    /// Substrings of the binary's real path that identify this install method.
167    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    /// Location when the config root is relocated (for files that live outside it).
175    pub isolated: Option<String>,
176    /// `json` | `jsonc` | `toml` | `yaml`
177    pub format: String,
178    /// `whole` (restore the file) | `pointers` (restore only `pointers`) | `record` (digest only)
179    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    /// Ordered sources (`env:VAR` or `<file>#<json-pointer>`); the first hit wins.
187    pub default: Vec<String>,
188    pub small: Vec<String>,
189    pub provider: Vec<String>,
190    pub base_url: Vec<String>,
191    /// `provider/model` when the model value carries its provider.
192    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    /// Pointers (relative to one provider entry) to its base URL.
201    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    /// How servers are written: `standard` (`command`/`args`/`env`, `url`/`headers`),
211    /// `opencode`, `goose`, or `codex`.
212    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    /// Directories whose skills ship with the harness itself.
221    pub builtin_dirs: Vec<String>,
222    /// A file listing the harness's bundled skills, one per line or as keys.
223    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    /// `plugin` | `package` | `extension`
230    pub kind: String,
231    /// `claude-plugins` | `pi-packages` | `npm-list`
232    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    /// Arguments that run one prompt non-interactively and exit (`hlock run`, and
243    /// `hlock meta run --headless`), with `{prompt}` replaced by the prompt, e.g.
244    /// `["-p", "{prompt}"]`.
245    pub prompt: Vec<String>,
246    /// Arguments that start an interactive session whose first message is `{prompt}`
247    /// (`hlock meta run`).
248    pub interactive: Vec<String>,
249}
250
251/// How `hlock localize` points a harness at a local OpenAI-compatible endpoint:
252/// values to set (by JSON pointer) in one of the profile's files. Strings and keys
253/// may use `{provider}`, `{model}`, `{base_url}` and `{host}`.
254#[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/// An API provider a harness supports, and the environment variable holding its key.
262#[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/// A model `hlock localize` may pick for a runtime.
270#[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    /// Memory the weights plus a working context need.
276    pub min_memory_gb: Option<f64>,
277    /// 3 strong agentic coder, 2 capable, 1 small/fast.
278    pub tier: u8,
279    /// Reliable tool calling.
280    pub tools: bool,
281    /// Roles it suits (empty: any).
282    pub roles: Vec<String>,
283}
284
285/// How hlock runs a code harness (a framework agent or a custom script).
286#[derive(Debug, Clone, Serialize, Deserialize, Default)]
287#[serde(default, rename_all = "kebab-case")]
288pub struct RunSpec {
289    /// Python `module:callable`, called as `fn(prompt, ctx)`.
290    pub entrypoint: Option<String>,
291    /// Any executable; `{prompt}`, `{model}` and `{workspace}` are substituted.
292    pub command: Vec<String>,
293    /// Interpreter for `entrypoint` (default: `HL_PYTHON`, else `python3`).
294    pub python: Option<String>,
295    pub env: BTreeMap<String, String>,
296}
297
298/// A declarative description of one agent harness.
299#[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    /// `cli` (an installed agent CLI), `framework` (a Python/JS agent library), or
305    /// `custom` (a project's own agent, declared under `[harnesses.<id>]`).
306    pub kind: String,
307    pub aliases: Vec<String>,
308    pub vendor: Option<String>,
309    pub homepage: Option<String>,
310    /// `full` (pin, sync and launch are tested) or `basic` (detect, version, MCP, skills).
311    pub support: String,
312    /// Open-source license (for `hlock localize`, which prefers open harnesses).
313    pub open_source: Option<bool>,
314    /// Can use a local OpenAI- or Anthropic-compatible model endpoint.
315    pub local_models: Option<bool>,
316    /// How to configure a local model endpoint (`hlock localize`).
317    pub local_provider: Vec<LocalProvider>,
318    /// API providers the harness talks to natively, and the variable each reads its key from.
319    pub api_keys: Vec<ApiKey>,
320    /// How to set an API provider and model (`hlock meta`): like `local-provider`, with
321    /// `{provider}` and `{model}` substituted.
322    pub api_provider: Vec<LocalProvider>,
323    /// Framework profile a custom harness builds on (`pydantic-ai`, `deepagents`, …).
324    pub framework: Option<String>,
325    /// Packages whose installed versions are pinned (`pypi:pydantic-ai`, `npm:…`).
326    pub packages: Vec<String>,
327    /// How to run a code harness.
328    pub run: RunSpec,
329    pub binaries: Vec<String>,
330    pub version: VersionProbe,
331    /// Variables a harness sets in the processes it spawns (detects "running inside").
332    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    /// Where the profile came from: `builtin` or a file path.
344    #[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    /// `ollama` | `publisher-repo` | `hf-cache`
352    pub layout: String,
353    /// Ordered model directories (`env:VAR` entries are honored first).
354    pub dirs: Vec<String>,
355}
356
357/// A declarative description of one local inference runtime.
358#[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    /// Default ports, used to recognize a provider's base URL.
368    pub ports: Vec<u16>,
369    /// Provider names harness configs use for this runtime.
370    pub provider_names: Vec<String>,
371    /// Command template to fetch a model (`{model}`, `{repo}`, `{revision}`).
372    pub pull: Option<String>,
373    pub serve: Option<String>,
374    pub models: RuntimeModels,
375    pub install: Vec<InstallMethod>,
376    /// Models `hlock localize` may pick.
377    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    /// The live config root: the relocation variable if set, else the default.
389    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    /// Whether `config_root` is currently relocated by its variable.
404    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    /// Resolve a named file to a path for the live environment.
409    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    /// Environment that relocates this harness's config root to `dir`, used by
420    /// isolated sessions. `None` when the harness has no relocation variable.
421    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            // XDG-style: the variable names the parent of the root.
425            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    /// True when the current process runs inside this harness.
432    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    /// The first model directory that exists (falls back to the first candidate).
444    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// --------------------------------------------------------------------------- //
464// Registry
465// --------------------------------------------------------------------------- //
466
467/// All known profiles: the built-ins, overridden or extended by user profile dirs.
468#[derive(Debug, Clone, Default)]
469pub struct Registry {
470    pub harnesses: Vec<HarnessProfile>,
471    pub runtimes: Vec<RuntimeProfile>,
472}
473
474/// Profile directories, lowest precedence first: `~/.config/hlock/profiles`,
475/// each entry of `HLOCK_PROFILE_PATH`, then `./.hlock/profiles`. Each holds
476/// `harnesses/*.toml` and `runtimes/*.toml`; a later profile with the same `id`
477/// replaces an earlier one.
478pub 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    /// Only the profiles compiled into this build.
521    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    /// Built-ins plus every profile found in [`profile_dirs`].
534    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    /// Add (or replace by id) the profiles in `dir/harnesses` and `dir/runtimes`.
543    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    /// Built-ins, profile dirs, then the manifest's own `[harnesses.<id>]` tables.
560    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    /// Register a manifest's `[harnesses.<id>]` tables as `custom` profiles. A
569    /// table with `framework = "<id>"` inherits that framework's packages.
570    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                // `entrypoint`, `command`, `python` and `env` may sit at the top level.
581                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    /// Look a harness up by id or alias, with a helpful error listing what exists.
635    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    /// The harness this process is running inside, if any.
643    pub fn active(&self, ctx: &Ctx) -> Option<&HarnessProfile> {
644        self.harnesses.iter().find(|h| h.is_active(ctx))
645    }
646
647    /// Pick the harness a command should target, in precedence order: an explicit
648    /// name, `HLOCK_HARNESS`, the manifest's `[target] harness`, the harness this
649    /// process runs inside, then the only installed harness.
650    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/// The outcome of [`Registry::select`]: which harness, and why it was chosen.
679#[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// --------------------------------------------------------------------------- //
699// Probing an installed harness / runtime
700// --------------------------------------------------------------------------- //
701
702/// What's installed on this machine for one profile.
703#[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    /// Detected install method (`npm`, `brew-cask`, `script`, …).
710    pub method: Option<String>,
711    /// Reproducible source spec, e.g. `npm:@openai/codex@0.51.0`.
712    pub source: Option<String>,
713    pub config_root: Option<String>,
714    pub active: bool,
715    /// Installed versions of the profile's `packages` (code harnesses).
716    #[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
732/// Frameworks and custom harnesses: "installed" means their packages import
733/// (or a custom command resolves), and the version is the first package's.
734fn 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
754/// The Python interpreter for code harnesses: an explicit one, `HL_PYTHON`, then `python3`.
755pub 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
760/// Installed versions of `pypi:` packages, read with one interpreter call.
761pub 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
804/// Format a reproducible source spec for an install method.
805pub 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
816/// Infer how a binary was installed from its real path.
817pub 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
848/// The `name` of the nearest `package.json` above a file inside `node_modules`.
849fn 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
867/// Run a command with a timeout, returning stdout+stderr on success.
868pub 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
899/// The first dotted version number in `text`, e.g. `2.1.4` or `2026.05.28-a70ca7c`.
900/// ANSI escape sequences are skipped.
901pub 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                // optional pre-release / build suffix
920                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            // every `<file>#<pointer>` reference names a declared file
970            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        // `hlock run` needs every agent CLI's non-interactive mode
984        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}