use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::capture::{live_mcp_servers, read_config, render_config, tree_digest};
use crate::error::{Error, Result};
use crate::lockfile::{Lockfile, Tool};
use crate::profiles::{probe_harness, Ctx, HarnessProfile, Registry};
use crate::sync::{self, copy_tree, lock_dir_of, placeholders, pretty, set_pointer, SyncOptions};
pub const ENV_DIR: &str = ".hlock/env";
#[derive(Debug, Clone, Default)]
pub struct LaunchOptions {
pub target: Option<String>,
pub prefix: Option<PathBuf>,
pub allow_version_mismatch: bool,
pub in_place: bool,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct LaunchPrep {
pub harness: String,
pub harness_name: String,
pub kind: String,
pub argv: Vec<String>,
pub env: BTreeMap<String, String>,
pub root: Option<PathBuf>,
pub translated: bool,
pub done: Vec<String>,
pub notes: Vec<String>,
pub missing_secrets: Vec<String>,
}
fn model_arg(lock: &Lockfile, target: &HarnessProfile, notes: &mut Vec<String>) -> Option<String> {
let llm = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first())?;
if matches!(llm.provider.as_str(), "reference" | "unknown") || llm.model == "harness-default" {
return None;
}
if target.model.format.as_deref() == Some("provider/model") || target.providers.is_some() {
if target.providers.is_some() && !matches!(llm.provider.as_str(), "anthropic" | "openai" | "google") {
notes.push(format!("{} must have a provider named {:?} configured for {}", target.name, llm.provider, llm.model));
}
return Some(format!("{}/{}", llm.provider, llm.model));
}
match target.model.default_provider.as_deref() {
Some(p) if p == llm.provider => Some(llm.model.clone()),
Some(p) => {
notes.push(format!("{} talks to {p}, so the locked model {}:{} isn't passed; it uses its own default", target.name, llm.provider, llm.model));
None
}
None => Some(llm.model.clone()),
}
}
pub fn mcp_entry(t: &Tool, shape: &str) -> Value {
let remote = matches!(t.transport.as_deref(), Some("http") | Some("sse")) && t.url.is_some();
let env = json!(t.env);
let headers = json!(t.headers);
let enabled = t.enabled != Some(false);
match (shape, remote) {
("opencode", false) => {
let mut cmd = vec![t.command.clone().unwrap_or_default()];
cmd.extend(t.args.iter().cloned());
let mut e = json!({"type": "local", "command": cmd, "enabled": enabled});
if !t.env.is_empty() {
e["environment"] = env;
}
e
}
("opencode", true) => {
let mut e = json!({"type": "remote", "url": t.url, "enabled": enabled});
if !t.headers.is_empty() {
e["headers"] = headers;
}
e
}
("goose", false) => json!({"type": "stdio", "name": t.name, "cmd": t.command, "args": t.args, "envs": env, "enabled": enabled, "timeout": 300}),
("goose", true) => json!({"type": "streamable_http", "name": t.name, "uri": t.url, "headers": headers, "enabled": enabled, "timeout": 300}),
("codex", true) => {
let mut e = json!({"url": t.url});
if !t.headers.is_empty() {
e["http_headers"] = headers;
}
e
}
(_, true) => {
let mut e = json!({"type": t.transport, "url": t.url});
if !t.headers.is_empty() {
e["headers"] = headers;
}
e
}
(_, false) => {
let mut e = json!({"command": t.command, "args": t.args});
if !t.env.is_empty() {
e["env"] = env;
}
e
}
}
}
fn fill_tool(t: &Tool, live: Option<&Value>, ctx: &Ctx, missing: &mut BTreeSet<String>) -> Tool {
let mut t = t.clone();
let lookup = |section: &[&str], key: &str| -> Option<String> {
let entry = live?;
section.iter().find_map(|s| entry.get(*s).and_then(|m| m.get(key)).and_then(Value::as_str).map(str::to_string))
};
let mut fill = |v: &str, live_value: Option<String>| -> String {
let names = placeholders(v);
if names.is_empty() {
return v.to_string();
}
let mut out = v.to_string();
let mut unfilled = false;
for n in &names {
match ctx.var(n) {
Some(val) => out = out.replace(&format!("${{{n}}}"), val),
None => unfilled = true,
}
}
if unfilled {
if let Some(lv) = live_value.filter(|x| placeholders(x).is_empty()) {
return lv;
}
missing.extend(names.into_iter().filter(|n| ctx.var(n).is_none()));
}
out
};
t.env = t.env.iter().map(|(k, v)| (k.clone(), fill(v, lookup(&["env", "environment", "envs"], k)))).collect();
t.headers = t.headers.iter().map(|(k, v)| (k.clone(), fill(v, lookup(&["headers", "http_headers"], k)))).collect();
if let Some(u) = &t.url {
let live_url = live.and_then(|e| ["url", "serverUrl", "httpUrl", "uri"].iter().find_map(|k| e.get(*k).and_then(Value::as_str)).map(str::to_string));
t.url = Some(fill(u, live_url));
}
t
}
fn write_pointer(path: &Path, format: &str, ptr: &str, value: Value) -> Result<()> {
let mut doc = read_config(path, format)?.filter(Value::is_object).unwrap_or_else(|| json!({}));
set_pointer(&mut doc, ptr, value);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let fmt = if format == "jsonc" { "json" } else { format };
std::fs::write(path, render_config(&doc, fmt).map_err(Error::Profile)?)?;
Ok(())
}
fn translate(lock: &Lockfile, lock_path: &Path, source: Option<&HarnessProfile>, target: &HarnessProfile, t: &Ctx, root: &Path, ctx: &Ctx, prep: &mut LaunchPrep) -> Result<()> {
let lock_dir = lock_dir_of(lock_path, ctx);
let source_root = source.map(|s| s.config_root(ctx));
let tools: Vec<&Tool> = lock.tool.iter().filter(|x| x.kind == "mcp").collect();
if !tools.is_empty() {
match target.mcp.write.as_deref().and_then(|w| w.split_once('#')) {
Some((file, ptr)) => {
let spec = target.files.get(file).ok_or_else(|| Error::Profile(format!("{} profile: mcp.write names unknown file {file:?}", target.id)))?;
let path = target.file_path(file, t).unwrap_or_default();
let live = source.map(|s| live_mcp_servers(s, ctx)).unwrap_or_default();
let shape = target.mcp.shape.as_deref().unwrap_or("standard");
let mut missing = BTreeSet::new();
let servers: Map<String, Value> = tools
.iter()
.filter(|x| x.enabled != Some(false) || matches!(shape, "opencode" | "goose"))
.map(|x| (x.name.clone(), mcp_entry(&fill_tool(x, live.get(&x.name), ctx, &mut missing), shape)))
.collect();
let n = servers.len();
write_pointer(&path, &spec.format, ptr, Value::Object(servers))?;
prep.done.push(format!("wrote {n} MCP server{} to {}", if n == 1 { "" } else { "s" }, pretty(&path, ctx)));
prep.missing_secrets.extend(missing);
}
None => prep.notes.push(format!("{} has no MCP config hlock can write; MCP servers weren't carried over", target.name)),
}
}
if let Some(write) = &target.skills.write {
let dir = t.expand(write, Some(root));
let mut copied = 0;
for s in lock.skill.iter().filter(|s| !matches!(s.origin.as_str(), "harness-builtin" | "bundled")) {
let src = s
.vendored
.as_ref()
.map(|v| lock_dir.join(v))
.filter(|p| p.join("SKILL.md").is_file())
.or_else(|| s.path.as_ref().map(|p| ctx.expand(p, source_root.as_deref())).filter(|p| p.join("SKILL.md").is_file()));
let Some(src) = src else {
prep.notes.push(format!("skill {} has no local copy; run hlock sync first", s.name));
continue;
};
let dest = dir.join(&s.name);
if dest.join("SKILL.md").is_file() && Some(tree_digest(&dest)) == s.digest {
continue;
}
let _ = std::fs::remove_dir_all(&dest);
copy_tree(&src, &dest)?;
copied += 1;
}
if copied > 0 {
prep.done.push(format!("copied {copied} skill{} to {}", if copied == 1 { "" } else { "s" }, pretty(&dir, ctx)));
}
let skipped = lock.skill.iter().filter(|s| matches!(s.origin.as_str(), "harness-builtin" | "bundled")).count();
if skipped > 0 {
prep.notes.push(format!("{skipped} skills that ship with the source harness or its plugins don't carry over"));
}
} else if !lock.skill.is_empty() {
prep.notes.push(format!("{} has no skills directory; skills weren't carried over", target.name));
}
let user_ins: Vec<_> = lock.instructions.iter().filter(|i| i.scope == "user").collect();
if let Some(first) = user_ins.first() {
let dest_tpl = target.instructions.iter().find(|i| i.starts_with("{root}/"));
let src = first.vendored.as_ref().map(|v| lock_dir.join(v)).or_else(|| Some(ctx.expand(&first.name, source_root.as_deref()))).filter(|p| p.is_file());
match (dest_tpl, src) {
(Some(tpl), Some(src)) => {
let dest = t.expand(tpl, Some(root));
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dest)?;
prep.done.push(format!("wrote {} from {}", pretty(&dest, ctx), first.name));
}
_ => prep.notes.push(format!("user instructions {} weren't carried over", first.name)),
}
}
if !lock.native.is_empty() {
let names: Vec<&str> = lock.native.iter().filter(|n| n.kind != "package").map(|n| n.name.as_str()).collect();
if !names.is_empty() {
prep.notes.push(format!("plugins don't carry across harnesses: {}", names.join(", ")));
}
}
if lock.config.iter().any(|c| c.restore != "record") {
prep.notes.push(format!("{}-specific settings (permissions, hooks, theme, providers) don't carry over", source.map(|s| s.name.as_str()).unwrap_or("source")));
}
Ok(())
}
pub fn prepare(lock: &Lockfile, lock_path: &Path, reg: &Registry, ctx: &Ctx, opts: &LaunchOptions, args: &[String]) -> Result<LaunchPrep> {
let source = reg.harness(&lock.harness.name);
let target_name = opts.target.clone().unwrap_or_else(|| lock.harness.name.clone());
let target = reg.require_harness(&target_name)?;
let same = source.is_some_and(|s| s.id == target.id);
let mut prep = LaunchPrep { harness: target.id.clone(), harness_name: target.name.clone(), kind: target.kind.clone(), translated: !same, ..Default::default() };
if target.kind != "cli" {
let workspace = ctx.cwd.clone();
let prompt = args.join(" ");
let inv = crate::contract::invocation(target, Some(lock), Some(lock_path), None, &prompt, &workspace, 0, ctx)?;
prep.argv = inv.argv;
prep.env = inv.env;
return Ok(prep);
}
let probe = probe_harness(target, ctx);
let bin = probe.binary.clone().ok_or_else(|| {
let hint = if same { "run `hlock sync --install`" } else { "install it first" };
Error::Profile(format!("{} isn't installed; {hint}", target.name))
})?;
if same {
if let (Some(want), Some(have)) = (&lock.harness.version, &probe.version) {
if want != have {
if !opts.allow_version_mismatch {
return Err(Error::Profile(format!(
"{} {have} is installed but the lock pins {want}; install {want} (hlock sync --install) or pass --allow-version-mismatch",
target.name
)));
}
prep.notes.push(format!("running {} {have}; the lock pins {want}", target.name));
}
}
} else {
prep.notes.push(format!("translating a {} lock into {}", source.map(|s| s.name.as_str()).unwrap_or(&lock.harness.name), target.name));
}
let mut argv = vec![bin];
argv.extend(target.launch.args.iter().cloned());
let has_config = lock.config.iter().any(|c| c.restore != "record");
if same && opts.in_place {
prep.notes.push("using your own config (no isolation)".into());
} else {
let prefix = opts.prefix.clone().unwrap_or_else(|| match lock_path.file_stem().and_then(|s| s.to_str()) {
Some("harness") | None => PathBuf::from(ENV_DIR),
Some(stem) => PathBuf::from(ENV_DIR).join(stem),
});
let sopts = SyncOptions { prefix: Some(prefix), ..Default::default() };
let (t, _, iso) = sync::target_ctx(target, ctx, &sopts);
let root = target.config_root(&t);
std::fs::create_dir_all(&root)?;
if same {
let plan = sync::plan(lock, lock_path, reg, ctx, &sopts)?;
for st in plan.steps.iter().filter(|s| s.status == "auto" && matches!(s.kind.as_str(), "config" | "skill" | "instructions" | "plugin")) {
match sync::run_step(st, ctx) {
Ok(_) => prep.done.push(st.title.clone()),
Err(e) => prep.notes.push(format!("{}: {e}", st.title)),
}
}
for st in plan.steps.iter().filter(|s| s.kind == "secret" && s.status == "manual") {
prep.missing_secrets.push(st.id.trim_start_matches("secret:").to_string());
}
} else {
translate(lock, lock_path, source, target, &t, &root, ctx, &mut prep)?;
}
prep.env = iso;
prep.root = Some(root);
}
if !same || !has_config {
if let (Some(flag), Some(m)) = (target.launch.model_flag.clone(), model_arg(lock, target, &mut prep.notes)) {
argv.push(flag);
argv.push(m);
}
}
argv.extend(args.iter().cloned());
prep.argv = argv;
prep.missing_secrets.sort();
prep.missing_secrets.dedup();
Ok(prep)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcp_shapes() {
let stdio = Tool { name: "gh".into(), kind: "mcp".into(), transport: Some("stdio".into()), command: Some("npx".into()), args: vec!["pkg".into()], env: [("T".to_string(), "v".to_string())].into(), ..Default::default() };
let http = Tool { name: "r".into(), kind: "mcp".into(), transport: Some("http".into()), url: Some("https://x/mcp".into()), headers: [("Authorization".to_string(), "Bearer t".to_string())].into(), ..Default::default() };
assert_eq!(mcp_entry(&stdio, "standard"), json!({"command": "npx", "args": ["pkg"], "env": {"T": "v"}}));
assert_eq!(mcp_entry(&stdio, "opencode"), json!({"type": "local", "command": ["npx", "pkg"], "environment": {"T": "v"}, "enabled": true}));
assert_eq!(mcp_entry(&http, "opencode")["type"], "remote");
assert_eq!(mcp_entry(&http, "codex"), json!({"url": "https://x/mcp", "http_headers": {"Authorization": "Bearer t"}}));
assert_eq!(mcp_entry(&stdio, "goose")["cmd"], "npx");
assert_eq!(mcp_entry(&http, "standard")["type"], "http");
}
#[test]
fn secrets_from_live_config() {
let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: BTreeMap::new(), path: vec![] };
let t = Tool { name: "gh".into(), kind: "mcp".into(), env: [("TOKEN".to_string(), "${HLOCK_SECRET_GH_TOKEN}".to_string())].into(), ..Default::default() };
let live = json!({"command": "npx", "env": {"TOKEN": "real"}});
let mut missing = BTreeSet::new();
assert_eq!(fill_tool(&t, Some(&live), &ctx, &mut missing).env["TOKEN"], "real");
assert!(missing.is_empty());
let filled = fill_tool(&t, None, &ctx, &mut missing);
assert_eq!(filled.env["TOKEN"], "${HLOCK_SECRET_GH_TOKEN}");
assert!(missing.contains("HLOCK_SECRET_GH_TOKEN"));
}
}