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, SkillsConfig,
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.restricted {
59 host_config.path_policy = PathPolicy::Unrestricted;
60 }
61 let host = Arc::new(Host::new(host_config)?);
62 output::warning_line(if cli.restricted {
63 output::RESTRICTED_MODE_NOTICE
64 } else {
65 output::UNRESTRICTED_MODE_NOTICE
66 });
67
68 let settings_load = locode_core::load_settings(&cwd, cli.settings.as_deref());
72 for warning in &settings_load.warnings {
73 output::warning_line(warning);
74 }
75 let settings = settings_load.settings;
76 let extends_dirs = settings_load.extends_dirs;
80
81 let identity = resolve_identity(&cli, &cwd, &settings)?;
86
87 let pack = locode_core::resolve(&identity.harness)?;
91 let registry = pack.build_registry(&host);
92 let session_id = identity.session_id.clone();
93 let built = providers
94 .build(
95 &identity.api_schema,
96 &ProviderInit {
97 session_id: session_id.clone(),
98 model: identity.model_override.clone(),
99 },
100 )
101 .map_err(|e| PreRunError(e.to_string()))?;
102 let (provider, model) = (built.provider, built.model);
103
104 enforce_wire_requirement(pack, provider.api_schema())?;
108
109 let pack_ctx = PackContext {
111 cwd: cwd.clone(),
112 os: std::env::consts::OS.to_string(),
113 shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
114 date: chrono::Local::now().format("%Y-%m-%d").to_string(),
115 headless: true,
116 is_git_repo: detect_git_repo(&cwd),
117 model: Some(model.clone()),
118 os_version: os_version(),
119 timezone: timezone(),
120 strip_identity: cli.strip_identity,
121 };
122 let preamble = match &identity.resumed {
126 Some(resumed) => resumed.history.clone(),
127 None => pack.preamble(&pack_ctx),
128 };
129
130 let user_prompt = pack.shape_user_prompt(&prompt);
133
134 let config = EngineConfig {
136 session_id,
137 harness: pack.name().to_string(),
138 api_schema: provider.api_schema().to_string(),
139 model,
140 cwd: cwd.clone(),
141 workspace_root: cwd,
142 max_turns: cli.max_turns,
143 sampling_args: SamplingArgs::default(),
144 cache_hint: CacheHint::Standard,
145 streaming: cli.stream,
149 instructions: InstructionsConfig {
153 enabled: !cli.no_project_instructions,
154 root_stop_pattern: settings.root_stop_pattern.clone(),
155 extends_dirs: extends_dirs.clone(),
156 ..InstructionsConfig::default()
157 },
158 skills: SkillsConfig {
161 extends_dirs,
162 extra: settings.skills_extra.clone(),
163 ..SkillsConfig::enabled()
164 },
165 ..EngineConfig::default()
166 };
167 let mut trace = build_trace_writer(&cli, &identity, &config.cwd);
170
171 let sink = make_sink(cli.output_format, trace.take());
172
173 let mut session = Session::new(provider, registry, preamble, config, sink);
175 #[cfg(unix)]
176 crate::signal::arm(&cancel_slot, session.cancel_handle());
177 let report = session.run_text(user_prompt).await;
178
179 match cli.output_format {
180 OutputFormat::Json => output::write_json_line(&report),
181 OutputFormat::Text => output::write_text(report.final_message.as_deref().unwrap_or("")),
182 OutputFormat::StreamJson => {} }
184 Ok(output::exit_code(report.status))
185}
186
187struct ResumedSession {
189 path: std::path::PathBuf,
190 history: Vec<locode_core::Message>,
191}
192
193struct RunIdentity {
197 harness: String,
198 api_schema: String,
199 model_override: Option<String>,
200 session_id: String,
201 resumed: Option<ResumedSession>,
202}
203
204fn resolve_identity(
205 cli: &Cli,
206 cwd: &std::path::Path,
207 settings: &locode_core::Settings,
208) -> Result<RunIdentity, PreRunError> {
209 let recovered = if cli.continue_session || cli.resume.is_some() {
211 let home = locode_core::locode_home().map_err(PreRunError)?;
212 let root = home.join("sessions");
213 let path = if let Some(id) = &cli.resume {
214 locode_core::find_rollout_by_id(&root, cwd, id)
215 .ok_or_else(|| PreRunError(format!("--resume: no session `{id}` found")))?
216 } else {
217 locode_core::find_latest_rollout(&root, cwd).ok_or_else(|| {
218 PreRunError(format!(
219 "--continue: no session found for {}",
220 cwd.display()
221 ))
222 })?
223 };
224 let contents = locode_core::read_rollout(&path).map_err(PreRunError)?;
225 Some((path, contents))
226 } else {
227 None
228 };
229
230 if let Some((path, contents)) = recovered {
231 let meta = contents.meta;
232 if let Some(flag) = cli.harness
234 && flag.as_str() != meta.harness
235 {
236 return Err(PreRunError(format!(
237 "--harness {} conflicts with the resumed session's harness `{}`",
238 flag.as_str(),
239 meta.harness
240 )));
241 }
242 if let Some(flag) = &cli.api_schema
243 && flag != &meta.api_schema
244 {
245 return Err(PreRunError(format!(
246 "--api-schema {flag} conflicts with the resumed session's wire `{}` \
247 (a session never crosses wires)",
248 meta.api_schema
249 )));
250 }
251 return Ok(RunIdentity {
252 harness: meta.harness.clone(),
253 api_schema: meta.api_schema.clone(),
254 model_override: cli.model.clone().or_else(|| settings.model.clone()),
260 session_id: meta.session_id.clone(),
261 resumed: Some(ResumedSession {
262 path,
263 history: contents.history,
264 }),
265 });
266 }
267
268 Ok(RunIdentity {
269 harness: match cli.harness {
270 Some(harness) => harness.as_str().to_string(),
271 None => settings
272 .harness
273 .clone()
274 .unwrap_or_else(|| "claude".to_string()),
275 },
276 api_schema: cli
277 .api_schema
278 .clone()
279 .or_else(|| settings.api_schema.clone())
280 .unwrap_or_else(|| "anthropic".to_string()),
281 model_override: cli.model.clone().or_else(|| settings.model.clone()),
282 session_id: new_session_id(),
283 resumed: None,
284 })
285}
286
287fn build_trace_writer(
293 cli: &Cli,
294 identity: &RunIdentity,
295 cwd: &std::path::Path,
296) -> Option<locode_core::TraceWriter> {
297 let root = locode_core::locode_home()
298 .ok()
299 .filter(|_| !cli.no_session_persistence)?
300 .join("sessions");
301 match &identity.resumed {
302 Some(resumed) => locode_core::TraceWriter::resume(resumed.path.clone(), root)
304 .map_err(|e| output::warning_line(&format!("trace: {e}; tracing disabled")))
305 .ok(),
306 None => Some(locode_core::TraceWriter::new(
307 root,
308 locode_core::TraceExtras {
309 cli_version: env!("CARGO_PKG_VERSION").to_string(),
310 git: git_meta(cwd),
311 ..Default::default()
312 },
313 )),
314 }
315}
316
317fn make_sink(
322 output_format: OutputFormat,
323 mut trace: Option<locode_core::TraceWriter>,
324) -> Box<dyn EventSink> {
325 let stream = matches!(output_format, OutputFormat::StreamJson);
326 Box::new(FnSink(move |event| {
327 if let Some(writer) = trace.as_mut() {
328 writer.on_event(&event);
329 if let Some(e) = writer.take_error() {
330 output::warning_line(&format!("trace: {e}; tracing disabled"));
331 }
332 }
333 if stream && in_whole_message_trace(&event) {
334 output::write_json_line(&event);
335 }
336 }))
337}
338
339fn enforce_wire_requirement(pack: &dyn locode_core::Pack, schema: &str) -> Result<(), PreRunError> {
343 if schema != "mock"
344 && let Some(required) = pack.required_api_schemas()
345 && !required.contains(&schema)
346 {
347 return Err(PreRunError(format!(
348 "harness `{}` requires one of these wires: {}; got `--api-schema {}`",
349 pack.name(),
350 required.join(", "),
351 schema,
352 )));
353 }
354 Ok(())
355}
356
357fn resolve_prompt(arg: Option<&str>) -> Result<String, PreRunError> {
360 let prompt = match arg {
361 Some("-") | None => {
362 let mut buf = String::new();
363 std::io::stdin().read_to_string(&mut buf)?;
364 buf
365 }
366 Some(text) => text.to_string(),
367 };
368 let prompt = prompt.trim().to_string();
369 if prompt.is_empty() {
370 return Err(PreRunError(
371 "no prompt: pass it as the positional argument or on stdin".to_string(),
372 ));
373 }
374 Ok(prompt)
375}
376
377fn git_meta(cwd: &std::path::Path) -> Option<locode_core::GitMeta> {
381 if !detect_git_repo(cwd) {
382 return None;
383 }
384 let run = |args: &[&str]| -> Option<String> {
385 let out = std::process::Command::new("git")
386 .arg("-C")
387 .arg(cwd)
388 .args(args)
389 .output()
390 .ok()?;
391 if !out.status.success() {
392 return None;
393 }
394 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
395 (!s.is_empty()).then_some(s)
396 };
397 Some(locode_core::GitMeta {
398 root: run(&["rev-parse", "--show-toplevel"]).map(std::path::PathBuf::from),
399 branch: run(&["rev-parse", "--abbrev-ref", "HEAD"]),
400 head: run(&["rev-parse", "HEAD"]),
401 remote: run(&["remote", "get-url", "origin"]),
402 })
403}
404
405fn detect_git_repo(cwd: &std::path::Path) -> bool {
409 cwd.ancestors().any(|dir| dir.join(".git").exists())
410}
411
412fn os_version() -> Option<String> {
415 #[cfg(unix)]
416 {
417 let out = std::process::Command::new("uname")
418 .args(["-s", "-r"])
419 .output()
420 .ok()?;
421 if !out.status.success() {
422 return None;
423 }
424 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
425 (!s.is_empty()).then_some(s)
426 }
427 #[cfg(not(unix))]
428 {
429 None
430 }
431}
432
433fn timezone() -> Option<String> {
438 if let Ok(tz) = std::env::var("TZ") {
439 let tz = tz.trim();
440 if !tz.is_empty() {
441 return Some(tz.to_string());
442 }
443 }
444 #[cfg(unix)]
445 {
446 let target = std::fs::read_link("/etc/localtime").ok()?;
447 let s = target.to_string_lossy();
448 s.split_once("zoneinfo/")
449 .map(|(_, name)| name.to_string())
450 .filter(|name| !name.is_empty())
451 }
452 #[cfg(not(unix))]
453 {
454 None
455 }
456}
457
458fn new_session_id() -> String {
460 let now = std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .map_or(0, |d| d.as_millis());
463 format!("sess-{now}-{}", std::process::id())
464}
465
466fn in_whole_message_trace(event: &locode_core::Event) -> bool {
470 !matches!(event, locode_core::Event::MessageDelta { .. })
471}
472
473#[cfg(test)]
474mod tests {
475 use super::{enforce_wire_requirement, in_whole_message_trace};
476 use locode_core::{Event, Message, Role};
477
478 #[test]
479 fn codex_rejects_a_non_responses_wire() {
480 let codex = locode_core::resolve("codex").unwrap();
481 let err = enforce_wire_requirement(codex, "anthropic").expect_err("mismatch");
483 assert!(err.0.contains("codex"), "{}", err.0);
484 assert!(err.0.contains("openai-responses"), "{}", err.0);
485 assert!(err.0.contains("anthropic"), "{}", err.0);
486 assert!(enforce_wire_requirement(codex, "openai-responses").is_ok());
488 assert!(enforce_wire_requirement(codex, "mock").is_ok());
489 }
490
491 #[test]
492 fn wire_agnostic_packs_accept_any_wire() {
493 let grok = locode_core::resolve("grok").unwrap();
494 assert!(enforce_wire_requirement(grok, "anthropic").is_ok());
495 assert!(enforce_wire_requirement(grok, "openai-responses").is_ok());
496 }
497
498 #[test]
499 fn stream_json_trace_drops_message_deltas_keeps_whole_messages() {
500 assert!(!in_whole_message_trace(&Event::MessageDelta {
502 text: "tok".into()
503 }));
504 assert!(in_whole_message_trace(&Event::Message {
506 message: Message {
507 role: Role::Assistant,
508 content: vec![],
509 },
510 }));
511 assert!(in_whole_message_trace(&Event::Error {
512 message: "e".into()
513 }));
514 }
515}