1use std::path::PathBuf;
4use std::process::{Output, Stdio};
5use std::time::{Duration, Instant};
6
7use serde_json::Value;
8use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
9use tokio::process::Command;
10
11use crate::{
12 AgentEvent, Error, EventHandler, McpServer, OpenCodeV2, RunRequest, RunResult, TokenUsage,
13 ToolPolicy,
14};
15
16#[derive(Debug, thiserror::Error)]
18#[error("{provider:?} CLI: {source}")]
19pub struct ProviderError {
20 pub provider: Provider,
21 #[source]
22 pub source: Error,
23}
24
25impl ProviderError {
26 pub fn exit_code(&self) -> Option<i32> {
28 match &self.source {
29 Error::Exit { status, .. } => *status,
30 _ => None,
31 }
32 }
33
34 pub fn stderr_tail(&self) -> Option<&str> {
36 match &self.source {
37 Error::Exit { stderr_tail, .. } => Some(stderr_tail),
38 _ => None,
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Provider {
45 Claude,
46 Codex,
47 Devin,
48 OpenCodeV2,
49}
50
51impl Provider {
52 pub fn native_system_prompt(self) -> bool {
55 matches!(self, Self::Claude | Self::Codex)
56 }
57
58 pub fn executable(self) -> &'static str {
59 match self {
60 Self::Claude => "claude",
61 Self::Codex => "codex",
62 Self::Devin => "devin",
63 Self::OpenCodeV2 => "opencode",
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct AgentCli {
71 provider: Provider,
72 binary: PathBuf,
73}
74
75impl AgentCli {
76 pub fn discover_with_context(provider: Provider) -> Result<Self, ProviderError> {
78 Self::discover(provider).map_err(|source| ProviderError { provider, source })
79 }
80
81 pub fn with_binary_with_context(
83 provider: Provider,
84 binary: impl Into<PathBuf>,
85 ) -> Result<Self, ProviderError> {
86 Self::with_binary(provider, binary).map_err(|source| ProviderError { provider, source })
87 }
88
89 pub fn discover(provider: Provider) -> Result<Self, Error> {
90 Self::with_binary(provider, provider.executable())
91 }
92
93 pub fn with_binary(provider: Provider, binary: impl Into<PathBuf>) -> Result<Self, Error> {
94 let binary = binary.into();
95 if provider == Provider::OpenCodeV2 {
96 OpenCodeV2::with_binary(binary.clone())?;
97 } else {
98 let output = std::process::Command::new(&binary)
99 .arg("--version")
100 .output()
101 .map_err(|e| match e.kind() {
102 std::io::ErrorKind::NotFound => Error::NotFound(binary.clone()),
103 _ => Error::VersionProbe(e.to_string()),
104 })?;
105 if !output.status.success() {
106 return Err(Error::VersionProbe(format!(
107 "{} --version exited {:?}",
108 provider.executable(),
109 output.status.code()
110 )));
111 }
112 }
113 Ok(Self { provider, binary })
114 }
115
116 pub fn provider(&self) -> Provider {
117 self.provider
118 }
119
120 pub fn native_system_prompt(&self) -> bool {
123 self.provider.native_system_prompt()
124 }
125
126 pub async fn run_with_context(&self, request: RunRequest) -> Result<RunResult, ProviderError> {
128 self.run(request).await.map_err(|source| ProviderError {
129 provider: self.provider,
130 source,
131 })
132 }
133
134 pub async fn run(&self, request: RunRequest) -> Result<RunResult, Error> {
135 validate_request(&request)?;
136 match self.provider {
137 Provider::OpenCodeV2 => {
138 OpenCodeV2 {
139 binary: self.binary.clone(),
140 }
141 .run(request)
142 .await
143 }
144 Provider::Claude => self.run_claude(request).await,
145 Provider::Codex => self.run_codex(request).await,
146 Provider::Devin => self.run_devin(request).await,
147 }
148 }
149
150 async fn run_claude(&self, request: RunRequest) -> Result<RunResult, Error> {
151 let streaming = request.on_event.is_some();
152 let mut command = Command::new(&self.binary);
153 command.args(["-p", "--output-format"]);
154 if streaming {
155 command.args(["stream-json", "--verbose", "--include-partial-messages"]);
156 } else {
157 command.arg("json");
158 }
159 command.args(["--setting-sources", "local"]);
160 match (&request.resume, request.persist_session) {
161 (Some(session), _) => {
162 command.arg("--resume").arg(session);
163 }
164 (None, false) => {
165 command.arg("--no-session-persistence");
166 }
167 (None, true) => {}
168 }
169 claude_tools(&mut command, &request);
170 if let Some(system) = request
171 .system_prompt
172 .as_deref()
173 .filter(|s| !s.trim().is_empty())
174 {
175 command.arg("--append-system-prompt").arg(system);
176 }
177 if let Some(model) = request.model.as_deref() {
178 let (name, effort) = split_effort(model, &["low", "medium", "high", "xhigh", "max"])?;
179 command.arg("--model").arg(name);
180 if let Some(effort) = effort {
181 command.arg("--effort").arg(effort);
182 }
183 }
184 if let Some(mut schema) = request.json_schema.clone() {
185 strip_schema_uri(&mut schema);
186 command.arg("--json-schema").arg(schema.to_string());
187 }
188 configure(&mut command, &request, true);
189 if request.tool_policy != ToolPolicy::Default {
190 command.env("CLAUDE_CODE_DISABLE_ATTACHMENTS", "1");
194 }
195 let handler = request.on_event.clone();
196 let on_line = move |line: &str| {
197 if let Some(handler) = &handler {
198 claude_event(handler, line);
199 }
200 };
201 let (output, duration) = capture(
202 command,
203 Some(&request.prompt),
204 request.timeout,
205 streaming.then_some(&on_line as &(dyn Fn(&str) + Send + Sync)),
206 )
207 .await?;
208 let stderr_tail = stderr_tail(&output.stderr);
209 if let Some(session) = &request.resume
210 && !output.status.success()
211 && stderr_tail.contains("No conversation found")
212 {
213 return Err(Error::SessionNotFound(session.clone()));
214 }
215 let parsed = if streaming {
216 String::from_utf8_lossy(&output.stdout)
218 .lines()
219 .rev()
220 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
221 .find(|v| v["type"] == "result")
222 .ok_or_else(|| serde_json::Error::io(std::io::Error::other("no result line")))
223 } else {
224 serde_json::from_slice(&output.stdout)
225 };
226 let envelope: Value = match parsed {
227 Ok(value) => value,
228 Err(error) => {
229 check_status(&output, &stderr_tail)?;
230 return Err(Error::Protocol(format!("Claude JSON envelope: {error}")));
231 }
232 };
233 let error = if envelope["is_error"].as_bool() == Some(true) {
234 let details = envelope["errors"]
235 .as_array()
236 .map(|items| {
237 items
238 .iter()
239 .filter_map(Value::as_str)
240 .collect::<Vec<_>>()
241 .join("; ")
242 })
243 .filter(|s| !s.is_empty());
244 Some(
245 details
246 .or_else(|| envelope["result"].as_str().map(str::to_string))
247 .unwrap_or_else(|| "unknown Claude error".into()),
248 )
249 } else {
250 None
251 };
252 if let Some(error) = error {
253 return Err(Error::Agent(error));
254 }
255 check_status(&output, &stderr_tail)?;
256 let text = envelope
257 .get("structured_output")
258 .filter(|v| !v.is_null())
259 .map(Value::to_string)
260 .or_else(|| envelope["result"].as_str().map(str::to_string))
261 .unwrap_or_default();
262 let usage = envelope
263 .get("usage")
264 .filter(|v| v.is_object())
265 .map(|u| TokenUsage {
266 input: u["input_tokens"].as_u64().unwrap_or(0),
267 output: u["output_tokens"].as_u64().unwrap_or(0),
268 cache_read: u["cache_read_input_tokens"].as_u64().unwrap_or(0),
269 cache_write: u["cache_creation_input_tokens"].as_u64().unwrap_or(0),
270 ..Default::default()
271 });
272 result(
273 text,
274 envelope["session_id"].as_str(),
275 usage,
276 duration,
277 stderr_tail,
278 )
279 }
280
281 async fn run_codex(&self, request: RunRequest) -> Result<RunResult, Error> {
282 let out_file = tempfile::NamedTempFile::new()?;
283 let schema_file = if let Some(mut schema) = request.json_schema.clone() {
284 normalize_codex_schema(&mut schema);
285 let file = tempfile::NamedTempFile::new()?;
286 std::fs::write(file.path(), schema.to_string())?;
287 Some(file)
288 } else {
289 None
290 };
291 let mut command = Command::new(&self.binary);
292 command.arg("exec");
293 if request.resume.is_some() {
294 command.arg("resume");
295 }
296 command.args(["--skip-git-repo-check", "-c", "sandbox_mode=\"read-only\""]);
298 if request.resume.is_none() {
299 command.args(["--color", "never"]);
300 if !request.persist_session {
301 command.arg("--ephemeral");
302 }
303 }
304 command
305 .args([
306 "--disable",
307 "hooks",
308 "-c",
309 "project_doc_max_bytes=0",
310 "--json",
311 "-o",
312 ])
313 .arg(out_file.path());
314 if let Some(model) = request.model.as_deref() {
315 let (name, effort) =
316 split_effort(model, &["low", "medium", "high", "xhigh", "max", "ultra"])?;
317 command.arg("-m").arg(name);
318 if let Some(effort) = effort {
319 command
320 .arg("-c")
321 .arg(format!("model_reasoning_effort=\"{effort}\""));
322 }
323 }
324 if let Some(file) = &schema_file {
325 command.arg("--output-schema").arg(file.path());
326 }
327 codex_tools(&mut command, &request);
328 if let Some(system) = request
329 .system_prompt
330 .as_deref()
331 .filter(|s| !s.trim().is_empty())
332 {
333 command
334 .arg("-c")
335 .arg(format!("developer_instructions={}", toml_string(system)));
336 }
337 if let Some(session) = &request.resume {
338 command.arg(session);
339 }
340 command.arg("-");
341 configure(&mut command, &request, true);
342 let handler = request.on_event.clone();
343 let on_line = move |line: &str| {
344 if let Some(handler) = &handler {
345 codex_event(handler, line);
346 }
347 };
348 let (output, duration) = capture(
349 command,
350 Some(&request.prompt),
351 request.timeout,
352 request
353 .on_event
354 .is_some()
355 .then_some(&on_line as &(dyn Fn(&str) + Send + Sync)),
356 )
357 .await?;
358 let stderr_tail = stderr_tail(&output.stderr);
359 if let Some(session) = &request.resume
361 && !output.status.success()
362 && stderr_tail.contains("no rollout found")
363 {
364 return Err(Error::SessionNotFound(session.clone()));
365 }
366 check_status(&output, &stderr_tail)?;
367 let mut text = None;
368 let mut session_id = None;
369 let mut usage = None;
370 for line in String::from_utf8_lossy(&output.stdout).lines() {
371 let event: Value = serde_json::from_str(line)
372 .map_err(|e| Error::Protocol(format!("Codex JSONL: {e}")))?;
373 match event["type"].as_str() {
374 Some("thread.started") => {
375 session_id = event["thread_id"].as_str().map(str::to_string)
376 }
377 Some("item.completed") if event["item"]["type"] == "agent_message" => {
378 text = event["item"]["text"].as_str().map(str::to_string);
379 }
380 Some("turn.completed") => {
381 let u = &event["usage"];
382 usage = Some(TokenUsage {
383 input: u["input_tokens"].as_u64().unwrap_or(0),
384 output: u["output_tokens"].as_u64().unwrap_or(0),
385 reasoning: u["reasoning_output_tokens"].as_u64().unwrap_or(0),
386 ..Default::default()
387 });
388 }
389 Some("turn.failed" | "error") => {
390 return Err(Error::Agent(
391 event["error"]["message"]
392 .as_str()
393 .or_else(|| event["message"].as_str())
394 .unwrap_or("unknown Codex error")
395 .to_string(),
396 ));
397 }
398 _ => {}
399 }
400 }
401 let from_file = std::fs::read_to_string(out_file.path())?;
402 result(
403 if from_file.trim().is_empty() {
404 text.unwrap_or_default()
405 } else {
406 from_file
407 },
408 session_id.as_deref(),
409 usage,
410 duration,
411 stderr_tail,
412 )
413 }
414
415 async fn run_devin(&self, request: RunRequest) -> Result<RunResult, Error> {
416 request.require_default_tools("Devin")?;
417 if request.resume.is_some() {
418 return Err(Error::InvalidRequest("Devin cannot resume sessions".into()));
419 }
420 let prompt_file = tempfile::NamedTempFile::new()?;
421 std::fs::write(prompt_file.path(), request.prompt_with_system())?;
422 let mut command = Command::new(&self.binary);
423 command
424 .arg("-p")
425 .arg("--prompt-file")
426 .arg(prompt_file.path())
427 .args([
428 "--respect-workspace-trust",
429 "false",
430 "--permission-mode",
431 "auto",
432 ]);
433 if let Some(model) = &request.model {
434 command.arg("--model").arg(model);
435 }
436 configure(&mut command, &request, false);
437 let (output, duration) = capture(command, None, request.timeout, None).await?;
438 let stderr_tail = stderr_tail(&output.stderr);
439 check_status(&output, &stderr_tail)?;
440 result(
441 String::from_utf8_lossy(&output.stdout).to_string(),
442 None,
443 None,
444 duration,
445 stderr_tail,
446 )
447 }
448}
449
450fn claude_event(handler: &EventHandler, line: &str) {
452 let Ok(event) = serde_json::from_str::<Value>(line) else {
453 return;
454 };
455 match event["type"].as_str() {
456 Some("stream_event") => {
457 let delta = &event["event"]["delta"];
458 if delta["type"] == "text_delta"
459 && let Some(text) = delta["text"].as_str()
460 {
461 handler.emit(AgentEvent::TextDelta(text.to_string()));
462 }
463 }
464 Some("assistant") => {
465 for block in event["message"]["content"].as_array().into_iter().flatten() {
466 if block["type"] == "tool_use"
467 && let Some(name) = block["name"].as_str()
468 {
469 handler.emit(AgentEvent::ToolUse {
470 name: name.to_string(),
471 });
472 }
473 }
474 }
475 _ => {}
476 }
477}
478
479fn codex_event(handler: &EventHandler, line: &str) {
481 let Ok(event) = serde_json::from_str::<Value>(line) else {
482 return;
483 };
484 let item = &event["item"];
485 match (event["type"].as_str(), item["type"].as_str()) {
486 (Some("item.started"), Some(kind)) if kind != "agent_message" && kind != "reasoning" => {
487 let name = item["tool"]
488 .as_str()
489 .or_else(|| item["name"].as_str())
490 .unwrap_or(kind);
491 handler.emit(AgentEvent::ToolUse {
492 name: name.to_string(),
493 });
494 }
495 (Some("item.completed"), Some("agent_message")) => {
496 if let Some(text) = item["text"].as_str() {
497 handler.emit(AgentEvent::Text(text.to_string()));
498 }
499 }
500 _ => {}
501 }
502}
503
504pub(crate) fn validate_request(request: &RunRequest) -> Result<(), Error> {
505 if request.prompt.trim().is_empty() {
506 return Err(Error::InvalidRequest("prompt is empty".into()));
507 }
508 if !request.cwd.is_dir() {
509 return Err(Error::InvalidRequest(format!(
510 "working directory does not exist: {}",
511 request.cwd.display()
512 )));
513 }
514 if let Some(session) = &request.resume
515 && (!is_identifier(session) || session.starts_with('-'))
516 {
517 return Err(Error::InvalidRequest(format!(
519 "session id must use ASCII letters, digits, `_` or `-`: {session:?}"
520 )));
521 }
522 let mut names = std::collections::HashSet::new();
523 for server in &request.mcp_servers {
524 validate_mcp_server(server)?;
525 if !names.insert(server.name.as_str()) {
526 return Err(Error::InvalidRequest(format!(
527 "duplicate MCP server name: {}",
528 server.name
529 )));
530 }
531 }
532 Ok(())
533}
534
535fn is_identifier(s: &str) -> bool {
536 !s.is_empty()
537 && s.bytes()
538 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
539}
540
541fn validate_mcp_server(server: &McpServer) -> Result<(), Error> {
542 if !is_identifier(&server.name) {
543 return Err(Error::InvalidRequest(format!(
544 "MCP server name must use ASCII letters, digits, `_` or `-`: {:?}",
545 server.name
546 )));
547 }
548 if !(server.url.starts_with("http://") || server.url.starts_with("https://"))
549 || server.url.chars().any(|c| c.is_whitespace() || c == '"')
550 {
551 return Err(Error::InvalidRequest(format!(
552 "MCP server URL must be http(s) without spaces or quotes: {}",
553 server.url
554 )));
555 }
556 if let Some(var) = &server.bearer_token_env
557 && !(is_identifier(var) && !var.contains('-'))
558 {
559 return Err(Error::InvalidRequest(format!(
560 "bearer token variable must be a plain environment variable name: {var:?}"
561 )));
562 }
563 Ok(())
564}
565
566const CLAUDE_WEB_TOOLS: &str = "WebSearch,WebFetch";
569
570fn claude_tools(command: &mut Command, request: &RunRequest) {
572 let restricted = request.tool_policy != ToolPolicy::Default;
573 if !restricted {
574 command.args(["--permission-mode", "bypassPermissions"]);
575 }
576 if !request.mcp_servers.is_empty() {
577 let servers: serde_json::Map<String, Value> = request
578 .mcp_servers
579 .iter()
580 .map(|s| {
581 let mut server = serde_json::json!({"type": "http", "url": s.url});
582 if let Some(var) = &s.bearer_token_env {
583 server["headers"] =
585 serde_json::json!({"Authorization": format!("Bearer ${{{var}}}")});
586 }
587 (s.name.clone(), server)
588 })
589 .collect();
590 command
591 .arg("--mcp-config")
592 .arg(serde_json::json!({"mcpServers": servers}).to_string());
593 }
594 if restricted {
595 command.arg("--strict-mcp-config");
597 command.arg("--disable-slash-commands");
599 let builtin = match request.tool_policy {
600 ToolPolicy::Web => CLAUDE_WEB_TOOLS,
601 _ => "",
602 };
603 command.arg("--tools").arg(builtin);
604 let mut allowed: Vec<String> = request
605 .mcp_servers
606 .iter()
607 .map(|s| format!("mcp__{}", s.name))
608 .collect();
609 if request.tool_policy == ToolPolicy::Web {
610 allowed.extend(CLAUDE_WEB_TOOLS.split(',').map(str::to_string));
611 }
612 if !allowed.is_empty() {
613 command.arg("--allowedTools").arg(allowed.join(","));
614 }
615 command.args(["--permission-mode", "dontAsk"]);
617 }
618}
619
620const CODEX_RESTRICTED_FEATURES: [&str; 8] = [
623 "shell_tool",
624 "unified_exec",
625 "view_image",
626 "browser_use",
627 "computer_use",
628 "in_app_browser",
629 "apps",
630 "plugins",
632];
633
634fn codex_tools(command: &mut Command, request: &RunRequest) {
636 let restricted = request.tool_policy != ToolPolicy::Default;
637 if restricted {
638 for feature in CODEX_RESTRICTED_FEATURES {
639 command.arg("--disable").arg(feature);
640 }
641 let search = if request.tool_policy == ToolPolicy::Web {
642 "live"
643 } else {
644 "disabled"
645 };
646 command.arg("-c").arg(format!("web_search=\"{search}\""));
647 command.arg("-c").arg("mcp_servers={}");
649 }
650 for server in &request.mcp_servers {
651 command.arg("-c").arg(format!(
652 "mcp_servers.{}.url={}",
653 server.name,
654 toml_string(&server.url)
655 ));
656 if let Some(var) = &server.bearer_token_env {
657 command.arg("-c").arg(format!(
658 "mcp_servers.{}.bearer_token_env_var={}",
659 server.name,
660 toml_string(var)
661 ));
662 }
663 command.arg("-c").arg(format!(
666 "mcp_servers.{}.default_tools_approval_mode=\"approve\"",
667 server.name
668 ));
669 }
670}
671
672fn toml_string(value: &str) -> String {
675 Value::String(value.to_owned())
676 .to_string()
677 .replace('\u{7f}', "\\u007F")
678}
679
680fn split_effort<'a>(model: &'a str, valid: &[&str]) -> Result<(&'a str, Option<&'a str>), Error> {
681 if model.trim().is_empty() {
682 return Err(Error::InvalidRequest("model is empty".into()));
683 }
684 if let Some((name, effort)) = model.rsplit_once('@') {
685 if name.is_empty() || !valid.contains(&effort) {
686 return Err(Error::InvalidRequest(format!(
687 "unsupported model effort: {model}"
688 )));
689 }
690 Ok((name, Some(effort)))
691 } else {
692 Ok((model, None))
693 }
694}
695
696fn configure(command: &mut Command, request: &RunRequest, stdin: bool) {
697 command
698 .current_dir(&request.cwd)
699 .stdin(if stdin { Stdio::piped() } else { Stdio::null() })
700 .stdout(Stdio::piped())
701 .stderr(Stdio::piped())
702 .kill_on_drop(true);
703 for name in &request.env_remove {
704 command.env_remove(name);
705 }
706 command.envs(request.env.iter().map(|(k, v)| (k, v)));
707}
708
709type LineHandler<'a> = Option<&'a (dyn Fn(&str) + Send + Sync)>;
711
712async fn capture(
713 mut command: Command,
714 prompt: Option<&str>,
715 timeout: Duration,
716 on_line: LineHandler<'_>,
717) -> Result<(Output, Duration), Error> {
718 let started = Instant::now();
719 let mut child = command.spawn()?;
720 let mut stdin = child.stdin.take();
721 let stdout = child.stdout.take().expect("piped stdout");
722 let mut stderr = child.stderr.take().expect("piped stderr");
723 let work = async {
724 let write = async {
725 if let (Some(prompt), Some(mut stdin)) = (prompt, stdin.take()) {
726 stdin.write_all(prompt.as_bytes()).await?;
727 stdin.shutdown().await?;
728 }
729 Ok::<(), std::io::Error>(())
730 };
731 let read_out = async {
732 let mut bytes = Vec::new();
733 let mut reader = BufReader::new(stdout);
734 loop {
735 let start = bytes.len();
736 if reader.read_until(b'\n', &mut bytes).await? == 0 {
737 break;
738 }
739 if let Some(on_line) = on_line {
740 on_line(String::from_utf8_lossy(&bytes[start..]).trim_end());
741 }
742 }
743 Ok::<_, std::io::Error>(bytes)
744 };
745 let read_err = async {
746 let mut bytes = Vec::new();
747 stderr.read_to_end(&mut bytes).await?;
748 Ok::<_, std::io::Error>(bytes)
749 };
750 let (written, out, err, status) = tokio::join!(write, read_out, read_err, child.wait());
751 let output = Output {
752 status: status?,
753 stdout: out?,
754 stderr: err?,
755 };
756 if let Err(error) = written
757 && output.status.success()
758 {
759 return Err(Error::Io(error));
760 }
761 Ok((output, started.elapsed()))
762 };
763 match tokio::time::timeout(timeout, work).await {
764 Ok(value) => value,
765 Err(_) => {
766 let _ = child.start_kill();
767 let _ = child.wait().await;
768 Err(Error::Timeout(timeout))
769 }
770 }
771}
772
773fn check_status(output: &Output, stderr_tail: &str) -> Result<(), Error> {
774 if output.status.success() {
775 Ok(())
776 } else {
777 Err(Error::Exit {
778 status: output.status.code(),
779 stderr_tail: stderr_tail.into(),
780 })
781 }
782}
783
784fn result(
785 text: String,
786 session_id: Option<&str>,
787 usage: Option<TokenUsage>,
788 duration: Duration,
789 stderr_tail: String,
790) -> Result<RunResult, Error> {
791 let text = text.trim().to_string();
792 if text.is_empty() {
793 return Err(Error::EmptyResponse);
794 }
795 Ok(RunResult {
796 text,
797 session_id: session_id.map(str::to_string),
798 usage,
799 duration,
800 stderr_tail,
801 })
802}
803
804fn stderr_tail(bytes: &[u8]) -> String {
805 let bytes = &bytes[bytes.len().saturating_sub(4096)..];
806 String::from_utf8_lossy(bytes).to_string()
807}
808
809fn strip_schema_uri(value: &mut Value) {
810 match value {
811 Value::Object(map) => {
812 map.remove("$schema");
813 for item in map.values_mut() {
814 strip_schema_uri(item);
815 }
816 }
817 Value::Array(items) => {
818 for item in items {
819 strip_schema_uri(item);
820 }
821 }
822 _ => {}
823 }
824}
825
826fn normalize_codex_schema(value: &mut Value) {
827 let map = match value {
828 Value::Object(map) => map,
829 Value::Array(items) => {
830 for item in items {
831 normalize_codex_schema(item);
832 }
833 return;
834 }
835 _ => return,
836 };
837 map.remove("$schema");
838 if map.contains_key("$ref") && map.len() > 1 {
839 let reference = map.remove("$ref").expect("reference exists");
840 map.clear();
841 map.insert("$ref".into(), reference);
842 }
843 if let Some(Value::Array(variants)) = map.remove("oneOf") {
844 let constants: Option<Vec<Value>> =
845 variants.iter().map(|v| v.get("const").cloned()).collect();
846 if let Some(constants) = constants {
847 let mut types: Vec<&str> = variants
848 .iter()
849 .filter_map(|v| v.get("type").and_then(Value::as_str))
850 .collect();
851 types.sort_unstable();
852 types.dedup();
853 if let [ty] = types.as_slice() {
854 map.insert("type".into(), Value::String((*ty).into()));
855 }
856 map.insert("enum".into(), Value::Array(constants));
857 } else {
858 map.insert("anyOf".into(), Value::Array(variants));
859 }
860 }
861 for child in map.values_mut() {
862 normalize_codex_schema(child);
863 }
864 if map.get("type").and_then(Value::as_str) == Some("object") || map.contains_key("properties") {
865 let required: std::collections::HashSet<String> = map
866 .get("required")
867 .and_then(Value::as_array)
868 .into_iter()
869 .flatten()
870 .filter_map(Value::as_str)
871 .map(str::to_string)
872 .collect();
873 if let Some(Value::Object(properties)) = map.get_mut("properties") {
874 let all = properties.keys().cloned().map(Value::String).collect();
875 for (name, spec) in properties.iter_mut() {
876 if !required.contains(name) && !nullable(spec) {
877 let previous = spec.take();
878 *spec = serde_json::json!({"anyOf":[previous,{"type":"null"}]});
879 }
880 }
881 map.insert("required".into(), Value::Array(all));
882 }
883 map.insert("additionalProperties".into(), Value::Bool(false));
884 }
885}
886
887fn nullable(value: &Value) -> bool {
888 value["type"] == "null"
889 || value["type"]
890 .as_array()
891 .is_some_and(|a| a.iter().any(|v| v == "null"))
892 || value["anyOf"]
893 .as_array()
894 .is_some_and(|a| a.iter().any(|v| v["type"] == "null"))
895}
896
897#[cfg(test)]
898mod tests {
899 use super::toml_string;
900
901 #[test]
902 fn toml_string_escapes_control_characters() {
903 assert_eq!(
904 toml_string("a\"b\\c\nd\u{1}e\u{7f}"),
905 r#""a\"b\\c\nd\u0001e\u007F""#
906 );
907 }
908}