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;
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    // autonomous agents: long-running gateways (`autonomous = true`)
36    ("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    // frameworks: code harnesses a project builds on (`[harnesses.<id>] framework = …`)
42    ("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// --------------------------------------------------------------------------- //
66// Environment context
67// --------------------------------------------------------------------------- //
68
69/// The slice of the process environment that profiles read: home and working
70/// directories, environment variables, and the `PATH` search list. Tests build
71/// one by hand so nothing depends on the real machine.
72#[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    /// The process context with `cwd` set to a project directory (where the manifest
95    /// lives), so project-relative paths resolve there and not in a subdirectory.
96    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    /// A non-empty environment variable.
103    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    /// Expand `~`, `{root}`, `{home}` and relative (project) paths.
108    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    /// Resolve a binary name on `PATH`.
129    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// --------------------------------------------------------------------------- //
145// Profile schema
146// --------------------------------------------------------------------------- //
147
148#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149#[serde(default, rename_all = "kebab-case")]
150pub struct VersionProbe {
151    pub args: Vec<String>,
152}
153
154/// Where the harness keeps its user-level config, and the variable that moves it.
155#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156#[serde(default, rename_all = "kebab-case")]
157pub struct ConfigRoot {
158    /// Variable that relocates the root (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, …).
159    pub env: Option<String>,
160    /// Appended to `env` when it names a parent dir (`XDG_CONFIG_HOME` + `opencode`).
161    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    /// `npm` | `brew` | `brew-cask` | `pip` | `uv-tool` | `script` | `manual`
169    pub method: String,
170    pub package: Option<String>,
171    /// Shell command for `script` / instructions for `manual` (`{version}` is substituted).
172    pub command: Option<String>,
173    /// Substrings of the binary's real path that identify this install method.
174    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    /// Location when the config root is relocated (for files that live outside it).
182    pub isolated: Option<String>,
183    /// `json` | `jsonc` | `json5` | `toml` | `yaml`
184    pub format: String,
185    /// `whole` (restore the file) | `pointers` (restore only `pointers`) | `record` (digest only)
186    pub restore: String,
187    pub pointers: Vec<String>,
188    /// How the harness reads an environment variable inside this file, with `{name}`
189    /// for the variable: `${{name}}` (Claude Code), `{env:{name}}` (OpenCode). Set
190    /// only when the harness expands it wherever hlock writes secrets in the file;
191    /// `hlock launch` then writes the reference and passes the value in the environment.
192    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    /// Ordered sources (`env:VAR` or `<file>#<json-pointer>`); the first hit wins.
199    pub default: Vec<String>,
200    pub small: Vec<String>,
201    pub provider: Vec<String>,
202    pub base_url: Vec<String>,
203    /// `provider/model` when the model value carries its provider.
204    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    /// Pointers (relative to one provider entry) to its base URL.
213    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    /// How servers are written: `standard` (`command`/`args`/`env`, `url`/`headers`),
223    /// `opencode`, `goose`, or `codex`.
224    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    /// Directories whose skills ship with the harness itself.
233    pub builtin_dirs: Vec<String>,
234    /// A file listing the harness's bundled skills, one per line or as keys.
235    pub builtin_manifest: Option<String>,
236}
237
238/// A directory of instruction files (`.claude/rules`), and the extension a file there needs.
239#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240#[serde(default, rename_all = "kebab-case")]
241pub struct RulesSpec {
242    pub dir: String,
243    /// `.md`, `.mdc` (Cursor), `.instructions.md` (Copilot).
244    pub ext: String,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, Default)]
248#[serde(default, rename_all = "kebab-case")]
249pub struct BundleSpec {
250    /// `plugin` | `package` | `extension`
251    pub kind: String,
252    /// `claude-plugins` | `pi-packages` | `npm-list`
253    pub extractor: String,
254    pub source: String,
255    pub lockfile: Option<String>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, Default)]
259#[serde(default, rename_all = "kebab-case")]
260pub struct LaunchSpec {
261    pub args: Vec<String>,
262    pub model_flag: Option<String>,
263    /// Arguments that run one prompt non-interactively and exit (`hlock run`, and
264    /// `hlock meta run --headless`), with `{prompt}` replaced by the prompt, e.g.
265    /// `["-p", "{prompt}"]`.
266    pub prompt: Vec<String>,
267    /// Arguments that start an interactive session whose first message is `{prompt}`
268    /// (`hlock meta run`).
269    pub interactive: Vec<String>,
270}
271
272/// How `hlock localize` points a harness at a local OpenAI-compatible endpoint:
273/// values to set (by JSON pointer) in one of the profile's files. Strings and keys
274/// may use `{provider}`, `{model}`, `{base_url}` and `{host}`.
275#[derive(Debug, Clone, Serialize, Deserialize, Default)]
276#[serde(default, rename_all = "kebab-case")]
277pub struct LocalProvider {
278    pub file: String,
279    pub set: BTreeMap<String, serde_json::Value>,
280    /// Apply only when localizing onto one of these runtimes (empty: any). Entries
281    /// apply in order, so a runtime-specific entry after a generic one overrides it.
282    pub runtimes: Vec<String>,
283}
284
285impl LocalProvider {
286    /// Whether this entry applies on `runtime` (`None`: an API provider, not a runtime).
287    pub fn applies_to(&self, runtime: Option<&str>) -> bool {
288        self.runtimes.is_empty() || runtime.is_some_and(|rt| self.runtimes.iter().any(|r| r == rt))
289    }
290}
291
292/// An API provider a harness supports, and the environment variable holding its key.
293#[derive(Debug, Clone, Serialize, Deserialize, Default)]
294#[serde(default, rename_all = "kebab-case")]
295pub struct ApiKey {
296    pub provider: String,
297    pub env: String,
298}
299
300/// A model `hlock localize` may pick for a runtime.
301#[derive(Debug, Clone, Serialize, Deserialize, Default)]
302#[serde(default, rename_all = "kebab-case")]
303pub struct CatalogModel {
304    pub id: String,
305    pub params_b: Option<f64>,
306    /// Memory the weights plus a working context need.
307    pub min_memory_gb: Option<f64>,
308    /// 3 strong agentic coder, 2 capable, 1 small/fast.
309    pub tier: u8,
310    /// Reliable tool calling.
311    pub tools: bool,
312    /// Roles it suits (empty: any).
313    pub roles: Vec<String>,
314}
315
316/// How hlock runs a code harness (a framework agent or a custom script).
317#[derive(Debug, Clone, Serialize, Deserialize, Default)]
318#[serde(default, rename_all = "kebab-case")]
319pub struct RunSpec {
320    /// Python `module:callable`, called as `fn(prompt, ctx)`.
321    pub entrypoint: Option<String>,
322    /// Any executable; `{prompt}`, `{model}` and `{workspace}` are substituted.
323    pub command: Vec<String>,
324    /// Interpreter for `entrypoint` (default: `HL_PYTHON`, else `python3`).
325    pub python: Option<String>,
326    pub env: BTreeMap<String, String>,
327}
328
329/// How a harness reaches a decider for one use (`[deciders.<use>]` in a profile).
330#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
331#[serde(default, rename_all = "kebab-case")]
332pub struct DeciderWiringSpec {
333    /// A hook hlock writes: into the profile file `file`, under `pointer` (default
334    /// `/hooks`), for `events`, with output in `format` (`claude-code`).
335    pub hook: Option<HookSpec>,
336    /// A setting of the harness's own that names the model (`config#/auxiliary/approval`),
337    /// with the keys it uses for provider, model and base URL.
338    pub native: Option<NativeDeciderSpec>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
342#[serde(default, rename_all = "kebab-case")]
343pub struct HookSpec {
344    /// A profile file the hooks are merged into (`settings`)…
345    pub file: String,
346    /// …or a path of its own (`{root}/hooks.json`, `.github/hooks/hlock.json`): merged
347    /// into for `cursor`, written whole for `copilot` and the plugin formats (`amp`,
348    /// `opencode`, `pi`), which hlock owns.
349    pub path: Option<String>,
350    pub pointer: Option<String>,
351    pub events: Vec<String>,
352    /// `claude-code`, `codex`, `gemini`, `cursor`, `copilot`, `amp`, `opencode`, `pi`
353    pub format: String,
354}
355
356impl HookSpec {
357    /// What a lock's `[[decider.wiring]]` names: the profile file, or the path.
358    pub fn target(&self) -> String {
359        self.path.clone().unwrap_or_else(|| self.file.clone())
360    }
361
362    /// Whether hlock writes the whole file (a plugin, Copilot's hook file).
363    pub fn owned(&self) -> bool {
364        self.path.is_some() && matches!(self.format.as_str(), "copilot" | "amp" | "opencode" | "pi")
365    }
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
369#[serde(default, rename_all = "kebab-case")]
370pub struct NativeDeciderSpec {
371    pub file: String,
372    pub pointer: String,
373    pub provider_key: Option<String>,
374    pub model_key: String,
375    pub base_url_key: Option<String>,
376}
377
378/// A declarative description of one agent harness.
379#[derive(Debug, Clone, Serialize, Deserialize, Default)]
380#[serde(default, rename_all = "kebab-case")]
381pub struct HarnessProfile {
382    pub id: String,
383    pub name: String,
384    /// `cli` (an installed agent CLI), `framework` (a Python/JS agent library), or
385    /// `custom` (a project's own agent, declared under `[harnesses.<id>]`).
386    pub kind: String,
387    /// A long-running, self-directed agent (a gateway with chat channels, schedules
388    /// and memory) rather than a session a person drives. Docs and `hlock harness list`
389    /// group these apart; everything else treats them like any other `cli` harness.
390    pub autonomous: bool,
391    pub aliases: Vec<String>,
392    pub vendor: Option<String>,
393    pub homepage: Option<String>,
394    /// `full` (pin, sync and launch are tested) or `basic` (detect, version, MCP, skills).
395    pub support: String,
396    /// Open-source license (for `hlock localize`, which prefers open harnesses).
397    pub open_source: Option<bool>,
398    /// Can use a local OpenAI- or Anthropic-compatible model endpoint.
399    pub local_models: Option<bool>,
400    /// How to configure a local model endpoint (`hlock localize`).
401    pub local_provider: Vec<LocalProvider>,
402    /// API providers the harness talks to natively, and the variable each reads its key from.
403    pub api_keys: Vec<ApiKey>,
404    /// How to set an API provider and model (`hlock meta`): like `local-provider`, with
405    /// `{provider}` and `{model}` substituted.
406    pub api_provider: Vec<LocalProvider>,
407    /// Framework profile a custom harness builds on (`pydantic-ai`, `deepagents`, …).
408    pub framework: Option<String>,
409    /// Packages whose installed versions are pinned (`pypi:pydantic-ai`, `npm:…`).
410    pub packages: Vec<String>,
411    /// How to run a code harness.
412    pub run: RunSpec,
413    pub binaries: Vec<String>,
414    pub version: VersionProbe,
415    /// Variables a harness sets in the processes it spawns (detects "running inside").
416    pub active_env: Vec<String>,
417    pub config_root: ConfigRoot,
418    pub instructions: Vec<String>,
419    /// A project directory the harness reads every instruction file in, where
420    /// `hlock add instructions` installs one. Without it, a declared instruction file
421    /// becomes the project's own instruction file.
422    pub rules: Option<RulesSpec>,
423    pub install: Vec<InstallMethod>,
424    pub files: BTreeMap<String, FileSpec>,
425    pub model: ModelSpec,
426    pub providers: Option<ProviderSpec>,
427    pub mcp: McpSpec,
428    pub skills: SkillsSpec,
429    pub bundles: Vec<BundleSpec>,
430    pub launch: LaunchSpec,
431    /// How each decider use is wired into this harness (`approve`, `stop`, …).
432    pub deciders: BTreeMap<String, DeciderWiringSpec>,
433    /// Environment variables a framework reads a System One endpoint from
434    /// (`SYSTEM_ONE_BASE_URL`, `TYPESAFE_BASE_URL`): base-URL and key variable pairs.
435    pub decider_env: Vec<(String, String)>,
436    /// Where the profile came from: `builtin` or a file path.
437    #[serde(skip_deserializing)]
438    pub origin: String,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, Default)]
442#[serde(default, rename_all = "kebab-case")]
443pub struct RuntimeModels {
444    /// `ollama` | `publisher-repo` | `hf-cache`
445    pub layout: String,
446    /// Ordered model directories (`env:VAR` entries are honored first).
447    pub dirs: Vec<String>,
448}
449
450/// A declarative description of one local inference runtime.
451#[derive(Debug, Clone, Serialize, Deserialize, Default)]
452#[serde(default, rename_all = "kebab-case")]
453pub struct RuntimeProfile {
454    pub id: String,
455    pub name: String,
456    pub aliases: Vec<String>,
457    pub homepage: Option<String>,
458    pub binaries: Vec<String>,
459    pub version: VersionProbe,
460    /// Default ports, used to recognize a provider's base URL.
461    pub ports: Vec<u16>,
462    /// Provider names harness configs use for this runtime.
463    pub provider_names: Vec<String>,
464    /// Command template to fetch a model (`{model}`, `{repo}`, `{revision}`).
465    pub pull: Option<String>,
466    pub serve: Option<String>,
467    pub models: RuntimeModels,
468    pub install: Vec<InstallMethod>,
469    /// Models `hlock localize` may pick.
470    pub catalog: Vec<CatalogModel>,
471    #[serde(skip_deserializing)]
472    pub origin: String,
473}
474
475impl HarnessProfile {
476    pub fn matches(&self, name: &str) -> bool {
477        let n = name.to_ascii_lowercase();
478        self.id == n || self.aliases.iter().any(|a| a.to_ascii_lowercase() == n)
479    }
480
481    /// The live config root: the relocation variable if set, else the default.
482    pub fn config_root(&self, ctx: &Ctx) -> PathBuf {
483        let cr = &self.config_root;
484        if let Some(var) = &cr.env {
485            if let Some(v) = ctx.var(var) {
486                let base = ctx.expand(v, None);
487                return match &cr.suffix {
488                    Some(sfx) => base.join(sfx),
489                    None => base,
490                };
491            }
492        }
493        ctx.expand(&cr.default, None)
494    }
495
496    /// Whether `config_root` is currently relocated by its variable.
497    pub fn root_relocated(&self, ctx: &Ctx) -> bool {
498        self.config_root.env.as_deref().and_then(|v| ctx.var(v)).is_some()
499    }
500
501    /// Resolve a named file to a path for the live environment.
502    pub fn file_path(&self, id: &str, ctx: &Ctx) -> Option<PathBuf> {
503        let f = self.files.get(id)?;
504        let root = self.config_root(ctx);
505        let tpl = match (&f.isolated, self.root_relocated(ctx)) {
506            (Some(iso), true) => iso,
507            _ => &f.path,
508        };
509        Some(ctx.expand(tpl, Some(&root)))
510    }
511
512    /// Environment that relocates this harness's config root to `dir`, used by
513    /// isolated sessions. `None` when the harness has no relocation variable.
514    pub fn isolation_env(&self, dir: &Path) -> Option<(String, PathBuf)> {
515        let var = self.config_root.env.clone()?;
516        let value = match &self.config_root.suffix {
517            // XDG-style: the variable names the parent of the root.
518            Some(_) => dir.parent().map(Path::to_path_buf).unwrap_or_else(|| dir.to_path_buf()),
519            None => dir.to_path_buf(),
520        };
521        Some((var, value))
522    }
523
524    /// True when the current process runs inside this harness.
525    pub fn is_active(&self, ctx: &Ctx) -> bool {
526        self.active_env.iter().any(|v| ctx.var(v).is_some())
527    }
528}
529
530impl RuntimeProfile {
531    pub fn matches(&self, name: &str) -> bool {
532        let n = name.to_ascii_lowercase();
533        self.id == n || self.aliases.iter().any(|a| a.to_ascii_lowercase() == n)
534    }
535
536    /// The first model directory that exists (falls back to the first candidate).
537    pub fn models_dir(&self, ctx: &Ctx) -> Option<PathBuf> {
538        let mut first = None;
539        for d in &self.models.dirs {
540            let p = match d.strip_prefix("env:") {
541                Some(var) => match ctx.var(var) {
542                    Some(v) => ctx.expand(v, None),
543                    None => continue,
544                },
545                None => ctx.expand(d, None),
546            };
547            if p.is_dir() {
548                return Some(p);
549            }
550            first.get_or_insert(p);
551        }
552        first
553    }
554}
555
556// --------------------------------------------------------------------------- //
557// Registry
558// --------------------------------------------------------------------------- //
559
560/// All known profiles: the built-ins, overridden or extended by user profile dirs.
561#[derive(Debug, Clone, Default)]
562pub struct Registry {
563    pub harnesses: Vec<HarnessProfile>,
564    pub runtimes: Vec<RuntimeProfile>,
565    /// Project profiles (or parts of them) that were skipped, and why.
566    pub notes: Vec<String>,
567}
568
569/// Profile directories, lowest precedence first: `~/.config/hlock/profiles`,
570/// each entry of `HLOCK_PROFILE_PATH`, then `./.hlock/profiles`. Each holds
571/// `harnesses/*.toml` and `runtimes/*.toml`; a later profile with the same `id`
572/// replaces an earlier one, except that the project's own directory (the last; see
573/// [`project_profile_dir`]) may only add profiles.
574pub fn profile_dirs(ctx: &Ctx) -> Vec<PathBuf> {
575    let mut dirs = vec![ctx.expand("~/.config/hlock/profiles", None)];
576    if let Some(p) = ctx.var("HLOCK_PROFILE_PATH") {
577        dirs.extend(std::env::split_paths(p));
578    }
579    dirs.push(project_profile_dir(ctx));
580    dirs
581}
582
583/// The project's own profile directory, `./.hlock/profiles`. Whoever wrote the
584/// repository controls it, so [`Registry::extend_from_project_dir`] limits what it can do.
585pub fn project_profile_dir(ctx: &Ctx) -> PathBuf {
586    ctx.cwd.join(".hlock/profiles")
587}
588
589/// Version arguments a project profile may probe with.
590const VERSION_FLAGS: &[&str] = &["--version", "-version", "-V", "-v", "version"];
591
592/// Keep a project profile from running anything but a program on `PATH` with a
593/// version flag: `hlock check` probes every profile's binaries.
594fn confine(notes: &mut Vec<String>, origin: &Path, binaries: &mut Vec<String>, version: &mut VersionProbe, python: Option<&mut Option<String>>) {
595    let by_path = |b: &str| b.contains(['/', '\\']);
596    for b in binaries.iter().filter(|b| by_path(b)) {
597        notes.push(format!("{}: ignored binary {b:?}; a project profile names programs on PATH, not by path", origin.display()));
598    }
599    binaries.retain(|b| !by_path(b));
600    if !version.args.iter().all(|a| VERSION_FLAGS.contains(&a.as_str())) {
601        notes.push(format!("{}: ignored version args {:?}; a project profile may only probe with a version flag", origin.display(), version.args));
602        version.args.clear();
603    }
604    if let Some(py) = python {
605        if py.as_deref().is_some_and(by_path) {
606            notes.push(format!("{}: ignored python {:?}; a project profile names an interpreter on PATH, not by path", origin.display(), py.as_deref().unwrap_or_default()));
607            *py = None;
608        }
609    }
610}
611
612pub fn parse_harness(text: &str, origin: &str) -> Result<HarnessProfile> {
613    let mut p: HarnessProfile =
614        toml::from_str(text).map_err(|e| Error::Profile(format!("invalid harness profile {origin}: {e}")))?;
615    if p.id.is_empty() {
616        return Err(Error::Profile(format!("harness profile {origin} has no id")));
617    }
618    if p.name.is_empty() {
619        p.name = p.id.clone();
620    }
621    if p.support.is_empty() {
622        p.support = "basic".into();
623    }
624    if p.kind.is_empty() {
625        p.kind = if p.run.entrypoint.is_some() || !p.run.command.is_empty() { "custom" } else { "cli" }.into();
626    }
627    p.origin = origin.to_string();
628    Ok(p)
629}
630
631pub fn parse_runtime(text: &str, origin: &str) -> Result<RuntimeProfile> {
632    let mut p: RuntimeProfile =
633        toml::from_str(text).map_err(|e| Error::Profile(format!("invalid runtime profile {origin}: {e}")))?;
634    if p.id.is_empty() {
635        return Err(Error::Profile(format!("runtime profile {origin} has no id")));
636    }
637    if p.name.is_empty() {
638        p.name = p.id.clone();
639    }
640    p.origin = origin.to_string();
641    Ok(p)
642}
643
644impl Registry {
645    /// Only the profiles compiled into this build.
646    pub fn builtin() -> Registry {
647        let harnesses = BUILTIN_HARNESSES
648            .iter()
649            .map(|(id, text)| parse_harness(text, "builtin").unwrap_or_else(|e| panic!("builtin profile {id}: {e}")))
650            .collect();
651        let runtimes = BUILTIN_RUNTIMES
652            .iter()
653            .map(|(id, text)| parse_runtime(text, "builtin").unwrap_or_else(|e| panic!("builtin profile {id}: {e}")))
654            .collect();
655        Registry { harnesses, runtimes, notes: Vec::new() }
656    }
657
658    /// Built-ins plus every profile found in [`profile_dirs`].
659    pub fn load(ctx: &Ctx) -> Result<Registry> {
660        let mut reg = Registry::builtin();
661        let project = project_profile_dir(ctx);
662        for dir in profile_dirs(ctx) {
663            if dir == project {
664                reg.extend_from_project_dir(&dir)?;
665            } else {
666                reg.extend_from_dir(&dir)?;
667            }
668        }
669        Ok(reg)
670    }
671
672    /// Add the profiles in a project's own profile directory. They may add new ids but
673    /// not replace a profile hlock already has (put an override in
674    /// `~/.config/hlock/profiles`), and they can't name a program by path or probe it
675    /// with anything but a version flag. What's skipped is noted in `notes`.
676    pub fn extend_from_project_dir(&mut self, dir: &Path) -> Result<()> {
677        let taken = |notes: &mut Vec<String>, path: &Path, kind: &str, id: &str| {
678            notes.push(format!("{}: skipped; a project profile can't replace the {kind} profile {id:?} (override it in ~/.config/hlock/profiles)", path.display()));
679        };
680        for path in toml_files(&dir.join("harnesses")) {
681            let text = std::fs::read_to_string(&path)?;
682            let mut p = parse_harness(&text, &path.to_string_lossy())?;
683            if self.harnesses.iter().any(|h| h.id == p.id) {
684                taken(&mut self.notes, &path, "harness", &p.id);
685                continue;
686            }
687            confine(&mut self.notes, &path, &mut p.binaries, &mut p.version, Some(&mut p.run.python));
688            self.harnesses.push(p);
689        }
690        for path in toml_files(&dir.join("runtimes")) {
691            let text = std::fs::read_to_string(&path)?;
692            let mut p = parse_runtime(&text, &path.to_string_lossy())?;
693            if self.runtimes.iter().any(|r| r.id == p.id) {
694                taken(&mut self.notes, &path, "runtime", &p.id);
695                continue;
696            }
697            confine(&mut self.notes, &path, &mut p.binaries, &mut p.version, None);
698            self.runtimes.push(p);
699        }
700        Ok(())
701    }
702
703    /// Add (or replace by id) the profiles in `dir/harnesses` and `dir/runtimes`.
704    pub fn extend_from_dir(&mut self, dir: &Path) -> Result<()> {
705        for path in toml_files(&dir.join("harnesses")) {
706            let text = std::fs::read_to_string(&path)?;
707            let p = parse_harness(&text, &path.to_string_lossy())?;
708            self.harnesses.retain(|h| h.id != p.id);
709            self.harnesses.push(p);
710        }
711        for path in toml_files(&dir.join("runtimes")) {
712            let text = std::fs::read_to_string(&path)?;
713            let p = parse_runtime(&text, &path.to_string_lossy())?;
714            self.runtimes.retain(|r| r.id != p.id);
715            self.runtimes.push(p);
716        }
717        Ok(())
718    }
719
720    /// Built-ins, profile dirs, then the manifest's own `[harnesses.<id>]` tables.
721    pub fn load_for(ctx: &Ctx, manifest: Option<&crate::manifest::Manifest>) -> Result<Registry> {
722        let mut reg = Registry::load(ctx)?;
723        if let Some(m) = manifest {
724            reg.add_project_harnesses(m)?;
725        }
726        Ok(reg)
727    }
728
729    /// Register a manifest's `[harnesses.<id>]` tables as `custom` profiles. A
730    /// table with `framework = "<id>"` inherits that framework's packages.
731    pub fn add_project_harnesses(&mut self, m: &crate::manifest::Manifest) -> Result<()> {
732        let origin = m
733            .source_path
734            .as_ref()
735            .map(|p| p.to_string_lossy().into_owned())
736            .unwrap_or_else(|| crate::MANIFEST_PATH.to_string());
737        for (id, table) in &m.harnesses {
738            let mut v = table.clone();
739            if let Some(obj) = v.as_object_mut() {
740                obj.entry("id").or_insert_with(|| serde_json::json!(id));
741                // `entrypoint`, `command`, `python` and `env` may sit at the top level.
742                let mut run = obj.remove("run").and_then(|r| r.as_object().cloned()).unwrap_or_default();
743                for key in ["entrypoint", "command", "python", "env"] {
744                    if let Some(val) = obj.remove(key) {
745                        run.insert(key.to_string(), val);
746                    }
747                }
748                obj.insert("run".into(), serde_json::Value::Object(run));
749            }
750            let mut p: HarnessProfile = serde_json::from_value(v)
751                .map_err(|e| Error::Profile(format!("invalid [harnesses.{id}] in {origin}: {e}")))?;
752            if p.name.is_empty() {
753                p.name = id.clone();
754            }
755            if p.kind.is_empty() {
756                p.kind = "custom".into();
757            }
758            if p.support.is_empty() {
759                p.support = "basic".into();
760            }
761            if let Some(fw) = &p.framework {
762                let base = self
763                    .harness(fw)
764                    .filter(|h| h.kind == "framework")
765                    .ok_or_else(|| Error::Profile(format!("[harnesses.{id}] framework {fw:?} is not a known framework")))?;
766                for pkg in &base.packages {
767                    if !p.packages.contains(pkg) {
768                        p.packages.push(pkg.clone());
769                    }
770                }
771                if p.homepage.is_none() {
772                    p.homepage = base.homepage.clone();
773                }
774                if p.decider_env.is_empty() {
775                    p.decider_env = base.decider_env.clone();
776                }
777            }
778            if p.kind == "custom" && p.run.entrypoint.is_none() && p.run.command.is_empty() {
779                return Err(Error::Profile(format!(
780                    "[harnesses.{id}] needs an `entrypoint` (python module:callable) or a `command`"
781                )));
782            }
783            p.origin = origin.clone();
784            self.harnesses.retain(|h| h.id != p.id);
785            self.harnesses.push(p);
786        }
787        Ok(())
788    }
789
790    pub fn harness(&self, name: &str) -> Option<&HarnessProfile> {
791        self.harnesses.iter().find(|h| h.matches(name))
792    }
793
794    pub fn runtime(&self, name: &str) -> Option<&RuntimeProfile> {
795        self.runtimes.iter().find(|r| r.matches(name))
796    }
797
798    /// Look a harness up by id or alias, with a helpful error listing what exists.
799    pub fn require_harness(&self, name: &str) -> Result<&HarnessProfile> {
800        self.harness(name).ok_or_else(|| {
801            let known: Vec<&str> = self.harnesses.iter().map(|h| h.id.as_str()).collect();
802            Error::Profile(format!("unknown harness {name:?} (known: {})", known.join(", ")))
803        })
804    }
805
806    /// The harness this process is running inside, if any.
807    pub fn active(&self, ctx: &Ctx) -> Option<&HarnessProfile> {
808        self.harnesses.iter().find(|h| h.is_active(ctx))
809    }
810
811    /// Pick the harness a command should target, in precedence order: an explicit
812    /// name, `HLOCK_HARNESS`, the manifest's `[target] harness`, the harness this
813    /// process runs inside, then the only installed harness.
814    pub fn select(&self, explicit: Option<&str>, manifest_target: Option<&str>, ctx: &Ctx) -> Result<Selection> {
815        let named = explicit
816            .map(|n| (n.to_string(), "--harness"))
817            .or_else(|| ctx.var("HLOCK_HARNESS").map(|n| (n.to_string(), "HLOCK_HARNESS")))
818            .or_else(|| manifest_target.map(|n| (n.to_string(), "harness.toml [target]")));
819        if let Some((name, why)) = named {
820            let p = self.require_harness(&name)?;
821            return Ok(Selection { id: p.id.clone(), reason: why.to_string() });
822        }
823        if let Some(p) = self.active(ctx) {
824            return Ok(Selection { id: p.id.clone(), reason: "running inside it".into() });
825        }
826        let installed: Vec<&HarnessProfile> = self
827            .harnesses
828            .iter()
829            .filter(|h| h.kind == "cli" && h.binaries.iter().any(|b| ctx.which(b).is_some()))
830            .collect();
831        match installed.as_slice() {
832            [one] => Ok(Selection { id: one.id.clone(), reason: "the only installed harness".into() }),
833            [] => Err(Error::Profile("no supported harness found on PATH".into())),
834            many => Err(Error::Profile(format!(
835                "several harnesses are installed ({}); pick one with --harness",
836                many.iter().map(|h| h.id.as_str()).collect::<Vec<_>>().join(", ")
837            ))),
838        }
839    }
840}
841
842/// The outcome of [`Registry::select`]: which harness, and why it was chosen.
843#[derive(Debug, Clone, Serialize)]
844pub struct Selection {
845    pub id: String,
846    pub reason: String,
847}
848
849fn toml_files(dir: &Path) -> Vec<PathBuf> {
850    let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
851        .map(|rd| {
852            rd.flatten()
853                .map(|e| e.path())
854                .filter(|p| p.extension().is_some_and(|x| x == "toml"))
855                .collect()
856        })
857        .unwrap_or_default();
858    out.sort();
859    out
860}
861
862// --------------------------------------------------------------------------- //
863// Probing an installed harness / runtime
864// --------------------------------------------------------------------------- //
865
866/// What's installed on this machine for one profile.
867#[derive(Debug, Clone, Serialize, Default)]
868pub struct Probe {
869    pub id: String,
870    pub installed: bool,
871    pub binary: Option<String>,
872    pub version: Option<String>,
873    /// Detected install method (`npm`, `brew-cask`, `script`, …).
874    pub method: Option<String>,
875    /// Reproducible source spec, e.g. `npm:@openai/codex@0.51.0`.
876    pub source: Option<String>,
877    pub config_root: Option<String>,
878    pub active: bool,
879    /// Installed versions of the profile's `packages` (code harnesses).
880    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
881    pub packages: BTreeMap<String, String>,
882}
883
884const VERSION_TIMEOUT: Duration = Duration::from_secs(8);
885
886pub fn probe_harness(p: &HarnessProfile, ctx: &Ctx) -> Probe {
887    if p.kind != "cli" {
888        return probe_code_harness(p, ctx);
889    }
890    let mut out = probe_binary(&p.id, &p.binaries, &p.version, &p.install, ctx);
891    out.config_root = Some(p.config_root(ctx).to_string_lossy().into_owned());
892    out.active = p.is_active(ctx);
893    out
894}
895
896/// Frameworks and custom harnesses: "installed" means their packages import
897/// (or a custom command resolves), and the version is the first package's.
898fn probe_code_harness(p: &HarnessProfile, ctx: &Ctx) -> Probe {
899    let mut out = Probe { id: p.id.clone(), active: p.is_active(ctx), ..Default::default() };
900    out.packages = python_package_versions(&p.packages, p.run.python.as_deref(), ctx);
901    let first = p.packages.iter().find_map(|pkg| out.packages.get(pkg).map(|v| (pkg, v)));
902    if let Some((pkg, v)) = first {
903        out.version = Some(v.clone());
904        out.method = Some("pip".into());
905        out.source = Some(format!("pypi:{}=={v}", pkg.trim_start_matches("pypi:")));
906    }
907    out.installed = match (p.kind.as_str(), p.run.command.first()) {
908        ("custom", Some(cmd)) => {
909            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());
910            out.binary.is_some()
911        }
912        ("custom", None) => p.run.entrypoint.is_some(),
913        _ => first.is_some(),
914    };
915    out
916}
917
918/// The Python interpreter for code harnesses: an explicit one, `HL_PYTHON`, then `python3`.
919pub fn python_for(explicit: Option<&str>, ctx: &Ctx) -> Option<PathBuf> {
920    let name = explicit.map(str::to_string).or_else(|| ctx.var("HL_PYTHON").map(str::to_string)).unwrap_or_else(|| "python3".into());
921    ctx.which(&name)
922}
923
924/// Installed versions of `pypi:` packages, read with one interpreter call.
925pub fn python_package_versions(packages: &[String], python: Option<&str>, ctx: &Ctx) -> BTreeMap<String, String> {
926    let names: Vec<&str> = packages.iter().filter_map(|p| p.strip_prefix("pypi:")).collect();
927    let mut out = BTreeMap::new();
928    if names.is_empty() {
929        return out;
930    }
931    let Some(py) = python_for(python, ctx) else { return out };
932    let script = format!(
933        "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))"
934    );
935    let Some(text) = run_capture(&py, &["-c".to_string(), script], ctx, VERSION_TIMEOUT) else { return out };
936    let Some(line) = text.lines().find(|l| l.trim_start().starts_with('{')) else { return out };
937    if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(line) {
938        for (k, v) in map {
939            if let Some(v) = v.as_str() {
940                out.insert(format!("pypi:{k}"), v.to_string());
941            }
942        }
943    }
944    out
945}
946
947pub fn probe_runtime(p: &RuntimeProfile, ctx: &Ctx) -> Probe {
948    probe_binary(&p.id, &p.binaries, &p.version, &p.install, ctx)
949}
950
951fn probe_binary(id: &str, binaries: &[String], version: &VersionProbe, install: &[InstallMethod], ctx: &Ctx) -> Probe {
952    let mut out = Probe { id: id.to_string(), ..Default::default() };
953    let Some(bin) = binaries.iter().find_map(|b| ctx.which(b)) else {
954        return out;
955    };
956    out.installed = true;
957    out.binary = Some(bin.to_string_lossy().into_owned());
958    if !version.args.is_empty() {
959        out.version = run_capture(&bin, &version.args, ctx, VERSION_TIMEOUT).and_then(|t| extract_version(&t));
960    }
961    let real = std::fs::canonicalize(&bin).unwrap_or(bin.clone());
962    let (method, package) = detect_install(&real, install);
963    out.source = method.as_deref().map(|m| source_spec(m, package.as_deref(), out.version.as_deref()));
964    out.method = method;
965    out
966}
967
968/// Format a reproducible source spec for an install method.
969pub fn source_spec(method: &str, package: Option<&str>, version: Option<&str>) -> String {
970    match (method, package, version) {
971        ("pip", Some(p), Some(v)) => format!("pypi:{p}=={v}"),
972        ("uv-tool", Some(p), Some(v)) => format!("pypi:{p}=={v}"),
973        (m, Some(p), Some(v)) => format!("{m}:{p}@{v}"),
974        (m, Some(p), None) => format!("{m}:{p}"),
975        (m, None, Some(v)) => format!("{m}@{v}"),
976        (m, None, None) => m.to_string(),
977    }
978}
979
980/// Infer how a binary was installed from its real path.
981pub fn detect_install(real: &Path, install: &[InstallMethod]) -> (Option<String>, Option<String>) {
982    let s = real.to_string_lossy().replace('\\', "/");
983    if s.contains("/node_modules/") {
984        let pkg = npm_package_name(real).or_else(|| find_method(install, "npm").and_then(|m| m.package.clone()));
985        return (Some("npm".into()), pkg);
986    }
987    if let Some(rest) = s.split("/Caskroom/").nth(1) {
988        return (Some("brew-cask".into()), rest.split('/').next().map(str::to_string));
989    }
990    if let Some(rest) = s.split("/Cellar/").nth(1) {
991        return (Some("brew".into()), rest.split('/').next().map(str::to_string));
992    }
993    if s.contains("/uv/tools/") {
994        let pkg = s.split("/uv/tools/").nth(1).and_then(|r| r.split('/').next()).map(str::to_string);
995        return (Some("uv-tool".into()), pkg);
996    }
997    if s.contains("/site-packages/") || s.contains("/pipx/venvs/") {
998        return (Some("pip".into()), find_method(install, "pip").and_then(|m| m.package.clone()));
999    }
1000    for m in install {
1001        if m.path_hints.iter().any(|h| s.contains(h.as_str())) {
1002            return (Some(m.method.clone()), m.package.clone());
1003        }
1004    }
1005    (None, None)
1006}
1007
1008fn find_method<'a>(install: &'a [InstallMethod], method: &str) -> Option<&'a InstallMethod> {
1009    install.iter().find(|m| m.method == method)
1010}
1011
1012/// The `name` of the nearest `package.json` above a file inside `node_modules`.
1013fn npm_package_name(file: &Path) -> Option<String> {
1014    let mut dir = file.parent();
1015    while let Some(d) = dir {
1016        let pj = d.join("package.json");
1017        if pj.is_file() {
1018            let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&pj).ok()?).ok()?;
1019            if let Some(name) = v.get("name").and_then(|n| n.as_str()) {
1020                return Some(name.to_string());
1021            }
1022        }
1023        if d.file_name().is_some_and(|n| n == "node_modules") {
1024            break;
1025        }
1026        dir = d.parent();
1027    }
1028    None
1029}
1030
1031/// Run a command with a timeout, returning stdout+stderr when it exits 0. `None` when
1032/// it can't start, times out, or fails.
1033pub fn run_capture(bin: &Path, args: &[String], ctx: &Ctx, timeout: Duration) -> Option<String> {
1034    let mut cmd = Command::new(bin);
1035    cmd.args(args).current_dir(&ctx.cwd).env_clear().envs(&ctx.env).stdin(Stdio::null());
1036    let out = crate::process::run(cmd, &crate::process::Limits::timeout(timeout)).ok()?;
1037    if !out.success {
1038        return None;
1039    }
1040    let mut text = out.stdout_text();
1041    text.push('\n');
1042    text.push_str(&out.stderr_text());
1043    Some(text)
1044}
1045
1046/// The first dotted version number in `text`, e.g. `2.1.4` or `2026.05.28-a70ca7c`.
1047/// ANSI escape sequences are skipped.
1048pub fn extract_version(text: &str) -> Option<String> {
1049    let clean = strip_ansi(text);
1050    let b = clean.as_bytes();
1051    let mut i = 0;
1052    while i < b.len() {
1053        let boundary = i == 0
1054            || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'.')
1055            || (matches!(b[i - 1], b'v' | b'V') && (i == 1 || !b[i - 2].is_ascii_alphanumeric()));
1056        if b[i].is_ascii_digit() && boundary {
1057            let start = i;
1058            let mut dots = 0;
1059            while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && i + 1 < b.len() && b[i + 1].is_ascii_digit())) {
1060                if b[i] == b'.' {
1061                    dots += 1;
1062                }
1063                i += 1;
1064            }
1065            if dots >= 1 {
1066                // optional pre-release / build suffix
1067                if i < b.len() && (b[i] == b'-' || b[i] == b'+') && i + 1 < b.len() && b[i + 1].is_ascii_alphanumeric() {
1068                    i += 1;
1069                    while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') {
1070                        i += 1;
1071                    }
1072                }
1073                return Some(clean[start..i].trim_end_matches('.').to_string());
1074            }
1075        }
1076        i += 1;
1077    }
1078    None
1079}
1080
1081fn strip_ansi(s: &str) -> String {
1082    let mut out = String::with_capacity(s.len());
1083    let mut chars = s.chars().peekable();
1084    while let Some(c) = chars.next() {
1085        if c == '\u{1b}' {
1086            if chars.peek() == Some(&'[') {
1087                chars.next();
1088                for n in chars.by_ref() {
1089                    if n.is_ascii_alphabetic() {
1090                        break;
1091                    }
1092                }
1093            }
1094            continue;
1095        }
1096        out.push(c);
1097    }
1098    out
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104
1105    #[test]
1106    fn builtins_parse() {
1107        let r = Registry::builtin();
1108        assert!(r.harnesses.len() >= 10);
1109        for id in ["claude-code", "codex", "pi", "opencode", "hermes", "cursor-agent"] {
1110            assert_eq!(r.harness(id).unwrap().support, "full", "{id}");
1111        }
1112        for h in &r.harnesses {
1113            let id = &h.id;
1114            for (fid, f) in &h.files {
1115                assert!(["json", "jsonc", "json5", "toml", "yaml"].contains(&f.format.as_str()), "{id}.{fid}");
1116                assert!(["whole", "pointers", "record"].contains(&f.restore.as_str()), "{id}.{fid}");
1117            }
1118            // every `<file>#<pointer>` reference names a declared file
1119            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);
1120            for r in refs {
1121                if let Some((file, _)) = r.split_once('#') {
1122                    assert!(h.files.contains_key(file), "{id}: {r}");
1123                }
1124            }
1125            for lp in &h.local_provider {
1126                assert!(h.files.contains_key(&lp.file), "{id}: local-provider file {}", lp.file);
1127                assert!(lp.runtimes.iter().all(|rt| r.runtime(rt).is_some()), "{id}: {:?}", lp.runtimes);
1128            }
1129        }
1130        // autonomous agents are agent CLIs, grouped apart in docs and `hlock harness list`
1131        for id in ["hermes", "openclaw", "nanobot", "picoclaw", "zeroclaw", "ironclaw"] {
1132            let h = r.harness(id).unwrap();
1133            assert!(h.autonomous && h.kind == "cli" && h.open_source == Some(true), "{id}");
1134        }
1135        assert_eq!(r.harness("clawdbot").unwrap().id, "openclaw");
1136        assert!(!r.harness("claude-code").unwrap().autonomous);
1137        for fw in ["pydantic-ai", "deepagents", "langgraph", "openai-agents", "claude-agent-sdk"] {
1138            let h = r.harness(fw).unwrap();
1139            assert_eq!(h.kind, "framework", "{fw}");
1140            assert!(h.packages.iter().all(|p| p.starts_with("pypi:")), "{fw}");
1141        }
1142        assert!(r.harnesses.iter().filter(|h| h.kind == "cli").count() >= 10);
1143        // `hlock run` needs every agent CLI's non-interactive mode
1144        for h in r.harnesses.iter().filter(|h| h.kind == "cli") {
1145            assert!(h.launch.prompt.iter().any(|a| a.contains("{prompt}")), "{}", h.id);
1146        }
1147        assert!(r.runtime("ollama").is_some());
1148        assert_eq!(r.harness("claude").unwrap().id, "claude-code");
1149        assert_eq!(r.runtime("lms").unwrap().id, "lmstudio");
1150    }
1151
1152    #[test]
1153    fn versions() {
1154        assert_eq!(extract_version("2.1.285 (Claude Code)").as_deref(), Some("2.1.285"));
1155        assert_eq!(extract_version("Hermes Agent v0.20.0 (2026.8.3)").as_deref(), Some("0.20.0"));
1156        assert_eq!(extract_version("2026.05.28-a70ca7c").as_deref(), Some("2026.05.28-a70ca7c"));
1157        assert_eq!(extract_version("Warning: client version is 0.35.1").as_deref(), Some("0.35.1"));
1158        assert_eq!(extract_version("\u{1b}[38;5;166m art \u{1b}[0m\nCLI commit: 1.2.3").as_deref(), Some("1.2.3"));
1159        assert_eq!(extract_version("no version"), None);
1160    }
1161
1162    #[test]
1163    fn install_detection() {
1164        let install = vec![InstallMethod {
1165            method: "script".into(),
1166            path_hints: vec!["/.local/share/cursor-agent/".into()],
1167            ..Default::default()
1168        }];
1169        let (m, p) = detect_install(Path::new("/opt/homebrew/Caskroom/claude-code/2.1.285/claude"), &[]);
1170        assert_eq!((m.as_deref(), p.as_deref()), (Some("brew-cask"), Some("claude-code")));
1171        let (m, p) = detect_install(Path::new("/opt/homebrew/Cellar/opencode/1.18.18/bin/opencode"), &[]);
1172        assert_eq!((m.as_deref(), p.as_deref()), (Some("brew"), Some("opencode")));
1173        let (m, _) = detect_install(Path::new("/home/u/.local/share/cursor-agent/versions/x/cursor-agent"), &install);
1174        assert_eq!(m.as_deref(), Some("script"));
1175        assert_eq!(source_spec("npm", Some("@openai/codex"), Some("0.5.0")), "npm:@openai/codex@0.5.0");
1176        assert_eq!(source_spec("pip", Some("vllm"), Some("0.11.2")), "pypi:vllm==0.11.2");
1177    }
1178
1179    fn ctx_with(env: &[(&str, &str)]) -> Ctx {
1180        Ctx {
1181            home: PathBuf::from("/home/u"),
1182            cwd: PathBuf::from("/work"),
1183            env: env.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
1184            path: vec![],
1185        }
1186    }
1187
1188    #[test]
1189    fn config_roots_and_isolation() {
1190        let r = Registry::builtin();
1191        let claude = r.harness("claude-code").unwrap();
1192        let ctx = ctx_with(&[]);
1193        assert_eq!(claude.config_root(&ctx), PathBuf::from("/home/u/.claude"));
1194        assert_eq!(claude.file_path("state", &ctx).unwrap(), PathBuf::from("/home/u/.claude.json"));
1195        assert_eq!(claude.file_path("project-mcp", &ctx).unwrap(), PathBuf::from("/work/.mcp.json"));
1196        let iso = ctx_with(&[("CLAUDE_CONFIG_DIR", "/tmp/env/claude")]);
1197        assert_eq!(claude.file_path("state", &iso).unwrap(), PathBuf::from("/tmp/env/claude/.claude.json"));
1198
1199        let oc = r.harness("opencode").unwrap();
1200        let xdg = ctx_with(&[("XDG_CONFIG_HOME", "/tmp/env/xdg")]);
1201        assert_eq!(oc.config_root(&xdg), PathBuf::from("/tmp/env/xdg/opencode"));
1202        let (var, val) = oc.isolation_env(Path::new("/tmp/env/xdg/opencode")).unwrap();
1203        assert_eq!((var.as_str(), val), ("XDG_CONFIG_HOME", PathBuf::from("/tmp/env/xdg")));
1204    }
1205
1206    #[test]
1207    fn selection_precedence() {
1208        let r = Registry::builtin();
1209        let ctx = ctx_with(&[("CLAUDECODE", "1"), ("HLOCK_HARNESS", "pi")]);
1210        assert_eq!(r.select(Some("codex"), Some("opencode"), &ctx).unwrap().id, "codex");
1211        assert_eq!(r.select(None, Some("opencode"), &ctx).unwrap().id, "pi");
1212        let ctx = ctx_with(&[("CLAUDECODE", "1")]);
1213        assert_eq!(r.select(None, Some("opencode"), &ctx).unwrap().id, "opencode");
1214        let s = r.select(None, None, &ctx).unwrap();
1215        assert_eq!((s.id.as_str(), s.reason.as_str()), ("claude-code", "running inside it"));
1216        assert!(r.select(Some("nope"), None, &ctx).unwrap_err().to_string().contains("known:"));
1217    }
1218
1219    #[test]
1220    fn project_harnesses() {
1221        let m = crate::manifest::Manifest::from_str(
1222            r#"
1223[target]
1224harness = "triage"
1225
1226[harnesses.triage]
1227framework = "pydantic-ai"
1228entrypoint = "triage.agent:run"
1229
1230[harnesses.loop]
1231command = ["./bin/agent", "{prompt}"]
1232packages = ["pypi:httpx"]
1233"#,
1234        )
1235        .unwrap();
1236        let mut r = Registry::builtin();
1237        r.add_project_harnesses(&m).unwrap();
1238        let t = r.harness("triage").unwrap();
1239        assert_eq!(t.kind, "custom");
1240        assert_eq!(t.run.entrypoint.as_deref(), Some("triage.agent:run"));
1241        assert!(t.packages.contains(&"pypi:pydantic-ai-slim".to_string()));
1242        assert_eq!(r.harness("loop").unwrap().run.command[0], "./bin/agent");
1243        assert_eq!(r.select(None, m.target_harness().as_deref(), &ctx_with(&[])).unwrap().id, "triage");
1244
1245        let bad = crate::manifest::Manifest::from_str("[harnesses.x]\nframework = \"nope\"\nentrypoint = \"a:b\"\n").unwrap();
1246        assert!(Registry::builtin().add_project_harnesses(&bad).unwrap_err().to_string().contains("not a known framework"));
1247        let bare = crate::manifest::Manifest::from_str("[harnesses.x]\nframework = \"langgraph\"\n").unwrap();
1248        assert!(Registry::builtin().add_project_harnesses(&bare).unwrap_err().to_string().contains("entrypoint"));
1249    }
1250
1251    #[test]
1252    fn user_profiles_override_and_extend() {
1253        let dir = std::env::temp_dir().join(format!("hlock-prof-{}", std::process::id()));
1254        std::fs::create_dir_all(dir.join("harnesses")).unwrap();
1255        std::fs::write(dir.join("harnesses/my.toml"), "id = \"my-agent\"\nbinaries = [\"my-agent\"]\n").unwrap();
1256        std::fs::write(dir.join("harnesses/pi.toml"), "id = \"pi\"\nname = \"Pi (fork)\"\n").unwrap();
1257        let mut r = Registry::builtin();
1258        r.extend_from_dir(&dir).unwrap();
1259        assert_eq!(r.harness("my-agent").unwrap().support, "basic");
1260        assert_eq!(r.harness("pi").unwrap().name, "Pi (fork)");
1261        assert_eq!(r.harnesses.iter().filter(|h| h.id == "pi").count(), 1);
1262        let _ = std::fs::remove_dir_all(&dir);
1263    }
1264
1265    #[test]
1266    fn project_profiles_only_add_and_never_run_by_path() {
1267        let dir = std::env::temp_dir().join(format!("hlock-prof-proj-{}", std::process::id()));
1268        let _ = std::fs::remove_dir_all(&dir);
1269        let pd = dir.join("proj/.hlock/profiles");
1270        std::fs::create_dir_all(pd.join("harnesses")).unwrap();
1271        std::fs::create_dir_all(pd.join("runtimes")).unwrap();
1272        std::fs::create_dir_all(dir.join("home")).unwrap();
1273        std::fs::write(pd.join("harnesses/pi.toml"), "id = \"pi\"\nname = \"Pi (repo)\"\nbinaries = [\"./evil\"]\n").unwrap();
1274        std::fs::write(
1275            pd.join("harnesses/mine.toml"),
1276            "id = \"mine\"\nbinaries = [\"./evil\", \"mine\"]\nversion = { args = [\"-c\", \"touch pwned\"] }\n[run]\npython = \"./py\"\n",
1277        )
1278        .unwrap();
1279        std::fs::write(pd.join("runtimes/ollama.toml"), "id = \"ollama\"\nbinaries = [\"./evil\"]\n").unwrap();
1280        let ctx = Ctx { home: dir.join("home"), cwd: dir.join("proj"), env: BTreeMap::new(), path: vec![] };
1281        let r = Registry::load(&ctx).unwrap();
1282        // built-ins aren't replaced by the repository
1283        assert_eq!(r.harness("pi").unwrap().origin, "builtin");
1284        assert_eq!(r.runtime("ollama").unwrap().origin, "builtin");
1285        // a new profile is added, without path-named programs or a non-version probe
1286        let mine = r.harness("mine").unwrap();
1287        assert_eq!(mine.binaries, ["mine"]);
1288        assert!(mine.version.args.is_empty() && mine.run.python.is_none());
1289        assert_eq!(r.notes.len(), 5, "{:?}", r.notes);
1290        assert!(r.notes.iter().any(|n| n.contains("can't replace the harness profile \"pi\"")));
1291        // the same files in a user directory keep full override power
1292        let mut user = Registry::builtin();
1293        user.extend_from_dir(&pd).unwrap();
1294        assert_eq!(user.harness("pi").unwrap().name, "Pi (repo)");
1295        let _ = std::fs::remove_dir_all(&dir);
1296    }
1297
1298    #[cfg(unix)]
1299    #[test]
1300    fn run_capture_is_none_on_failure() {
1301        let ctx = Ctx::from_process();
1302        let sh = ctx.which("sh").unwrap();
1303        let s = |a: &[&str]| a.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1304        assert!(run_capture(&sh, &s(&["-c", "echo 1.2.3"]), &ctx, VERSION_TIMEOUT).unwrap().contains("1.2.3"));
1305        assert!(run_capture(&sh, &s(&["-c", "echo 1.2.3; exit 1"]), &ctx, VERSION_TIMEOUT).is_none());
1306        // more than a pipe holds, on both streams, doesn't block
1307        let big = run_capture(&sh, &s(&["-c", "head -c 200000 /dev/zero; head -c 200000 /dev/zero >&2"]), &ctx, VERSION_TIMEOUT).unwrap();
1308        assert_eq!(big.len(), 400_001);
1309    }
1310}