1use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12
13use serde::Serialize;
14use serde_json::{json, Map, Value};
15
16use crate::capture::{live_mcp_servers, read_config, render_config, tree_digest};
17use crate::error::{Error, Result};
18use crate::lockfile::{Lockfile, Tool};
19use crate::profiles::{probe_harness, Ctx, HarnessProfile, Registry};
20use crate::sync::{self, copy_tree, lock_dir_of, placeholders, pretty, set_pointer, SyncOptions};
21
22pub const ENV_DIR: &str = ".hlock/env";
24
25#[derive(Debug, Clone, Default)]
26pub struct LaunchOptions {
27 pub target: Option<String>,
29 pub prefix: Option<PathBuf>,
31 pub allow_version_mismatch: bool,
33 pub in_place: bool,
35 pub workspace: Option<PathBuf>,
37 pub prompt: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Default)]
43pub struct LaunchPrep {
44 pub harness: String,
45 pub harness_name: String,
46 pub kind: String,
48 pub argv: Vec<String>,
49 pub env: BTreeMap<String, String>,
50 pub root: Option<PathBuf>,
51 pub translated: bool,
53 pub done: Vec<String>,
55 pub notes: Vec<String>,
57 pub missing_secrets: Vec<String>,
58}
59
60fn model_arg(lock: &Lockfile, target: &HarnessProfile, notes: &mut Vec<String>) -> Option<String> {
62 let llm = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first())?;
63 if matches!(llm.provider.as_str(), "reference" | "unknown") || llm.model == "harness-default" {
64 return None;
65 }
66 if target.model.format.as_deref() == Some("provider/model") || target.providers.is_some() {
67 if target.providers.is_some() && !matches!(llm.provider.as_str(), "anthropic" | "openai" | "google") {
68 notes.push(format!("{} must have a provider named {:?} configured for {}", target.name, llm.provider, llm.model));
69 }
70 return Some(format!("{}/{}", llm.provider, llm.model));
71 }
72 match target.model.default_provider.as_deref() {
73 Some(p) if p == llm.provider => Some(llm.model.clone()),
74 Some(p) => {
75 notes.push(format!("{} talks to {p}, so the locked model {}:{} isn't passed; it uses its own default", target.name, llm.provider, llm.model));
76 None
77 }
78 None => Some(llm.model.clone()),
79 }
80}
81
82pub fn mcp_entry(t: &Tool, shape: &str) -> Value {
84 let remote = matches!(t.transport.as_deref(), Some("http") | Some("sse")) && t.url.is_some();
85 let env = json!(t.env);
86 let headers = json!(t.headers);
87 let enabled = t.enabled != Some(false);
88 match (shape, remote) {
89 ("opencode", false) => {
90 let mut cmd = vec![t.command.clone().unwrap_or_default()];
91 cmd.extend(t.args.iter().cloned());
92 let mut e = json!({"type": "local", "command": cmd, "enabled": enabled});
93 if !t.env.is_empty() {
94 e["environment"] = env;
95 }
96 e
97 }
98 ("opencode", true) => {
99 let mut e = json!({"type": "remote", "url": t.url, "enabled": enabled});
100 if !t.headers.is_empty() {
101 e["headers"] = headers;
102 }
103 e
104 }
105 ("goose", false) => json!({"type": "stdio", "name": t.name, "cmd": t.command, "args": t.args, "envs": env, "enabled": enabled, "timeout": 300}),
106 ("goose", true) => json!({"type": "streamable_http", "name": t.name, "uri": t.url, "headers": headers, "enabled": enabled, "timeout": 300}),
107 ("codex", true) => {
108 let mut e = json!({"url": t.url});
109 if !t.headers.is_empty() {
110 e["http_headers"] = headers;
111 }
112 e
113 }
114 (_, true) => {
115 let mut e = json!({"type": t.transport, "url": t.url});
116 if !t.headers.is_empty() {
117 e["headers"] = headers;
118 }
119 e
120 }
121 (_, false) => {
122 let mut e = json!({"command": t.command, "args": t.args});
123 if !t.env.is_empty() {
124 e["env"] = env;
125 }
126 e
127 }
128 }
129}
130
131fn fill_tool(t: &Tool, live: Option<&Value>, ctx: &Ctx, missing: &mut BTreeSet<String>) -> Tool {
134 let mut t = t.clone();
135 let lookup = |section: &[&str], key: &str| -> Option<String> {
136 let entry = live?;
137 section.iter().find_map(|s| entry.get(*s).and_then(|m| m.get(key)).and_then(Value::as_str).map(str::to_string))
138 };
139 let mut fill = |v: &str, live_value: Option<String>| -> String {
140 let names = placeholders(v);
141 if names.is_empty() {
142 return v.to_string();
143 }
144 let mut out = v.to_string();
145 let mut unfilled = false;
146 for n in &names {
147 match ctx.var(n) {
148 Some(val) => out = out.replace(&format!("${{{n}}}"), val),
149 None => unfilled = true,
150 }
151 }
152 if unfilled {
153 if let Some(lv) = live_value.filter(|x| placeholders(x).is_empty()) {
154 return lv;
155 }
156 missing.extend(names.into_iter().filter(|n| ctx.var(n).is_none()));
157 }
158 out
159 };
160 t.env = t.env.iter().map(|(k, v)| (k.clone(), fill(v, lookup(&["env", "environment", "envs"], k)))).collect();
161 t.headers = t.headers.iter().map(|(k, v)| (k.clone(), fill(v, lookup(&["headers", "http_headers"], k)))).collect();
162 if let Some(u) = &t.url {
163 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));
164 t.url = Some(fill(u, live_url));
165 }
166 t
167}
168
169fn write_pointer(path: &Path, format: &str, ptr: &str, value: Value) -> Result<()> {
170 let mut doc = read_config(path, format)?.filter(Value::is_object).unwrap_or_else(|| json!({}));
171 set_pointer(&mut doc, ptr, value);
172 if let Some(parent) = path.parent() {
173 std::fs::create_dir_all(parent)?;
174 }
175 let fmt = if format == "jsonc" { "json" } else { format };
176 std::fs::write(path, render_config(&doc, fmt).map_err(Error::Profile)?)?;
177 Ok(())
178}
179
180fn translate(lock: &Lockfile, lock_path: &Path, source: Option<&HarnessProfile>, target: &HarnessProfile, t: &Ctx, root: &Path, ctx: &Ctx, prep: &mut LaunchPrep) -> Result<()> {
182 let lock_dir = lock_dir_of(lock_path, ctx);
183 let source_root = source.map(|s| s.config_root(ctx));
184
185 let tools: Vec<&Tool> = lock.tool.iter().filter(|x| x.kind == "mcp").collect();
187 if !tools.is_empty() {
188 match target.mcp.write.as_deref().and_then(|w| w.split_once('#')) {
189 Some((file, ptr)) => {
190 let spec = target.files.get(file).ok_or_else(|| Error::Profile(format!("{} profile: mcp.write names unknown file {file:?}", target.id)))?;
191 let path = target.file_path(file, t).unwrap_or_default();
192 let live = source.map(|s| live_mcp_servers(s, ctx)).unwrap_or_default();
193 let shape = target.mcp.shape.as_deref().unwrap_or("standard");
194 let mut missing = BTreeSet::new();
195 let servers: Map<String, Value> = tools
196 .iter()
197 .filter(|x| x.enabled != Some(false) || matches!(shape, "opencode" | "goose"))
198 .map(|x| (x.name.clone(), mcp_entry(&fill_tool(x, live.get(&x.name), ctx, &mut missing), shape)))
199 .collect();
200 let n = servers.len();
201 write_pointer(&path, &spec.format, ptr, Value::Object(servers))?;
202 prep.done.push(format!("wrote {n} MCP server{} to {}", if n == 1 { "" } else { "s" }, pretty(&path, ctx)));
203 prep.missing_secrets.extend(missing);
204 }
205 None => prep.notes.push(format!("{} has no MCP config hlock can write; MCP servers weren't carried over", target.name)),
206 }
207 }
208
209 if let Some(write) = &target.skills.write {
211 let dir = t.expand(write, Some(root));
212 let mut copied = 0;
213 for s in lock.skill.iter().filter(|s| !matches!(s.origin.as_str(), "harness-builtin" | "bundled")) {
214 let src = s
215 .vendored
216 .as_ref()
217 .map(|v| lock_dir.join(v))
218 .filter(|p| p.join("SKILL.md").is_file())
219 .or_else(|| s.path.as_ref().map(|p| ctx.expand(p, source_root.as_deref())).filter(|p| p.join("SKILL.md").is_file()));
220 let Some(src) = src else {
221 prep.notes.push(format!("skill {} has no local copy; run hlock sync first", s.name));
222 continue;
223 };
224 let dest = dir.join(&s.name);
225 if dest.join("SKILL.md").is_file() && Some(tree_digest(&dest)) == s.digest {
226 continue;
227 }
228 let _ = std::fs::remove_dir_all(&dest);
229 copy_tree(&src, &dest)?;
230 copied += 1;
231 }
232 if copied > 0 {
233 prep.done.push(format!("copied {copied} skill{} to {}", if copied == 1 { "" } else { "s" }, pretty(&dir, ctx)));
234 }
235 let skipped = lock.skill.iter().filter(|s| matches!(s.origin.as_str(), "harness-builtin" | "bundled")).count();
236 if skipped > 0 {
237 prep.notes.push(format!("{skipped} skills that ship with the source harness or its plugins don't carry over"));
238 }
239 } else if !lock.skill.is_empty() {
240 prep.notes.push(format!("{} has no skills directory; skills weren't carried over", target.name));
241 }
242
243 let user_ins: Vec<_> = lock.instructions.iter().filter(|i| i.scope == "user").collect();
245 if let Some(first) = user_ins.first() {
246 let dest_tpl = target.instructions.iter().find(|i| i.starts_with("{root}/"));
247 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());
248 match (dest_tpl, src) {
249 (Some(tpl), Some(src)) => {
250 let dest = t.expand(tpl, Some(root));
251 if let Some(parent) = dest.parent() {
252 std::fs::create_dir_all(parent)?;
253 }
254 std::fs::copy(&src, &dest)?;
255 prep.done.push(format!("wrote {} from {}", pretty(&dest, ctx), first.name));
256 }
257 _ => prep.notes.push(format!("user instructions {} weren't carried over", first.name)),
258 }
259 }
260
261 if !lock.native.is_empty() {
263 let names: Vec<&str> = lock.native.iter().filter(|n| n.kind != "package").map(|n| n.name.as_str()).collect();
264 if !names.is_empty() {
265 prep.notes.push(format!("plugins don't carry across harnesses: {}", names.join(", ")));
266 }
267 }
268 if lock.config.iter().any(|c| c.restore != "record") {
269 prep.notes.push(format!("{}-specific settings (permissions, hooks, theme, providers) don't carry over", source.map(|s| s.name.as_str()).unwrap_or("source")));
270 }
271 Ok(())
272}
273
274pub fn env_prefix(lock_path: &Path, ctx: &Ctx) -> PathBuf {
277 let dir = match lock_path.file_stem().and_then(|s| s.to_str()) {
278 Some("harness") | None => PathBuf::from(ENV_DIR),
279 Some(stem) => PathBuf::from(ENV_DIR).join(stem),
280 };
281 ctx.cwd.join(dir)
282}
283
284pub fn prepare(lock: &Lockfile, lock_path: &Path, reg: &Registry, ctx: &Ctx, opts: &LaunchOptions, args: &[String]) -> Result<LaunchPrep> {
287 let source = reg.harness(&lock.harness.name);
288 let target_name = opts.target.clone().unwrap_or_else(|| lock.harness.name.clone());
289 let target = reg.require_harness(&target_name)?;
290 let same = source.is_some_and(|s| s.id == target.id);
291 let mut prep = LaunchPrep { harness: target.id.clone(), harness_name: target.name.clone(), kind: target.kind.clone(), translated: !same, ..Default::default() };
292
293 if target.kind != "cli" {
294 let workspace = opts.workspace.clone().unwrap_or_else(|| ctx.cwd.clone());
295 let prompt = args.join(" ");
296 let inv = crate::contract::invocation(target, Some(lock), Some(lock_path), None, &prompt, &workspace, 0, ctx)?;
297 prep.argv = inv.argv;
298 prep.env = inv.env;
299 return Ok(prep);
300 }
301
302 let probe = probe_harness(target, ctx);
303 let bin = probe.binary.clone().ok_or_else(|| {
304 let hint = if same { "run `hlock sync --install`" } else { "install it first" };
305 Error::Profile(format!("{} isn't installed; {hint}", target.name))
306 })?;
307 if same {
308 if let (Some(want), Some(have)) = (&lock.harness.version, &probe.version) {
309 if want != have {
310 if !opts.allow_version_mismatch {
311 return Err(Error::Profile(format!(
312 "{} {have} is installed but the lock pins {want}; install {want} (hlock sync --install) or pass --allow-version-mismatch",
313 target.name
314 )));
315 }
316 prep.notes.push(format!("running {} {have}; the lock pins {want}", target.name));
317 }
318 }
319 } else {
320 prep.notes.push(format!("translating a {} lock into {}", source.map(|s| s.name.as_str()).unwrap_or(&lock.harness.name), target.name));
321 }
322
323 let mut argv = vec![bin];
324 argv.extend(target.launch.args.iter().cloned());
325 if let Some(prompt) = &opts.prompt {
327 if target.launch.prompt.is_empty() {
328 return Err(Error::Profile(format!(
329 "the {} profile doesn't say how to run a prompt non-interactively; add `prompt` to its [launch] table, or use hlock launch",
330 target.name
331 )));
332 }
333 argv.extend(target.launch.prompt.iter().map(|a| a.replace("{prompt}", prompt)));
334 }
335 let has_config = lock.config.iter().any(|c| c.restore != "record");
336
337 if same && opts.in_place {
338 prep.notes.push("using your own config (no isolation)".into());
339 } else {
340 let prefix = match opts.prefix.clone() {
341 Some(p) if p.is_absolute() => p,
342 Some(p) => ctx.cwd.join(p),
343 None => env_prefix(lock_path, ctx),
344 };
345 let sopts = SyncOptions { prefix: Some(prefix), ..Default::default() };
346 let (t, _, iso) = sync::target_ctx(target, ctx, &sopts);
347 let root = target.config_root(&t);
348 std::fs::create_dir_all(&root)?;
349 if same {
350 let plan = sync::plan(lock, lock_path, reg, ctx, &sopts)?;
351 for st in plan.steps.iter().filter(|s| s.status == "auto" && matches!(s.kind.as_str(), "config" | "skill" | "instructions" | "plugin")) {
352 match sync::run_step(st, ctx) {
353 Ok(_) => prep.done.push(st.title.clone()),
354 Err(e) => prep.notes.push(format!("{}: {e}", st.title)),
355 }
356 }
357 for st in plan.steps.iter().filter(|s| s.kind == "secret" && s.status == "manual") {
358 prep.missing_secrets.push(st.id.trim_start_matches("secret:").to_string());
359 }
360 } else {
361 translate(lock, lock_path, source, target, &t, &root, ctx, &mut prep)?;
362 }
363 prep.env = iso;
364 prep.root = Some(root);
365 }
366 if !same || !has_config {
368 if let (Some(flag), Some(m)) = (target.launch.model_flag.clone(), model_arg(lock, target, &mut prep.notes)) {
369 argv.push(flag);
370 argv.push(m);
371 }
372 }
373 argv.extend(args.iter().cloned());
374 prep.argv = argv;
375 prep.missing_secrets.sort();
376 prep.missing_secrets.dedup();
377 Ok(prep)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn mcp_shapes() {
386 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() };
387 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() };
388 assert_eq!(mcp_entry(&stdio, "standard"), json!({"command": "npx", "args": ["pkg"], "env": {"T": "v"}}));
389 assert_eq!(mcp_entry(&stdio, "opencode"), json!({"type": "local", "command": ["npx", "pkg"], "environment": {"T": "v"}, "enabled": true}));
390 assert_eq!(mcp_entry(&http, "opencode")["type"], "remote");
391 assert_eq!(mcp_entry(&http, "codex"), json!({"url": "https://x/mcp", "http_headers": {"Authorization": "Bearer t"}}));
392 assert_eq!(mcp_entry(&stdio, "goose")["cmd"], "npx");
393 assert_eq!(mcp_entry(&http, "standard")["type"], "http");
394 }
395
396 #[test]
397 fn secrets_from_live_config() {
398 let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: BTreeMap::new(), path: vec![] };
399 let t = Tool { name: "gh".into(), kind: "mcp".into(), env: [("TOKEN".to_string(), "${HLOCK_SECRET_GH_TOKEN}".to_string())].into(), ..Default::default() };
400 let live = json!({"command": "npx", "env": {"TOKEN": "real"}});
401 let mut missing = BTreeSet::new();
402 assert_eq!(fill_tool(&t, Some(&live), &ctx, &mut missing).env["TOKEN"], "real");
403 assert!(missing.is_empty());
404 let filled = fill_tool(&t, None, &ctx, &mut missing);
405 assert_eq!(filled.env["TOKEN"], "${HLOCK_SECRET_GH_TOKEN}");
406 assert!(missing.contains("HLOCK_SECRET_GH_TOKEN"));
407 }
408}