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 add_dirs = canonicalize_add_dirs(&cli.add_dir)?;
57 let mut host_config = host_config_for(&cwd, &add_dirs);
58 if !cli.restricted {
62 host_config.path_policy = PathPolicy::Unrestricted;
63 }
64 let host = Arc::new(Host::new(host_config)?);
65 output::warning_line(if cli.restricted {
66 output::RESTRICTED_MODE_NOTICE
67 } else {
68 output::UNRESTRICTED_MODE_NOTICE
69 });
70
71 let settings_load = load_settings_reporting(&cwd, cli.settings.as_deref());
75 let settings = settings_load.settings;
76 let effort = resolve_effort_reporting(cli.effort, settings.effort.as_deref());
77 let extends_dirs = settings_load.extends_dirs;
81
82 let identity = resolve_identity(&cli, &cwd, &settings)?;
87
88 let pack = locode_core::resolve(&identity.harness)?;
92 let registry = pack.build_registry(&host);
93 let session_id = identity.session_id.clone();
94 let built = providers
95 .build(
96 &identity.api_schema,
97 &ProviderInit {
98 session_id: session_id.clone(),
99 model: identity.model_override.clone(),
100 },
101 )
102 .map_err(|e| PreRunError(e.to_string()))?;
103 let (provider, model) = (built.provider, built.model);
104
105 enforce_wire_requirement(pack, provider.api_schema())?;
109
110 let pack_ctx = PackContext {
112 cwd: cwd.clone(),
113 os: std::env::consts::OS.to_string(),
114 shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
115 date: chrono::Local::now().format("%Y-%m-%d").to_string(),
116 headless: true,
117 is_git_repo: detect_git_repo(&cwd),
118 model: Some(model.clone()),
119 os_version: os_version(),
120 timezone: timezone(),
121 strip_identity: cli.strip_identity,
122 };
123 let preamble = match &identity.resumed {
127 Some(resumed) => resumed.history.clone(),
128 None => pack.preamble(&pack_ctx),
129 };
130
131 let user_prompt = pack.shape_user_prompt(&prompt);
134
135 let config = EngineConfig {
137 session_id,
138 harness: pack.name().to_string(),
139 api_schema: provider.api_schema().to_string(),
140 model,
141 cwd: cwd.clone(),
142 workspace_root: cwd,
143 max_turns: cli.max_turns,
144 sampling_args: SamplingArgs {
145 reasoning_effort: effort.map(Into::into),
146 ..SamplingArgs::default()
147 },
148 cache_hint: CacheHint::Standard,
149 streaming: cli.stream,
153 instructions: InstructionsConfig {
157 enabled: !cli.no_project_instructions,
158 root_stop_pattern: settings.root_stop_pattern.clone(),
159 extends_dirs: extends_dirs.clone(),
160 extra_roots: add_dirs.clone(),
161 ..InstructionsConfig::default()
162 },
163 skills: SkillsConfig {
166 extends_dirs,
167 extra: settings.skills_extra.clone(),
168 extra_roots: add_dirs.clone(),
169 ..SkillsConfig::enabled()
170 },
171 ..EngineConfig::default()
172 };
173 let mut trace = build_trace_writer(&cli, &identity, &config.cwd);
176
177 let sink = make_sink(cli.output_format, trace.take());
178
179 let mut session = Session::new(provider, registry, preamble, config, sink);
181 #[cfg(unix)]
182 crate::signal::arm(&cancel_slot, session.cancel_handle());
183 let report = session.run_text(user_prompt).await;
184
185 match cli.output_format {
186 OutputFormat::Json => output::write_json_line(&report),
187 OutputFormat::Text => output::write_text(report.final_message.as_deref().unwrap_or("")),
188 OutputFormat::StreamJson => {} }
190 Ok(output::exit_code(report.status))
191}
192
193struct ResumedSession {
195 path: std::path::PathBuf,
196 history: Vec<locode_core::Message>,
197}
198
199struct RunIdentity {
203 harness: String,
204 api_schema: String,
205 model_override: Option<String>,
206 session_id: String,
207 resumed: Option<ResumedSession>,
208}
209
210fn resolve_identity(
211 cli: &Cli,
212 cwd: &std::path::Path,
213 settings: &locode_core::Settings,
214) -> Result<RunIdentity, PreRunError> {
215 let recovered = if cli.continue_session || cli.resume.is_some() {
217 let home = locode_core::locode_home().map_err(PreRunError)?;
218 let root = home.join("sessions");
219 let path = if let Some(id) = &cli.resume {
220 locode_core::find_rollout_by_id(&root, cwd, id)
221 .ok_or_else(|| PreRunError(format!("--resume: no session `{id}` found")))?
222 } else {
223 locode_core::find_latest_rollout(&root, cwd).ok_or_else(|| {
224 PreRunError(format!(
225 "--continue: no session found for {}",
226 cwd.display()
227 ))
228 })?
229 };
230 let contents = locode_core::read_rollout(&path).map_err(PreRunError)?;
231 Some((path, contents))
232 } else {
233 None
234 };
235
236 if let Some((path, contents)) = recovered {
237 let meta = contents.meta;
238 if let Some(flag) = cli.harness
240 && flag.as_str() != meta.harness
241 {
242 return Err(PreRunError(format!(
243 "--harness {} conflicts with the resumed session's harness `{}`",
244 flag.as_str(),
245 meta.harness
246 )));
247 }
248 if let Some(flag) = &cli.api_schema
249 && flag != &meta.api_schema
250 {
251 return Err(PreRunError(format!(
252 "--api-schema {flag} conflicts with the resumed session's wire `{}` \
253 (a session never crosses wires)",
254 meta.api_schema
255 )));
256 }
257 return Ok(RunIdentity {
258 harness: meta.harness.clone(),
259 api_schema: meta.api_schema.clone(),
260 model_override: cli.model.clone().or_else(|| settings.model.clone()),
266 session_id: meta.session_id.clone(),
267 resumed: Some(ResumedSession {
268 path,
269 history: contents.history,
270 }),
271 });
272 }
273
274 Ok(RunIdentity {
275 harness: match cli.harness {
276 Some(harness) => harness.as_str().to_string(),
277 None => settings
278 .harness
279 .clone()
280 .unwrap_or_else(|| "claude".to_string()),
281 },
282 api_schema: cli
283 .api_schema
284 .clone()
285 .or_else(|| settings.api_schema.clone())
286 .unwrap_or_else(|| "anthropic".to_string()),
287 model_override: cli.model.clone().or_else(|| settings.model.clone()),
288 session_id: new_session_id(),
289 resumed: None,
290 })
291}
292
293fn build_trace_writer(
299 cli: &Cli,
300 identity: &RunIdentity,
301 cwd: &std::path::Path,
302) -> Option<locode_core::TraceWriter> {
303 let root = locode_core::locode_home()
304 .ok()
305 .filter(|_| !cli.no_session_persistence)?
306 .join("sessions");
307 match &identity.resumed {
308 Some(resumed) => locode_core::TraceWriter::resume(resumed.path.clone(), root)
310 .map_err(|e| output::warning_line(&format!("trace: {e}; tracing disabled")))
311 .ok(),
312 None => Some(locode_core::TraceWriter::new(
313 root,
314 locode_core::TraceExtras {
315 cli_version: env!("CARGO_PKG_VERSION").to_string(),
316 git: git_meta(cwd),
317 ..Default::default()
318 },
319 )),
320 }
321}
322
323fn make_sink(
328 output_format: OutputFormat,
329 mut trace: Option<locode_core::TraceWriter>,
330) -> Box<dyn EventSink> {
331 let stream = matches!(output_format, OutputFormat::StreamJson);
332 Box::new(FnSink(move |event| {
333 if let Some(writer) = trace.as_mut() {
334 writer.on_event(&event);
335 if let Some(e) = writer.take_error() {
336 output::warning_line(&format!("trace: {e}; tracing disabled"));
337 }
338 }
339 if stream && in_whole_message_trace(&event) {
340 output::write_json_line(&event);
341 }
342 }))
343}
344
345fn enforce_wire_requirement(pack: &dyn locode_core::Pack, schema: &str) -> Result<(), PreRunError> {
349 if schema != "mock"
350 && let Some(required) = pack.required_api_schemas()
351 && !required.contains(&schema)
352 {
353 return Err(PreRunError(format!(
354 "harness `{}` requires one of these wires: {}; got `--api-schema {}`",
355 pack.name(),
356 required.join(", "),
357 schema,
358 )));
359 }
360 Ok(())
361}
362
363fn resolve_prompt(arg: Option<&str>) -> Result<String, PreRunError> {
366 let prompt = match arg {
367 Some("-") | None => {
368 let mut buf = String::new();
369 std::io::stdin().read_to_string(&mut buf)?;
370 buf
371 }
372 Some(text) => text.to_string(),
373 };
374 let prompt = prompt.trim().to_string();
375 if prompt.is_empty() {
376 return Err(PreRunError(
377 "no prompt: pass it as the positional argument or on stdin".to_string(),
378 ));
379 }
380 Ok(prompt)
381}
382
383fn git_meta(cwd: &std::path::Path) -> Option<locode_core::GitMeta> {
387 if !detect_git_repo(cwd) {
388 return None;
389 }
390 let run = |args: &[&str]| -> Option<String> {
391 let out = std::process::Command::new("git")
392 .arg("-C")
393 .arg(cwd)
394 .args(args)
395 .output()
396 .ok()?;
397 if !out.status.success() {
398 return None;
399 }
400 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
401 (!s.is_empty()).then_some(s)
402 };
403 Some(locode_core::GitMeta {
404 root: run(&["rev-parse", "--show-toplevel"]).map(std::path::PathBuf::from),
405 branch: run(&["rev-parse", "--abbrev-ref", "HEAD"]),
406 head: run(&["rev-parse", "HEAD"]),
407 remote: run(&["remote", "get-url", "origin"]),
408 })
409}
410
411fn detect_git_repo(cwd: &std::path::Path) -> bool {
415 cwd.ancestors().any(|dir| dir.join(".git").exists())
416}
417
418fn os_version() -> Option<String> {
421 #[cfg(unix)]
422 {
423 let out = std::process::Command::new("uname")
424 .args(["-s", "-r"])
425 .output()
426 .ok()?;
427 if !out.status.success() {
428 return None;
429 }
430 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
431 (!s.is_empty()).then_some(s)
432 }
433 #[cfg(not(unix))]
434 {
435 None
436 }
437}
438
439fn timezone() -> Option<String> {
444 if let Ok(tz) = std::env::var("TZ") {
445 let tz = tz.trim();
446 if !tz.is_empty() {
447 return Some(tz.to_string());
448 }
449 }
450 #[cfg(unix)]
451 {
452 let target = std::fs::read_link("/etc/localtime").ok()?;
453 let s = target.to_string_lossy();
454 s.split_once("zoneinfo/")
455 .map(|(_, name)| name.to_string())
456 .filter(|name| !name.is_empty())
457 }
458 #[cfg(not(unix))]
459 {
460 None
461 }
462}
463
464fn new_session_id() -> String {
466 let now = std::time::SystemTime::now()
467 .duration_since(std::time::UNIX_EPOCH)
468 .map_or(0, |d| d.as_millis());
469 format!("sess-{now}-{}", std::process::id())
470}
471
472fn in_whole_message_trace(event: &locode_core::Event) -> bool {
480 !matches!(
481 event,
482 locode_core::Event::MessageDelta { .. } | locode_core::Event::MessageDeltaReset { .. }
483 )
484}
485
486pub fn canonicalize_add_dirs(
496 dirs: &[std::path::PathBuf],
497) -> Result<Vec<std::path::PathBuf>, PreRunError> {
498 dirs.iter()
499 .map(|dir| {
500 std::fs::canonicalize(dir)
501 .map_err(|e| PreRunError(format!("--add-dir {}: {e}", dir.display())))
502 })
503 .collect()
504}
505
506fn load_settings_reporting(
509 cwd: &std::path::Path,
510 inline: Option<&str>,
511) -> locode_core::SettingsLoad {
512 let load = locode_core::load_settings(cwd, inline);
513 for warning in &load.warnings {
514 output::warning_line(warning);
515 }
516 load
517}
518
519fn host_config_for(cwd: &std::path::Path, add_dirs: &[std::path::PathBuf]) -> HostConfig {
521 let mut config = HostConfig::new(cwd);
522 config.extra_roots = add_dirs.to_vec();
523 config
524}
525
526fn resolve_effort_reporting(
528 flag: Option<crate::EffortArg>,
529 setting: Option<&str>,
530) -> Option<locode_core::Effort> {
531 let mut warnings = Vec::new();
532 let effort = resolve_effort(flag, setting, &mut warnings);
533 for warning in &warnings {
534 output::warning_line(warning);
535 }
536 effort
537}
538
539#[must_use]
545pub fn resolve_effort(
546 flag: Option<crate::EffortArg>,
547 setting: Option<&str>,
548 warnings: &mut Vec<String>,
549) -> Option<locode_core::Effort> {
550 if let Some(flag) = flag {
551 return Some(flag.into());
552 }
553 let raw = setting?;
554 if let Some(effort) = locode_core::Effort::parse(raw) {
555 return Some(effort);
556 }
557 warnings.push(format!(
558 "settings: unknown effort {raw:?} — using the API default (expected one of {})",
559 locode_core::Effort::ALL
560 .iter()
561 .map(|e| e.as_str())
562 .collect::<Vec<_>>()
563 .join(", ")
564 ));
565 None
566}
567
568#[cfg(test)]
569mod tests {
570 use super::{enforce_wire_requirement, in_whole_message_trace};
571 use locode_core::{Event, Message, Role};
572
573 #[test]
574 fn codex_rejects_a_non_responses_wire() {
575 let codex = locode_core::resolve("codex").unwrap();
576 let err = enforce_wire_requirement(codex, "anthropic").expect_err("mismatch");
578 assert!(err.0.contains("codex"), "{}", err.0);
579 assert!(err.0.contains("openai-responses"), "{}", err.0);
580 assert!(err.0.contains("anthropic"), "{}", err.0);
581 assert!(enforce_wire_requirement(codex, "openai-responses").is_ok());
583 assert!(enforce_wire_requirement(codex, "mock").is_ok());
584 }
585
586 #[test]
587 fn wire_agnostic_packs_accept_any_wire() {
588 let grok = locode_core::resolve("grok").unwrap();
589 assert!(enforce_wire_requirement(grok, "anthropic").is_ok());
590 assert!(enforce_wire_requirement(grok, "openai-responses").is_ok());
591 }
592
593 #[test]
594 fn stream_json_trace_drops_message_deltas_keeps_whole_messages() {
595 assert!(!in_whole_message_trace(&Event::MessageDelta {
597 text: "tok".into()
598 }));
599 assert!(in_whole_message_trace(&Event::Message {
601 message: Message {
602 role: Role::Assistant,
603 content: vec![],
604 },
605 }));
606 assert!(in_whole_message_trace(&Event::Error {
607 message: "e".into()
608 }));
609 }
610}