Skip to main content

harness_lock_core/
sync.rs

1//! `hlock sync`: rehydrate a locked harness setup (SPEC §7.8.5).
2//!
3//! Syncing is a [`Plan`] of [`Step`]s, each `satisfied` (already true here),
4//! `auto` (hlock can do it), or `manual` (it needs the user, and the step says
5//! exactly what to run and why). Planning is side-effect free; [`run_step`]
6//! performs one `auto` step. Package-manager installs and model downloads are
7//! only `auto` when the caller opts in (`install`, `pull`); otherwise they're
8//! manual steps with the command to run.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16
17use crate::capture::{host_port, locate_model, read_config, render_config, tree_digest};
18use crate::error::{Error, Result};
19use crate::lockfile::{Lockfile, Native, Runtime};
20use crate::profiles::{probe_harness, probe_runtime, python_package_versions, run_capture, Ctx, HarnessProfile, Registry};
21
22/// Where a sync materializes the setup.
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[serde(default)]
25pub struct SyncOptions {
26    /// Materialize into an isolated config root under this directory instead of
27    /// the user's real one (the "virtual environment" `launch` uses).
28    pub prefix: Option<PathBuf>,
29    /// Materialize into a Docker build context: files land under `<dir>/home`.
30    pub docker: Option<PathBuf>,
31    /// Let hlock run package managers (npm, brew, uv, pip, install scripts).
32    pub install: bool,
33    /// Let hlock download model weights through installed runtimes.
34    pub pull: bool,
35    /// Also fetch models a provider lists but no locked role uses.
36    pub all_models: bool,
37    /// Remove skills in the harness's skill directory that aren't in the lock.
38    pub exact: bool,
39}
40
41/// One concrete thing an `auto` step does.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43#[serde(tag = "type", rename_all = "kebab-case")]
44pub enum Action {
45    /// Restore a vendored config file (whole, or only `pointers` merged into the target).
46    /// Secrets missing from the environment are taken from `path`, else from `fallback`
47    /// (the user's live config, when materializing into a prefix on the same machine).
48    WriteConfig {
49        path: PathBuf,
50        vendored: PathBuf,
51        format: String,
52        pointers: Vec<String>,
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        fallback: Option<PathBuf>,
55    },
56    /// Copy a vendored directory (a skill) into place.
57    CopyDir { from: PathBuf, to: PathBuf },
58    /// Copy a file (an instruction file) into place.
59    CopyFile { from: PathBuf, to: PathBuf },
60    /// Fetch a skill from git at a commit.
61    GitSkill { url: String, commit: String, subpath: String, to: PathBuf },
62    /// Run a command.
63    Run { argv: Vec<String>, env: BTreeMap<String, String> },
64    /// Move a path aside to `<path>.hlock-bak`.
65    Remove { path: PathBuf },
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69#[serde(default)]
70pub struct Step {
71    pub id: String,
72    /// `harness` | `plugin` | `config` | `skill` | `instructions` | `mcp` | `runtime` | `model` | `package` | `secret`
73    pub kind: String,
74    pub title: String,
75    /// `satisfied` | `auto` | `manual`
76    pub status: String,
77    /// Why it's manual, or what satisfied it.
78    pub reason: String,
79    pub action: Option<Action>,
80    /// The command to run by hand (manual steps), or the one `action` runs.
81    pub command: Option<String>,
82    /// A command that confirms the step worked.
83    pub verify: Option<String>,
84    /// Not needed for the locked roles (e.g. extra models a provider lists).
85    pub optional: bool,
86    pub size_bytes: Option<u64>,
87}
88
89impl Default for Step {
90    fn default() -> Self {
91        Step {
92            id: String::new(),
93            kind: String::new(),
94            title: String::new(),
95            status: "satisfied".into(),
96            reason: String::new(),
97            action: None,
98            command: None,
99            verify: None,
100            optional: false,
101            size_bytes: None,
102        }
103    }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107#[serde(default)]
108pub struct Plan {
109    pub harness: String,
110    pub harness_name: String,
111    /// The command that starts the harness (`claude`, `pi`, …).
112    pub binary: Option<String>,
113    pub version: Option<String>,
114    /// `home` (the user's real config), `prefix`, or `docker`.
115    pub mode: String,
116    /// The config root this plan writes into.
117    pub root: PathBuf,
118    /// Environment that points the harness at `root` (empty in `home` mode).
119    pub isolation: BTreeMap<String, String>,
120    pub steps: Vec<Step>,
121}
122
123impl Plan {
124    pub fn count(&self, status: &str) -> usize {
125        self.steps.iter().filter(|s| s.status == status && !s.optional).count()
126    }
127}
128
129// --------------------------------------------------------------------------- //
130// Placeholders
131// --------------------------------------------------------------------------- //
132
133const PH_OPEN: &str = "${HLOCK_SECRET_";
134
135/// Secret placeholder names in a string.
136pub(crate) fn placeholders(s: &str) -> Vec<String> {
137    let mut out = Vec::new();
138    let mut rest = s;
139    while let Some(i) = rest.find(PH_OPEN) {
140        let after = &rest[i + 2..];
141        match after.find('}') {
142            Some(j) => {
143                out.push(after[..j].to_string());
144                rest = &after[j + 1..];
145            }
146            None => break,
147        }
148    }
149    out
150}
151
152fn collect_placeholders(v: &Value, out: &mut BTreeSet<String>) {
153    match v {
154        Value::String(s) => out.extend(placeholders(s)),
155        Value::Array(a) => a.iter().for_each(|x| collect_placeholders(x, out)),
156        Value::Object(m) => m.values().for_each(|x| collect_placeholders(x, out)),
157        _ => {}
158    }
159}
160
161/// Fill placeholders from the environment, else keep the target's existing value
162/// at the same place (so a sync never overwrites a real secret with a
163/// placeholder). Returns the names still missing.
164fn fill(desired: &Value, actual: Option<&Value>, env: &BTreeMap<String, String>, missing: &mut BTreeSet<String>) -> Value {
165    match desired {
166        Value::String(s) if s.contains(PH_OPEN) => {
167            let mut out = s.clone();
168            let mut unfilled = Vec::new();
169            for name in placeholders(s) {
170                match env.get(&name).filter(|v| !v.is_empty()) {
171                    Some(v) => out = out.replace(&format!("${{{name}}}"), v),
172                    None => unfilled.push(name),
173                }
174            }
175            if unfilled.is_empty() {
176                return Value::String(out);
177            }
178            match actual.and_then(Value::as_str) {
179                Some(a) if !a.contains(PH_OPEN) && !a.is_empty() => Value::String(a.to_string()),
180                _ => {
181                    missing.extend(unfilled);
182                    Value::String(out)
183                }
184            }
185        }
186        Value::Object(m) => {
187            let am = actual.and_then(Value::as_object);
188            Value::Object(m.iter().map(|(k, v)| (k.clone(), fill(v, am.and_then(|a| a.get(k)), env, missing))).collect())
189        }
190        Value::Array(items) => {
191            let aa = actual.and_then(Value::as_array);
192            Value::Array(items.iter().enumerate().map(|(i, v)| fill(v, aa.and_then(|a| a.get(i)), env, missing)).collect())
193        }
194        other => other.clone(),
195    }
196}
197
198pub(crate) fn set_pointer(root: &mut Value, ptr: &str, v: Value) {
199    if ptr.is_empty() {
200        *root = v;
201        return;
202    }
203    let parts: Vec<String> = ptr.trim_start_matches('/').split('/').map(|p| p.replace("~1", "/").replace("~0", "~")).collect();
204    let mut cur = root;
205    for (i, part) in parts.iter().enumerate() {
206        if !cur.is_object() {
207            *cur = json!({});
208        }
209        let obj = cur.as_object_mut().expect("object");
210        if i == parts.len() - 1 {
211            obj.insert(part.clone(), v);
212            return;
213        }
214        cur = obj.entry(part.clone()).or_insert_with(|| json!({}));
215    }
216}
217
218/// Merge `b` under `a`: keys missing from `a` come from `b` (used to combine the
219/// target's existing values with the live config's when filling secrets).
220fn overlay(a: Option<Value>, b: Option<Value>) -> Option<Value> {
221    match (a, b) {
222        (Some(Value::Object(mut am)), Some(Value::Object(bm))) => {
223            for (k, bv) in bm {
224                let merged = overlay(am.remove(&k), Some(bv));
225                if let Some(v) = merged {
226                    am.insert(k, v);
227                }
228            }
229            Some(Value::Object(am))
230        }
231        (Some(Value::String(s)), b) if s.contains(PH_OPEN) => b.or(Some(Value::String(s))),
232        (Some(a), _) => Some(a),
233        (None, b) => b,
234    }
235}
236
237/// The file contents a `WriteConfig` would produce, and the secrets it lacks.
238fn desired_config(vendored: &Path, format: &str, pointers: &[String], target: &Path, fallback: Option<&Path>, env: &BTreeMap<String, String>) -> Result<(Value, BTreeSet<String>)> {
239    let src_fmt = if pointers.is_empty() { if format == "jsonc" { "json" } else { format } } else { "json" };
240    let desired = read_config(vendored, src_fmt)?.ok_or_else(|| Error::Lockfile(format!("vendored file missing: {}", vendored.display())))?;
241    let actual = read_config(target, format).ok().flatten();
242    let live = fallback.and_then(|f| read_config(f, format).ok().flatten());
243    let secrets_from = overlay(actual.clone(), live);
244    let mut missing = BTreeSet::new();
245    if pointers.is_empty() {
246        return Ok((fill(&desired, secrets_from.as_ref(), env, &mut missing), missing));
247    }
248    let mut out = actual.clone().filter(Value::is_object).unwrap_or_else(|| json!({}));
249    for p in pointers {
250        if let Some(sub) = desired.get(p) {
251            let cur = secrets_from.as_ref().and_then(|a| a.pointer(p));
252            set_pointer(&mut out, p, fill(sub, cur, env, &mut missing));
253        }
254    }
255    Ok((out, missing))
256}
257
258// --------------------------------------------------------------------------- //
259// Targets
260// --------------------------------------------------------------------------- //
261
262/// The context a plan writes into, and the environment that selects it.
263pub(crate) fn target_ctx(profile: &HarnessProfile, ctx: &Ctx, opts: &SyncOptions) -> (Ctx, String, BTreeMap<String, String>) {
264    let mut t = ctx.clone();
265    let mut iso = BTreeMap::new();
266    if let Some(dir) = &opts.docker {
267        t.home = dir.join("home");
268        if let Some(var) = &profile.config_root.env {
269            t.env.remove(var);
270        }
271        return (t, "docker".into(), iso);
272    }
273    if let Some(prefix) = &opts.prefix {
274        let base = if prefix.is_absolute() { prefix.clone() } else { ctx.cwd.join(prefix) };
275        let base = base.join(&profile.id);
276        let root = match &profile.config_root.suffix {
277            Some(sfx) => base.join("xdg").join(sfx),
278            None => base.join("config"),
279        };
280        match profile.isolation_env(&root) {
281            Some((var, value)) => {
282                t.env.insert(var.clone(), value.to_string_lossy().into_owned());
283                iso.insert(var, value.to_string_lossy().into_owned());
284            }
285            None => {
286                t.home = base.join("home");
287                iso.insert("HOME".into(), t.home.to_string_lossy().into_owned());
288            }
289        }
290        return (t, "prefix".into(), iso);
291    }
292    (t, "home".into(), iso)
293}
294
295/// Expand a lock's portable path (`{root}/…`, `~/…`, relative) for a target.
296fn expand(p: &str, root: &Path, t: &Ctx) -> PathBuf {
297    t.expand(p, Some(root))
298}
299
300pub(crate) fn lock_dir_of(lock_path: &Path, ctx: &Ctx) -> PathBuf {
301    match lock_path.parent().filter(|p| !p.as_os_str().is_empty()) {
302        Some(p) if p.is_absolute() => p.to_path_buf(),
303        Some(p) => ctx.cwd.join(p),
304        None => ctx.cwd.clone(),
305    }
306}
307
308/// A path for display: `~/…` under the home directory, relative under the project.
309pub(crate) fn pretty(p: &Path, ctx: &Ctx) -> String {
310    if let Ok(rel) = p.strip_prefix(&ctx.cwd) {
311        return rel.to_string_lossy().into_owned();
312    }
313    if let Ok(rel) = p.strip_prefix(&ctx.home) {
314        return format!("~/{}", rel.to_string_lossy());
315    }
316    p.to_string_lossy().into_owned()
317}
318
319pub(crate) fn shell_join(argv: &[String]) -> String {
320    argv.iter()
321        .map(|a| if a.chars().all(|c| c.is_ascii_alphanumeric() || "-_./:@=+".contains(c)) { a.clone() } else { format!("'{}'", a.replace('\'', "'\\''")) })
322        .collect::<Vec<_>>()
323        .join(" ")
324}
325
326/// The command that installs a package by method, or `None` for app/manual installs.
327pub(crate) fn install_command(method: &str, package: Option<&str>, version: Option<&str>, script: Option<&str>) -> Option<Vec<String>> {
328    let v = |sep: &str| version.map(|v| format!("{sep}{v}")).unwrap_or_default();
329    match (method, package) {
330        ("npm", Some(p)) => Some(vec!["npm".into(), "install".into(), "-g".into(), format!("{p}{}", v("@"))]),
331        ("brew", Some(p)) => Some(vec!["brew".into(), "install".into(), p.into()]),
332        ("brew-cask", Some(p)) => Some(vec!["brew".into(), "install".into(), "--cask".into(), p.into()]),
333        ("uv-tool", Some(p)) => Some(vec!["uv".into(), "tool".into(), "install".into(), format!("{p}{}", v("=="))]),
334        ("pip", Some(p)) => Some(vec!["python3".into(), "-m".into(), "pip".into(), "install".into(), format!("{p}{}", v("=="))]),
335        ("script", _) => script.map(|s| vec!["sh".into(), "-c".into(), s.replace("{version}", version.unwrap_or(""))]),
336        _ => None,
337    }
338}
339
340/// Split a lock `source` (`npm:pkg@1.2`, `brew-cask:claude-code@2.1`, `script@0.20`) into method and package.
341fn parse_source(src: &str) -> (String, Option<String>) {
342    match src.split_once(':') {
343        Some((m, rest)) if !m.contains('@') => {
344            let pkg = if m == "pypi" {
345                rest.split("==").next().unwrap_or(rest).to_string()
346            } else {
347                let (scope, body) = rest.strip_prefix('@').map(|b| ("@", b)).unwrap_or(("", rest));
348                format!("{scope}{}", body.split('@').next().unwrap_or(body))
349            };
350            (if m == "pypi" { "pip".into() } else { m.to_string() }, Some(pkg))
351        }
352        _ => (src.split('@').next().unwrap_or(src).to_string(), None),
353    }
354}
355
356fn install_step(id: String, kind: &str, what: &str, version: Option<&str>, source: Option<&str>, profile_install: &[crate::profiles::InstallMethod], opts: &SyncOptions, verify: Option<String>, ctx: &Ctx) -> Step {
357    let (method, package) = source.map(parse_source).unwrap_or_default();
358    let script = profile_install.iter().find(|m| m.method == "script").and_then(|m| m.command.clone());
359    let manual_hint = profile_install.iter().find(|m| m.method == method || m.method == "app" || m.method == "manual").and_then(|m| m.command.clone());
360    let mut argv = install_command(&method, package.as_deref(), version, script.as_deref());
361    if argv.is_none() {
362        // the recorded method can't be scripted here; fall back to the profile's first scriptable one
363        argv = profile_install.iter().find_map(|m| install_command(&m.method, m.package.as_deref(), version, m.command.as_deref()).filter(|a| ctx.which(&a[0]).is_some()));
364    }
365    let vtxt = version.map(|v| format!(" {v}")).unwrap_or_default();
366    let mut step = Step { id, kind: kind.into(), title: format!("Install {what}{vtxt}"), verify, ..Default::default() };
367    match argv {
368        Some(a) => {
369            step.command = Some(if a[0] == "sh" && a.len() == 3 { a[2].clone() } else { shell_join(&a) });
370            let tool_ok = ctx.which(&a[0]).is_some();
371            if opts.install && tool_ok {
372                step.status = "auto".into();
373                step.action = Some(Action::Run { argv: a, env: BTreeMap::new() });
374            } else {
375                step.status = "manual".into();
376                step.reason = if !tool_ok {
377                    format!("needs `{}`, which isn't on PATH", a[0])
378                } else {
379                    "package installs run only with --install".into()
380                };
381                if method == "brew" || method == "brew-cask" {
382                    step.reason.push_str("; Homebrew installs the current version, which may differ from the pin");
383                }
384            }
385        }
386        None => {
387            step.status = "manual".into();
388            step.command = manual_hint;
389            step.reason = format!("{what} is installed by hand");
390        }
391    }
392    step
393}
394
395// --------------------------------------------------------------------------- //
396// Planning
397// --------------------------------------------------------------------------- //
398
399/// Build the sync plan for a lock. Nothing is changed.
400pub fn plan(lock: &Lockfile, lock_path: &Path, reg: &Registry, ctx: &Ctx, opts: &SyncOptions) -> Result<Plan> {
401    let profile = reg.require_harness(&lock.harness.name)?;
402    let (t, mode, isolation) = target_ctx(profile, ctx, opts);
403    let root = profile.config_root(&t);
404    let lock_dir = lock_dir_of(lock_path, ctx);
405    let docker = mode == "docker";
406    let mut steps: Vec<Step> = Vec::new();
407    let mut missing_secrets: BTreeSet<String> = BTreeSet::new();
408    let mut all_secrets: BTreeSet<String> = BTreeSet::new();
409
410    // ---- harness ----
411    if profile.kind == "cli" && !docker {
412        let probe = probe_harness(profile, ctx);
413        let want = lock.harness.version.as_deref();
414        let bin = profile.binaries.first().cloned().unwrap_or_default();
415        let verify = Some(format!("{bin} {}", profile.version.args.join(" ")));
416        let matches = probe.installed && (want.is_none() || probe.version.as_deref() == want);
417        if matches {
418            steps.push(Step {
419                id: "harness".into(),
420                kind: "harness".into(),
421                title: format!("{} {}", profile.name, probe.version.clone().unwrap_or_default()),
422                reason: "installed".into(),
423                ..Default::default()
424            });
425        } else {
426            let mut s = install_step("harness".into(), "harness", &profile.name, want, lock.harness.source.as_deref(), &profile.install, opts, verify, ctx);
427            if probe.installed {
428                s.title = format!("Install {} {} (found {})", profile.name, want.unwrap_or("?"), probe.version.unwrap_or_else(|| "?".into()));
429            }
430            steps.push(s);
431        }
432    }
433
434    // ---- plugins and packages ----
435    let harness_env = isolation.clone();
436    for n in &lock.native {
437        if let Some(s) = native_step(n, profile, &t, &root, harness_env.clone(), opts, ctx) {
438            steps.push(s);
439        }
440    }
441
442    // ---- config files ----
443    for c in &lock.config {
444        let Some(target) = profile.file_path(&c.id, &t) else { continue };
445        let display = pretty(&target, ctx);
446        let Some(vend) = &c.vendored else {
447            if c.restore == "record" {
448                let ok = std::fs::read(&target).map(|b| Some(crate::hash::sha256_bytes(&b)) == c.digest).unwrap_or(false);
449                steps.push(Step {
450                    id: format!("config:{}", c.id),
451                    kind: "config".into(),
452                    title: format!("Check {}", c.path),
453                    status: if ok { "satisfied" } else { "manual" }.into(),
454                    reason: if ok { "matches the lock".into() } else { "project file differs from the lock; it comes from the repo, so update it there".into() },
455                    optional: !ok,
456                    ..Default::default()
457                });
458            }
459            continue;
460        };
461        let vendored = lock_dir.join(vend);
462        let fallback = (mode == "prefix").then(|| profile.file_path(&c.id, ctx)).flatten();
463        let (desired, missing) = desired_config(&vendored, &c.format, &c.pointers, &target, fallback.as_deref(), &t.env)?;
464        collect_placeholders(&read_config(&vendored, if c.pointers.is_empty() && c.format != "jsonc" { &c.format } else { "json" })?.unwrap_or_default(), &mut all_secrets);
465        missing_secrets.extend(missing.iter().cloned());
466        let actual = read_config(&target, &c.format).ok().flatten();
467        let same = match (&actual, c.pointers.is_empty()) {
468            (Some(a), true) => a == &desired,
469            (Some(a), false) => c.pointers.iter().all(|p| a.pointer(p) == desired.pointer(p)),
470            (None, _) => false,
471        };
472        let what = if c.pointers.is_empty() { display.clone() } else { format!("{} {}", display, c.pointers.join(",")) };
473        let step_id = if c.pointers.is_empty() { format!("config:{}", c.id) } else { format!("config:{}#{}", c.id, c.pointers.join(",")) };
474        steps.push(Step {
475            id: step_id,
476            kind: "config".into(),
477            title: if same { what } else if actual.is_some() { format!("Update {what}") } else { format!("Write {what}") },
478            status: if same { "satisfied" } else { "auto" }.into(),
479            reason: if same { "matches the lock".into() } else if actual.is_some() { "the current file is kept as .hlock-bak".into() } else { String::new() },
480            action: (!same).then(|| Action::WriteConfig { path: target.clone(), vendored, format: c.format.clone(), pointers: c.pointers.clone(), fallback }),
481            ..Default::default()
482        });
483    }
484
485    // ---- skills ----
486    let write_dir = profile.skills.write.as_ref().map(|w| expand(w, &root, &t));
487    let mut locked_skill_dirs = BTreeSet::new();
488    for s in &lock.skill {
489        if matches!(s.origin.as_str(), "harness-builtin" | "bundled") {
490            continue; // provided by the harness / its plugins
491        }
492        let Some(path) = &s.path else { continue };
493        let mut dest = expand(path, &root, &t);
494        let outside_root = !path.starts_with("{root}") && (path.starts_with('~') || Path::new(path).is_absolute());
495        if mode != "home" && outside_root {
496            if let Some(w) = &write_dir {
497                dest = w.join(&s.name);
498            }
499        }
500        locked_skill_dirs.insert(dest.clone());
501        let ok = dest.join("SKILL.md").is_file() && Some(tree_digest(&dest)) == s.digest;
502        let shown = pretty(&dest, ctx);
503        let mut step = Step { id: format!("skill:{}", s.name), kind: "skill".into(), title: format!("{} → {shown}", s.name), ..Default::default() };
504        if ok {
505            step.reason = "matches the lock".into();
506        } else if let Some(v) = &s.vendored {
507            step.status = "auto".into();
508            step.title = format!("Copy skill {} → {shown}", s.name);
509            step.action = Some(Action::CopyDir { from: lock_dir.join(v), to: dest });
510        } else if let Some(src) = s.source.as_deref().and_then(|x| x.strip_prefix("git+")) {
511            let (url_commit, sub) = src.split_once("#path=").unwrap_or((src, ""));
512            let (url, commit) = url_commit.rsplit_once('@').unwrap_or((url_commit, "HEAD"));
513            let has_git = ctx.which("git").is_some();
514            step.status = if has_git { "auto" } else { "manual" }.into();
515            step.title = format!("Fetch skill {} from {url}@{}", s.name, &commit[..commit.len().min(10)]);
516            step.reason = if has_git { String::new() } else { "needs git".into() };
517            step.command = Some(format!("git clone {url} && git -C <clone> checkout {commit}  # then copy {sub}"));
518            step.action = has_git.then(|| Action::GitSkill { url: url.into(), commit: commit.into(), subpath: sub.into(), to: dest });
519        } else {
520            step.status = "manual".into();
521            step.reason = if s.origin == "first-party" { "project skill missing or changed; it comes from the repo".into() } else { "no vendored copy or source to fetch it from".into() };
522        }
523        steps.push(step);
524    }
525    if opts.exact {
526        if let Some(w) = &write_dir {
527            for d in std::fs::read_dir(w).into_iter().flatten().flatten() {
528                let p = d.path();
529                if p.join("SKILL.md").is_file() && !locked_skill_dirs.contains(&p) {
530                    steps.push(Step {
531                        id: format!("skill-extra:{}", d.file_name().to_string_lossy()),
532                        kind: "skill".into(),
533                        title: format!("Remove skill {} (not in the lock)", d.file_name().to_string_lossy()),
534                        status: "auto".into(),
535                        reason: "--exact; moved aside to .hlock-bak".into(),
536                        action: Some(Action::Remove { path: p }),
537                        ..Default::default()
538                    });
539                }
540            }
541        }
542    }
543
544    // ---- instructions ----
545    for i in &lock.instructions {
546        let dest = expand(&i.name, &root, &t);
547        let ok = std::fs::read(&dest).map(|b| Some(crate::hash::sha256_bytes(&b)) == i.digest).unwrap_or(false);
548        let mut step = Step { id: format!("instructions:{}", i.name), kind: "instructions".into(), title: pretty(&dest, ctx), ..Default::default() };
549        if ok {
550            step.reason = "matches the lock".into();
551        } else if let Some(v) = &i.vendored {
552            step.status = "auto".into();
553            step.title = format!("Write {}", pretty(&dest, ctx));
554            step.action = Some(Action::CopyFile { from: lock_dir.join(v), to: dest });
555        } else {
556            step.status = "manual".into();
557            step.optional = true;
558            step.reason = "project instruction file differs from the lock; it comes from the repo".into();
559        }
560        steps.push(step);
561    }
562
563    // ---- MCP launchers ----
564    let mut launchers = BTreeSet::new();
565    for tool in lock.tool.iter().filter(|x| x.kind == "mcp" && x.enabled != Some(false)) {
566        if let Some(cmd) = &tool.command {
567            let bin = Path::new(cmd).file_name().map(|b| b.to_string_lossy().into_owned()).unwrap_or_default();
568            if launchers.insert(bin.clone()) && !docker {
569                let ok = ctx.which(cmd).is_some();
570                let users: Vec<&str> = lock.tool.iter().filter(|x| x.command.as_deref() == Some(cmd)).map(|x| x.name.as_str()).collect();
571                let hint = match bin.as_str() {
572                    "npx" | "node" | "npm" => Some("install Node.js: https://nodejs.org (or: brew install node)"),
573                    "uvx" | "uv" => Some("curl -LsSf https://astral.sh/uv/install.sh | sh"),
574                    "docker" => Some("install Docker: https://docs.docker.com/get-docker/"),
575                    _ => None,
576                };
577                steps.push(Step {
578                    id: format!("mcp:{bin}"),
579                    kind: "mcp".into(),
580                    title: format!("`{bin}` for MCP servers: {}", users.join(", ")),
581                    status: if ok { "satisfied" } else { "manual" }.into(),
582                    reason: if ok { "on PATH".into() } else { format!("`{bin}` isn't on PATH") },
583                    command: (!ok).then(|| hint.map(str::to_string)).flatten(),
584                    verify: Some(format!("{bin} --version")),
585                    ..Default::default()
586                });
587            }
588        }
589        for v in tool.env.values().chain(tool.headers.values()) {
590            all_secrets.extend(placeholders(v));
591        }
592    }
593
594    // ---- runtimes and models ----
595    let used: BTreeSet<(String, String)> = lock.llm.iter().filter_map(|l| l.runtime.clone().map(|r| (r, l.model.clone()))).collect();
596    for rt in &lock.runtime {
597        steps.extend(runtime_steps(rt, &used, reg, ctx, opts, docker));
598    }
599
600    // ---- secrets ----
601    let env_has = |n: &str| ctx.var(n).is_some();
602    for r in &lock.secret {
603        if !all_secrets.contains(&r.name) {
604            continue;
605        }
606        let missing = missing_secrets.contains(&r.name);
607        steps.push(Step {
608            id: format!("secret:{}", r.name),
609            kind: "secret".into(),
610            title: format!("{} ({})", r.name, r.used_by),
611            status: if missing && !docker { "manual" } else { "satisfied" }.into(),
612            reason: if docker {
613                "supplied at `docker run -e` time".into()
614            } else if !missing && env_has(&r.name) {
615                "set in the environment".into()
616            } else if !missing {
617                "kept the value already configured here".into()
618            } else {
619                "not set".into()
620            },
621            command: (missing && !docker).then(|| format!("export {}=…   # then re-run hlock sync", r.name)),
622            ..Default::default()
623        });
624    }
625
626    Ok(Plan {
627        harness: profile.id.clone(),
628        harness_name: profile.name.clone(),
629        binary: profile.binaries.first().cloned(),
630        version: lock.harness.version.clone(),
631        mode,
632        root,
633        isolation,
634        steps,
635    })
636}
637
638fn native_step(n: &Native, profile: &HarnessProfile, t: &Ctx, root: &Path, env: BTreeMap<String, String>, opts: &SyncOptions, ctx: &Ctx) -> Option<Step> {
639    let kind = n.kind.rsplit('.').next().unwrap_or(&n.kind);
640    let bin = profile.binaries.first().cloned().unwrap_or_default();
641    let have_bin = ctx.which(&bin).is_some();
642    let mut s = Step { id: format!("{}:{}", n.kind, n.name), kind: "plugin".into(), ..Default::default() };
643    let vtxt = n.version.as_deref().map(|v| format!(" {v}")).unwrap_or_default();
644    match (profile.id.as_str(), kind) {
645        ("claude-code", "marketplace") => {
646            let known = read_config(&root.join("plugins/known_marketplaces.json"), "json").ok().flatten().unwrap_or_default();
647            s.title = format!("Claude Code marketplace {}", n.name);
648            if known.get(&n.name).is_some() {
649                s.reason = "added".into();
650                return Some(s);
651            }
652            let src = n.source.as_deref().unwrap_or("");
653            let arg = src.strip_prefix("github:").or_else(|| src.strip_prefix("git+")).or_else(|| src.strip_prefix("path:")).unwrap_or(src).to_string();
654            let argv = vec![bin.clone(), "plugin".into(), "marketplace".into(), "add".into(), arg];
655            s.title = format!("Add Claude Code marketplace {}", n.name);
656            s.command = Some(shell_join(&argv));
657            s.status = if have_bin { "auto" } else { "manual" }.into();
658            s.reason = if have_bin { String::new() } else { "needs Claude Code installed".into() };
659            s.action = have_bin.then_some(Action::Run { argv, env });
660        }
661        ("claude-code", "plugin") => {
662            let installed = read_config(&root.join("plugins/installed_plugins.json"), "json").ok().flatten().unwrap_or_default();
663            let have = installed["plugins"][&n.name][0]["version"].as_str().map(str::to_string);
664            s.title = format!("Claude Code plugin {}{vtxt}", n.name);
665            if have.is_some() && (have == n.version || n.version.is_none()) {
666                s.reason = "installed".into();
667                return Some(s);
668            }
669            let mut argv = vec![bin.clone(), "plugin".into(), "install".into(), n.name.clone()];
670            if !n.enabled {
671                argv = vec!["sh".into(), "-c".into(), format!("{0} plugin install {1} && {0} plugin disable {1}", shell_join(&[bin.clone()]), shell_join(&[n.name.clone()]))];
672            }
673            s.title = format!("Install Claude Code plugin {}{vtxt}", n.name);
674            s.command = Some(if argv[0] == "sh" { argv[2].clone() } else { shell_join(&argv) });
675            s.status = if have_bin { "auto" } else { "manual" }.into();
676            s.reason = if have_bin { "installs the marketplace's current version".into() } else { "needs Claude Code installed".into() };
677            s.action = have_bin.then_some(Action::Run { argv, env });
678        }
679        ("pi", "package") => {
680            let lock = read_config(&root.join("npm/package-lock.json"), "json").ok().flatten().unwrap_or_default();
681            let have = lock["packages"][format!("node_modules/{}", n.name)]["version"].as_str().map(str::to_string);
682            s.title = format!("Pi package {}{vtxt}", n.name);
683            if have.is_some() && (have == n.version || n.version.is_none()) {
684                s.reason = "installed".into();
685                return Some(s);
686            }
687            let spec = n.source.clone().unwrap_or_else(|| format!("npm:{}", n.name));
688            let argv = vec![bin.clone(), "install".into(), spec];
689            s.title = format!("Install Pi package {}{vtxt}", n.name);
690            s.command = Some(shell_join(&argv));
691            s.status = if have_bin { "auto" } else { "manual" }.into();
692            s.reason = if have_bin { String::new() } else { "needs Pi installed".into() };
693            s.action = have_bin.then_some(Action::Run { argv, env });
694        }
695        ("opencode", "plugin") => {
696            s.title = format!("OpenCode plugin {}{vtxt}", n.name);
697            s.reason = "OpenCode installs plugins listed in its config on startup".into();
698        }
699        (_, "package") if n.source.as_deref().is_some_and(|x| x.starts_with("pypi:")) => {
700            let have = python_package_versions(&[n.name.clone()], profile.run.python.as_deref(), t).get(&n.name).cloned();
701            let pkg = n.name.trim_start_matches("pypi:");
702            s.kind = "package".into();
703            s.title = format!("Python package {pkg}{vtxt}");
704            if have.is_some() && have == n.version {
705                s.reason = "installed".into();
706                return Some(s);
707            }
708            let spec = format!("{pkg}{}", n.version.as_deref().map(|v| format!("=={v}")).unwrap_or_default());
709            let argv = if ctx.which("uv").is_some() { vec!["uv".into(), "pip".into(), "install".into(), spec] } else { vec!["python3".into(), "-m".into(), "pip".into(), "install".into(), spec] };
710            s.title = format!("Install Python package {pkg}{vtxt}");
711            s.command = Some(shell_join(&argv));
712            s.status = if opts.install { "auto" } else { "manual" }.into();
713            s.reason = if opts.install { String::new() } else { "package installs run only with --install".into() };
714            s.action = opts.install.then_some(Action::Run { argv, env: BTreeMap::new() });
715        }
716        _ => {
717            s.title = format!("{} {}{vtxt}", n.kind, n.name);
718            s.status = "manual".into();
719            s.reason = "hlock doesn't know how to install this kind yet".into();
720            s.command = n.source.clone();
721        }
722    }
723    Some(s)
724}
725
726fn runtime_steps(rt: &Runtime, used: &BTreeSet<(String, String)>, reg: &Registry, ctx: &Ctx, opts: &SyncOptions, docker: bool) -> Vec<Step> {
727    let mut out = Vec::new();
728    let base_id = rt.name.split('@').next().unwrap_or(&rt.name);
729    let Some(profile) = reg.runtime(base_id) else { return out };
730    let is_used = |m: &str| used.contains(&(rt.name.clone(), m.to_string()));
731    if rt.location != "local" || docker {
732        let models: Vec<&str> = rt.model.iter().map(|m| m.id.as_str()).collect();
733        let endpoint = rt.endpoint.clone().unwrap_or_default();
734        out.push(Step {
735            id: format!("runtime:{}", rt.name),
736            kind: "runtime".into(),
737            title: format!("{} at {endpoint}", profile.name),
738            status: "manual".into(),
739            reason: format!(
740                "{} server; make sure it's running and serving: {}",
741                if docker { "reached from the container via host.docker.internal; a host" } else { "a self-hosted" },
742                if models.is_empty() { "(no models listed)".to_string() } else { models.join(", ") }
743            ),
744            verify: (!endpoint.is_empty()).then(|| format!("curl -s {}/models", endpoint.trim_end_matches('/'))),
745            optional: !rt.model.iter().any(|m| is_used(&m.id)),
746            ..Default::default()
747        });
748        return out;
749    }
750    let probe = probe_runtime(profile, ctx);
751    let bin = profile.binaries.first().cloned().unwrap_or_default();
752    if probe.installed {
753        out.push(Step { id: format!("runtime:{}", rt.name), kind: "runtime".into(), title: format!("{} {}", profile.name, probe.version.unwrap_or_default()), reason: "installed".into(), ..Default::default() });
754    } else {
755        let mut s = install_step(format!("runtime:{}", rt.name), "runtime", &profile.name, rt.version.as_deref(), rt.source.as_deref(), &profile.install, opts, Some(format!("{bin} --version")), ctx);
756        if rt.source.is_none() {
757            s.command = profile.install.iter().find_map(|m| install_command(&m.method, m.package.as_deref(), None, m.command.as_deref()).map(|a| shell_join(&a)).or_else(|| m.command.clone()));
758        }
759        out.push(s);
760    }
761    for m in &rt.model {
762        let here = locate_model(profile, &m.id, ctx);
763        let optional = !is_used(&m.id) && !opts.all_models;
764        let size = m.size_bytes.map(|b| format!(" ({:.1} GB)", b as f64 / 1e9)).unwrap_or_default();
765        let mut s = Step { id: format!("model:{}/{}", rt.name, m.id), kind: "model".into(), title: format!("{} model {}{size}", profile.name, m.id), optional, size_bytes: m.size_bytes, ..Default::default() };
766        let matches = here.present && (m.digest.is_none() || here.digest == m.digest) && (m.revision.is_none() || here.revision == m.revision);
767        if matches {
768            s.reason = "on disk".into();
769        } else {
770            let repo = m.source.as_deref().and_then(|x| x.strip_prefix("hf://")).unwrap_or(&m.id);
771            let cmd = profile.pull.as_ref().map(|p| {
772                p.replace("{model}", &m.id).replace("{repo}", repo).replace("{revision}", m.revision.as_deref().unwrap_or("main")).replace(" {file}", "")
773            });
774            s.title = format!("Pull {} model {}{size}", profile.name, m.id);
775            s.command = cmd.clone();
776            s.verify = (bin == "ollama").then(|| "ollama list".to_string());
777            let can = cmd.is_some() && probe.installed;
778            if opts.pull && can && !optional {
779                s.status = "auto".into();
780                s.action = cmd.map(|c| Action::Run { argv: vec!["sh".into(), "-c".into(), c], env: BTreeMap::new() });
781            } else {
782                s.status = "manual".into();
783                s.reason = if !probe.installed {
784                    format!("needs {} installed first", profile.name)
785                } else if optional {
786                    "not used by a locked role; pull it with --all-models".into()
787                } else {
788                    "model downloads run only with --pull".into()
789                };
790                if here.present && m.digest.is_some() && here.digest != m.digest {
791                    s.reason = format!("a different build is on disk; {}", s.reason);
792                }
793            }
794        }
795        out.push(s);
796    }
797    out
798}
799
800// --------------------------------------------------------------------------- //
801// Running steps
802// --------------------------------------------------------------------------- //
803
804fn backup(path: &Path) -> Result<Option<PathBuf>> {
805    if !path.exists() {
806        return Ok(None);
807    }
808    let bak = PathBuf::from(format!("{}.hlock-bak", path.display()));
809    if bak.is_dir() {
810        std::fs::remove_dir_all(&bak)?;
811    } else if bak.exists() {
812        std::fs::remove_file(&bak)?;
813    }
814    std::fs::rename(path, &bak)?;
815    Ok(Some(bak))
816}
817
818pub(crate) fn copy_tree(from: &Path, to: &Path) -> Result<()> {
819    std::fs::create_dir_all(to)?;
820    for e in std::fs::read_dir(from)?.flatten() {
821        let (src, dst) = (e.path(), to.join(e.file_name()));
822        if src.is_dir() {
823            copy_tree(&src, &dst)?;
824        } else {
825            std::fs::copy(&src, &dst)?;
826        }
827    }
828    Ok(())
829}
830
831/// Perform one `auto` step and describe what happened.
832pub fn run_step(step: &Step, ctx: &Ctx) -> Result<String> {
833    let Some(action) = &step.action else { return Ok("nothing to do".into()) };
834    match action {
835        Action::WriteConfig { path, vendored, format, pointers, fallback } => {
836            let (desired, missing) = desired_config(vendored, format, pointers, path, fallback.as_deref(), &ctx.env)?;
837            let fmt = if format == "jsonc" { "json" } else { format.as_str() };
838            let text = render_config(&desired, fmt).map_err(Error::Profile)?;
839            if let Some(parent) = path.parent() {
840                std::fs::create_dir_all(parent)?;
841            }
842            let bak = if path.exists() {
843                let b = PathBuf::from(format!("{}.hlock-bak", path.display()));
844                std::fs::copy(path, &b)?;
845                Some(b)
846            } else {
847                None
848            };
849            std::fs::write(path, text)?;
850            let mut msg = format!("wrote {}", path.display());
851            if let Some(b) = bak {
852                msg.push_str(&format!(" (previous: {})", b.display()));
853            }
854            if !missing.is_empty() {
855                msg.push_str(&format!("; placeholders left for {}", missing.into_iter().collect::<Vec<_>>().join(", ")));
856            }
857            Ok(msg)
858        }
859        Action::CopyDir { from, to } => {
860            if !from.is_dir() {
861                return Err(Error::Lockfile(format!("vendored copy missing: {}", from.display())));
862            }
863            let bak = backup(to)?;
864            copy_tree(from, to)?;
865            Ok(match bak {
866                Some(b) => format!("copied to {} (previous: {})", to.display(), b.display()),
867                None => format!("copied to {}", to.display()),
868            })
869        }
870        Action::CopyFile { from, to } => {
871            if let Some(parent) = to.parent() {
872                std::fs::create_dir_all(parent)?;
873            }
874            if to.exists() {
875                std::fs::copy(to, format!("{}.hlock-bak", to.display()))?;
876            }
877            std::fs::copy(from, to)?;
878            Ok(format!("wrote {}", to.display()))
879        }
880        Action::GitSkill { url, commit, subpath, to } => {
881            let git = ctx.which("git").ok_or_else(|| Error::Profile("git isn't on PATH".into()))?;
882            let tmp = std::env::temp_dir().join(format!("hlock-skill-{}-{}", std::process::id(), crate::hash::sha256_hex(url.as_bytes()).get(..8).unwrap_or("x")));
883            let _ = std::fs::remove_dir_all(&tmp);
884            let t = Duration::from_secs(600);
885            let s = |a: &[&str]| a.iter().map(|x| x.to_string()).collect::<Vec<_>>();
886            run_capture(&git, &s(&["clone", "--quiet", url, &tmp.to_string_lossy()]), ctx, t).ok_or_else(|| Error::Profile(format!("git clone {url} failed")))?;
887            run_capture(&git, &s(&["-C", &tmp.to_string_lossy(), "checkout", "--quiet", commit]), ctx, t).ok_or_else(|| Error::Profile(format!("git checkout {commit} failed")))?;
888            let src = tmp.join(subpath);
889            if !src.join("SKILL.md").is_file() {
890                return Err(Error::Profile(format!("{url}@{commit} has no skill at {subpath}")));
891            }
892            backup(to)?;
893            copy_tree(&src, to)?;
894            let _ = std::fs::remove_dir_all(&tmp);
895            Ok(format!("fetched into {}", to.display()))
896        }
897        Action::Run { argv, env } => {
898            let mut cmd = std::process::Command::new(&argv[0]);
899            cmd.args(&argv[1..]).current_dir(&ctx.cwd).envs(env).stdin(std::process::Stdio::null());
900            let out = cmd.output().map_err(|e| Error::Profile(format!("cannot run {}: {e}", argv[0])))?;
901            let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
902            if out.status.success() {
903                Ok(format!("ran {}", shell_join(argv)))
904            } else {
905                let tail: Vec<&str> = text.lines().rev().take(8).collect::<Vec<_>>().into_iter().rev().collect();
906                Err(Error::Profile(format!("`{}` exited with {}:\n{}", shell_join(argv), out.status, tail.join("\n"))))
907            }
908        }
909        Action::Remove { path } => {
910            let bak = backup(path)?;
911            Ok(format!("moved {} aside to {}", path.display(), bak.map(|b| b.display().to_string()).unwrap_or_default()))
912        }
913    }
914}
915
916// --------------------------------------------------------------------------- //
917// Agent-guided rehydration and Docker
918// --------------------------------------------------------------------------- //
919
920/// An Agent Skill (`SKILL.md`) that walks an agent through the plan's remaining steps.
921pub fn rehydrate_skill(plan: &Plan, lock_name: &str) -> String {
922    let mut s = String::new();
923    s.push_str(&format!(
924        "---\nname: hlock-rehydrate\ndescription: Rehydrate the {} setup pinned in {lock_name} on this machine. Use when the user asks to set up, restore, or sync their agent harness from {lock_name}.\n---\n\n",
925        plan.harness_name
926    ));
927    s.push_str(&format!("# Rehydrate {} from `{lock_name}`\n\n", plan.harness_name));
928    s.push_str(&format!(
929        "This skill was generated by `hlock sync --skill` from `{lock_name}`. It lists what is still missing to reproduce the pinned {} {} setup, in order.\n\n",
930        plan.harness_name,
931        plan.version.as_deref().unwrap_or("")
932    ));
933    s.push_str("## Rules\n\n- Ask the user before installing system software or downloading large model weights, and say how big the download is.\n- Never print, log, or commit secret values. Ask the user to set secrets in their shell; don't type them yourself.\n- Run each step's check before moving on. If a step fails, stop and report the error.\n- Finish by running `hlock sync`, which performs the remaining automatic steps, then `hlock check`.\n\n");
934    let pending: Vec<&Step> = plan.steps.iter().filter(|st| st.status != "satisfied" && !st.optional).collect();
935    if pending.is_empty() {
936        s.push_str("## Steps\n\nNothing is missing. Run `hlock check` to confirm.\n");
937        return s;
938    }
939    s.push_str("## Steps\n\n");
940    for (i, st) in pending.iter().enumerate() {
941        s.push_str(&format!("{}. **{}**", i + 1, st.title));
942        if st.status == "auto" {
943            s.push_str(" (`hlock sync` does this)");
944        }
945        s.push('\n');
946        if !st.reason.is_empty() {
947            s.push_str(&format!("   - Why: {}\n", st.reason));
948        }
949        if let Some(c) = &st.command {
950            s.push_str(&format!("   - Run: `{c}`\n"));
951        }
952        if let Some(v) = &st.verify {
953            s.push_str(&format!("   - Check: `{v}`\n"));
954        }
955    }
956    let optional: Vec<&Step> = plan.steps.iter().filter(|st| st.status != "satisfied" && st.optional).collect();
957    if !optional.is_empty() {
958        s.push_str("\n## Optional\n\n");
959        for st in optional {
960            s.push_str(&format!("- {}{}\n", st.title, st.command.as_ref().map(|c| format!(": `{c}`")).unwrap_or_default()));
961        }
962    }
963    s.push_str("\n## Finish\n\n1. Run `hlock sync` and confirm it reports no remaining steps.\n2. Run `hlock check`.\n");
964    s
965}
966
967/// A Dockerfile and entrypoint for a plan built with `SyncOptions::docker`.
968/// Files the plan writes under `<context>/home` are copied to the image's home;
969/// secrets stay placeholders in the image and are filled from `-e` at start-up.
970pub fn dockerfile(plan: &Plan, lock: &Lockfile, profile: &HarnessProfile) -> (String, String) {
971    let mut d = String::new();
972    d.push_str(&format!("# Generated by `hlock sync --docker` for {} {}. Rebuild after re-pinning.\n", plan.harness_name, plan.version.as_deref().unwrap_or("")));
973    d.push_str("FROM node:22-bookworm\n");
974    d.push_str("RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git python3 && rm -rf /var/lib/apt/lists/*\n");
975    d.push_str("RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh\n");
976    let version = lock.harness.version.as_deref();
977    let (method, package) = lock.harness.source.as_deref().map(parse_source).unwrap_or_default();
978    // Linux has no Homebrew casks: prefer the recorded method, else the profile's npm/script/pip method.
979    let script = profile.install.iter().find(|m| m.method == "script").and_then(|m| m.command.clone());
980    let install = install_command(&method, package.as_deref(), version, script.as_deref())
981        .filter(|a| a[0] != "brew")
982        .or_else(|| profile.install.iter().find_map(|m| install_command(&m.method, m.package.as_deref(), version, m.command.as_deref()).filter(|a| a[0] != "brew")));
983    match install {
984        Some(a) if a[0] == "sh" => d.push_str(&format!("RUN {}\n", a[2])),
985        Some(a) => d.push_str(&format!("RUN {}\n", a.iter().map(|x| if x == "python3" { "uv".to_string() } else { x.clone() }).collect::<Vec<_>>().join(" ").replace("uv -m pip install", "uv pip install --system"))),
986        None => d.push_str(&format!("# TODO: install {} here; hlock has no scriptable Linux install for it\n", plan.harness_name)),
987    }
988    for st in plan.steps.iter().filter(|s| s.kind == "plugin" && s.status == "auto") {
989        if let Some(Action::Run { argv, .. }) = &st.action {
990            let cmd = if argv[0] == "sh" { argv[2].clone() } else { shell_join(argv) };
991            d.push_str(&format!("RUN {cmd} || echo 'warning: {} failed'\n", st.id.replace('\'', "")));
992        }
993    }
994    for st in plan.steps.iter().filter(|s| s.kind == "package") {
995        if let Some(c) = &st.command {
996            d.push_str(&format!("RUN {}\n", c.replace("uv pip install", "uv pip install --system").replace("python3 -m pip install", "uv pip install --system")));
997        }
998    }
999    d.push_str("COPY home/ /root/\n");
1000    d.push_str("COPY hlock-entrypoint /usr/local/bin/hlock-entrypoint\n");
1001    for rt in lock.runtime.iter().filter(|r| r.location == "local") {
1002        if let Some((_, Some(port))) = rt.endpoint.as_deref().and_then(host_port) {
1003            d.push_str(&format!("# {} runs on the host: point the harness at http://host.docker.internal:{port}\n", rt.name));
1004        }
1005    }
1006    let secrets: Vec<&str> = plan.steps.iter().filter(|s| s.kind == "secret").map(|s| s.id.trim_start_matches("secret:")).collect();
1007    if !secrets.is_empty() {
1008        d.push_str(&format!("# Secrets (pass with -e): {}\n", secrets.join(" ")));
1009    }
1010    d.push_str("WORKDIR /workspace\nENTRYPOINT [\"/usr/local/bin/hlock-entrypoint\"]\n");
1011    let bin = profile.binaries.first().cloned().unwrap_or_else(|| "bash".into());
1012    d.push_str(&format!("CMD [\"{bin}\"]\n"));
1013
1014    let entry = r#"#!/bin/sh
1015# Fill ${HLOCK_SECRET_*} placeholders in the harness config from the environment, then run.
1016python3 - <<'PY'
1017import os, pathlib, re
1018pat = re.compile(r"\$\{(HLOCK_SECRET_[A-Z0-9_]+)\}")
1019for p in pathlib.Path.home().rglob("*"):
1020    if p.is_file() and p.suffix in {".json", ".jsonc", ".toml", ".yaml", ".yml"} and p.stat().st_size < 5_000_000:
1021        try:
1022            text = p.read_text()
1023        except Exception:
1024            continue
1025        new = pat.sub(lambda m: os.environ.get(m.group(1), m.group(0)), text)
1026        if new != text:
1027            p.write_text(new)
1028PY
1029exec "$@"
1030"#
1031    .to_string();
1032    (d, entry)
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038    use crate::capture::parse_config;
1039
1040    #[test]
1041    fn placeholder_fill_keeps_existing_secrets() {
1042        let desired = json!({"a": {"apiKey": "${HLOCK_SECRET_A}"}, "b": "Bearer ${HLOCK_SECRET_B}", "c": "${HLOCK_SECRET_C}"});
1043        let actual = json!({"a": {"apiKey": "real-key"}});
1044        let env: BTreeMap<String, String> = [("HLOCK_SECRET_B".to_string(), "tok".to_string())].into();
1045        let mut missing = BTreeSet::new();
1046        let v = fill(&desired, Some(&actual), &env, &mut missing);
1047        assert_eq!(v, json!({"a": {"apiKey": "real-key"}, "b": "Bearer tok", "c": "${HLOCK_SECRET_C}"}));
1048        assert_eq!(missing.into_iter().collect::<Vec<_>>(), ["HLOCK_SECRET_C"]);
1049    }
1050
1051    #[test]
1052    fn pointers_and_sources() {
1053        let mut v = json!({"keep": 1});
1054        set_pointer(&mut v, "/mcpServers", json!({"gh": {}}));
1055        set_pointer(&mut v, "/a/b~1c", json!(2));
1056        assert_eq!(v, json!({"keep": 1, "mcpServers": {"gh": {}}, "a": {"b/c": 2}}));
1057        assert_eq!(parse_source("npm:@scope/pkg@1.2.3"), ("npm".into(), Some("@scope/pkg".into())));
1058        assert_eq!(parse_source("brew-cask:claude-code@2.1.285"), ("brew-cask".into(), Some("claude-code".into())));
1059        assert_eq!(parse_source("pypi:vllm==0.11.2"), ("pip".into(), Some("vllm".into())));
1060        assert_eq!(parse_source("script@0.20.0"), ("script".into(), None));
1061        assert_eq!(
1062            install_command("npm", Some("@openai/codex"), Some("0.5.0"), None).unwrap(),
1063            ["npm", "install", "-g", "@openai/codex@0.5.0"]
1064        );
1065        assert_eq!(install_command("script", None, Some("2.1"), Some("curl x | bash -s {version}")).unwrap()[2], "curl x | bash -s 2.1");
1066        assert!(install_command("app", None, None, None).is_none());
1067    }
1068
1069    #[test]
1070    fn parse_jsonc_vendored() {
1071        assert!(parse_config("{\"a\": 1,}", "jsonc").is_ok());
1072    }
1073}