1use std::io::Read;
5use std::process::ExitCode;
6use std::sync::Arc;
7
8use locode_core::{
9 CacheHint, EngineConfig, EventSink, FnSink, Host, HostConfig, InstructionsConfig, PackContext,
10 PathPolicy, ProviderInit, ProviderRegistry, SamplingArgs, Session,
11};
12
13use crate::cli::{Cli, OutputFormat};
14use crate::output;
15
16pub struct PreRunError(pub String);
18
19impl<E: std::fmt::Display> From<E> for PreRunError {
20 fn from(e: E) -> Self {
21 PreRunError(e.to_string())
22 }
23}
24
25pub async fn run(cli: Cli, providers: &ProviderRegistry) -> Result<ExitCode, PreRunError> {
35 #[cfg(unix)]
39 let cancel_slot = crate::signal::install_sigterm();
40
41 let prompt = resolve_prompt(cli.prompt.as_deref())?;
43
44 let cwd = match &cli.cwd {
48 Some(dir) => dir.clone(),
49 None => std::env::current_dir()?,
50 };
51 let cwd = std::fs::canonicalize(&cwd)
52 .map_err(|e| PreRunError(format!("--cwd {}: {e}", cwd.display())))?;
53
54 let mut host_config = HostConfig::new(&cwd);
55 if cli.dangerously_skip_permissions {
56 host_config.path_policy = PathPolicy::Unrestricted;
57 }
58 let host = Arc::new(Host::new(host_config)?);
59
60 let settings_load = locode_core::load_settings(&cwd, cli.settings.as_deref());
64 for warning in &settings_load.warnings {
65 output::warning_line(warning);
66 }
67 let settings = settings_load.settings;
68
69 let identity = resolve_identity(&cli, &cwd, &settings)?;
74
75 let pack = locode_core::resolve(&identity.harness)?;
79 let registry = pack.build_registry(&host);
80 let session_id = identity.session_id.clone();
81 let built = providers
82 .build(
83 &identity.api_schema,
84 &ProviderInit {
85 session_id: session_id.clone(),
86 model: identity.model_override.clone(),
87 },
88 )
89 .map_err(|e| PreRunError(e.to_string()))?;
90 let (provider, model) = (built.provider, built.model);
91
92 enforce_wire_requirement(pack, provider.api_schema())?;
96
97 let pack_ctx = PackContext {
99 cwd: cwd.clone(),
100 os: std::env::consts::OS.to_string(),
101 shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
102 date: chrono::Local::now().format("%Y-%m-%d").to_string(),
103 headless: true,
104 is_git_repo: detect_git_repo(&cwd),
105 model: Some(model.clone()),
106 os_version: os_version(),
107 timezone: timezone(),
108 strip_identity: cli.strip_identity,
109 };
110 let preamble = match &identity.resumed {
114 Some(resumed) => resumed.history.clone(),
115 None => pack.preamble(&pack_ctx),
116 };
117
118 let user_prompt = pack.shape_user_prompt(&prompt);
121
122 let config = EngineConfig {
124 session_id,
125 harness: pack.name().to_string(),
126 api_schema: provider.api_schema().to_string(),
127 model,
128 cwd: cwd.clone(),
129 workspace_root: cwd,
130 max_turns: cli.max_turns,
131 sampling_args: SamplingArgs::default(),
132 cache_hint: CacheHint::Standard,
133 streaming: cli.stream,
137 instructions: InstructionsConfig {
141 enabled: !cli.no_project_instructions,
142 root_stop_pattern: settings.root_stop_pattern.clone(),
143 ..InstructionsConfig::default()
144 },
145 ..EngineConfig::default()
146 };
147 let mut trace = build_trace_writer(&cli, &identity, &config.cwd);
150
151 let sink = make_sink(cli.output_format, trace.take());
152
153 let mut session = Session::new(provider, registry, preamble, config, sink);
155 #[cfg(unix)]
156 crate::signal::arm(&cancel_slot, session.cancel_handle());
157 let report = session.run_text(user_prompt).await;
158
159 match cli.output_format {
160 OutputFormat::Json => output::write_json_line(&report),
161 OutputFormat::Text => output::write_text(report.final_message.as_deref().unwrap_or("")),
162 OutputFormat::StreamJson => {} }
164 Ok(output::exit_code(report.status))
165}
166
167struct ResumedSession {
169 path: std::path::PathBuf,
170 history: Vec<locode_core::Message>,
171}
172
173struct RunIdentity {
177 harness: String,
178 api_schema: String,
179 model_override: Option<String>,
180 session_id: String,
181 resumed: Option<ResumedSession>,
182}
183
184fn resolve_identity(
185 cli: &Cli,
186 cwd: &std::path::Path,
187 settings: &locode_core::Settings,
188) -> Result<RunIdentity, PreRunError> {
189 let recovered = if cli.continue_session || cli.resume.is_some() {
191 let home = locode_core::locode_home().map_err(PreRunError)?;
192 let root = home.join("sessions");
193 let path = if let Some(id) = &cli.resume {
194 locode_core::find_rollout_by_id(&root, cwd, id)
195 .ok_or_else(|| PreRunError(format!("--resume: no session `{id}` found")))?
196 } else {
197 locode_core::find_latest_rollout(&root, cwd).ok_or_else(|| {
198 PreRunError(format!(
199 "--continue: no session found for {}",
200 cwd.display()
201 ))
202 })?
203 };
204 let contents = locode_core::read_rollout(&path).map_err(PreRunError)?;
205 Some((path, contents))
206 } else {
207 None
208 };
209
210 if let Some((path, contents)) = recovered {
211 let meta = contents.meta;
212 if let Some(flag) = cli.harness
214 && flag.as_str() != meta.harness
215 {
216 return Err(PreRunError(format!(
217 "--harness {} conflicts with the resumed session's harness `{}`",
218 flag.as_str(),
219 meta.harness
220 )));
221 }
222 if let Some(flag) = &cli.api_schema
223 && flag != &meta.api_schema
224 {
225 return Err(PreRunError(format!(
226 "--api-schema {flag} conflicts with the resumed session's wire `{}` \
227 (a session never crosses wires)",
228 meta.api_schema
229 )));
230 }
231 return Ok(RunIdentity {
232 harness: meta.harness.clone(),
233 api_schema: meta.api_schema.clone(),
234 model_override: cli.model.clone().or_else(|| settings.model.clone()),
240 session_id: meta.session_id.clone(),
241 resumed: Some(ResumedSession {
242 path,
243 history: contents.history,
244 }),
245 });
246 }
247
248 Ok(RunIdentity {
249 harness: match cli.harness {
250 Some(harness) => harness.as_str().to_string(),
251 None => settings
252 .harness
253 .clone()
254 .unwrap_or_else(|| "claude".to_string()),
255 },
256 api_schema: cli
257 .api_schema
258 .clone()
259 .or_else(|| settings.api_schema.clone())
260 .unwrap_or_else(|| "anthropic".to_string()),
261 model_override: cli.model.clone().or_else(|| settings.model.clone()),
262 session_id: new_session_id(),
263 resumed: None,
264 })
265}
266
267fn build_trace_writer(
273 cli: &Cli,
274 identity: &RunIdentity,
275 cwd: &std::path::Path,
276) -> Option<locode_core::TraceWriter> {
277 let root = locode_core::locode_home()
278 .ok()
279 .filter(|_| !cli.no_session_persistence)?
280 .join("sessions");
281 match &identity.resumed {
282 Some(resumed) => locode_core::TraceWriter::resume(resumed.path.clone(), root)
284 .map_err(|e| output::warning_line(&format!("trace: {e}; tracing disabled")))
285 .ok(),
286 None => Some(locode_core::TraceWriter::new(
287 root,
288 locode_core::TraceExtras {
289 cli_version: env!("CARGO_PKG_VERSION").to_string(),
290 git: git_meta(cwd),
291 ..Default::default()
292 },
293 )),
294 }
295}
296
297fn make_sink(
302 output_format: OutputFormat,
303 mut trace: Option<locode_core::TraceWriter>,
304) -> Box<dyn EventSink> {
305 let stream = matches!(output_format, OutputFormat::StreamJson);
306 Box::new(FnSink(move |event| {
307 if let Some(writer) = trace.as_mut() {
308 writer.on_event(&event);
309 if let Some(e) = writer.take_error() {
310 output::warning_line(&format!("trace: {e}; tracing disabled"));
311 }
312 }
313 if stream && in_whole_message_trace(&event) {
314 output::write_json_line(&event);
315 }
316 }))
317}
318
319fn enforce_wire_requirement(pack: &dyn locode_core::Pack, schema: &str) -> Result<(), PreRunError> {
323 if schema != "mock"
324 && let Some(required) = pack.required_api_schemas()
325 && !required.contains(&schema)
326 {
327 return Err(PreRunError(format!(
328 "harness `{}` requires one of these wires: {}; got `--api-schema {}`",
329 pack.name(),
330 required.join(", "),
331 schema,
332 )));
333 }
334 Ok(())
335}
336
337fn resolve_prompt(arg: Option<&str>) -> Result<String, PreRunError> {
340 let prompt = match arg {
341 Some("-") | None => {
342 let mut buf = String::new();
343 std::io::stdin().read_to_string(&mut buf)?;
344 buf
345 }
346 Some(text) => text.to_string(),
347 };
348 let prompt = prompt.trim().to_string();
349 if prompt.is_empty() {
350 return Err(PreRunError(
351 "no prompt: pass it as the positional argument or on stdin".to_string(),
352 ));
353 }
354 Ok(prompt)
355}
356
357fn git_meta(cwd: &std::path::Path) -> Option<locode_core::GitMeta> {
361 if !detect_git_repo(cwd) {
362 return None;
363 }
364 let run = |args: &[&str]| -> Option<String> {
365 let out = std::process::Command::new("git")
366 .arg("-C")
367 .arg(cwd)
368 .args(args)
369 .output()
370 .ok()?;
371 if !out.status.success() {
372 return None;
373 }
374 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
375 (!s.is_empty()).then_some(s)
376 };
377 Some(locode_core::GitMeta {
378 root: run(&["rev-parse", "--show-toplevel"]).map(std::path::PathBuf::from),
379 branch: run(&["rev-parse", "--abbrev-ref", "HEAD"]),
380 head: run(&["rev-parse", "HEAD"]),
381 remote: run(&["remote", "get-url", "origin"]),
382 })
383}
384
385fn detect_git_repo(cwd: &std::path::Path) -> bool {
389 cwd.ancestors().any(|dir| dir.join(".git").exists())
390}
391
392fn os_version() -> Option<String> {
395 #[cfg(unix)]
396 {
397 let out = std::process::Command::new("uname")
398 .args(["-s", "-r"])
399 .output()
400 .ok()?;
401 if !out.status.success() {
402 return None;
403 }
404 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
405 (!s.is_empty()).then_some(s)
406 }
407 #[cfg(not(unix))]
408 {
409 None
410 }
411}
412
413fn timezone() -> Option<String> {
418 if let Ok(tz) = std::env::var("TZ") {
419 let tz = tz.trim();
420 if !tz.is_empty() {
421 return Some(tz.to_string());
422 }
423 }
424 #[cfg(unix)]
425 {
426 let target = std::fs::read_link("/etc/localtime").ok()?;
427 let s = target.to_string_lossy();
428 s.split_once("zoneinfo/")
429 .map(|(_, name)| name.to_string())
430 .filter(|name| !name.is_empty())
431 }
432 #[cfg(not(unix))]
433 {
434 None
435 }
436}
437
438fn new_session_id() -> String {
440 let now = std::time::SystemTime::now()
441 .duration_since(std::time::UNIX_EPOCH)
442 .map_or(0, |d| d.as_millis());
443 format!("sess-{now}-{}", std::process::id())
444}
445
446fn in_whole_message_trace(event: &locode_core::Event) -> bool {
450 !matches!(event, locode_core::Event::MessageDelta { .. })
451}
452
453#[cfg(test)]
454mod tests {
455 use super::{enforce_wire_requirement, in_whole_message_trace};
456 use locode_core::{Event, Message, Role};
457
458 #[test]
459 fn codex_rejects_a_non_responses_wire() {
460 let codex = locode_core::resolve("codex").unwrap();
461 let err = enforce_wire_requirement(codex, "anthropic").expect_err("mismatch");
463 assert!(err.0.contains("codex"), "{}", err.0);
464 assert!(err.0.contains("openai-responses"), "{}", err.0);
465 assert!(err.0.contains("anthropic"), "{}", err.0);
466 assert!(enforce_wire_requirement(codex, "openai-responses").is_ok());
468 assert!(enforce_wire_requirement(codex, "mock").is_ok());
469 }
470
471 #[test]
472 fn wire_agnostic_packs_accept_any_wire() {
473 let grok = locode_core::resolve("grok").unwrap();
474 assert!(enforce_wire_requirement(grok, "anthropic").is_ok());
475 assert!(enforce_wire_requirement(grok, "openai-responses").is_ok());
476 }
477
478 #[test]
479 fn stream_json_trace_drops_message_deltas_keeps_whole_messages() {
480 assert!(!in_whole_message_trace(&Event::MessageDelta {
482 text: "tok".into()
483 }));
484 assert!(in_whole_message_trace(&Event::Message {
486 message: Message {
487 role: Role::Assistant,
488 content: vec![],
489 },
490 }));
491 assert!(in_whole_message_trace(&Event::Error {
492 message: "e".into()
493 }));
494 }
495}