1use std::error::Error;
2use std::fmt::Write as _;
3use std::fs;
4use std::net::TcpStream;
5use std::path::{Component, Path, PathBuf};
6use std::process::{Child, Command};
7use std::time::{Duration, Instant};
8
9use clap::{Args as ClapArgs, ValueEnum};
10use serde_json::Value;
11use toml_edit::{value as toml_value, DocumentMut, Item, Table};
12
13use crate::context_capacity::ContextCapacity;
14use crate::seed::{
15 client_integrations as seed_client_integrations, ClientIntegration, ConfigFormat,
16 ModeArgPosition, ModelArgPosition,
17};
18use crate::DEFAULT_MODEL;
19
20mod command;
21mod session_files;
22mod url;
23use command::resolve_integration_command;
24use session_files::{
25 newest_changed_session_file, print_session_files, session_file_snapshot, user_home_dir,
26 TempConfigDir,
27};
28use url::{base_url_with_port, join_url_path};
29
30const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080";
31const EMPTY_BACKUP_SENTINEL: &str = "# formal-ai-empty-config-backup-v1\n";
32
33#[derive(Debug, Clone, Copy, ValueEnum)]
34pub enum ClientProtocol {
35 Openai,
36 Gemini,
37 Vertex,
38 Anthropic,
39}
40
41impl ClientProtocol {
42 const fn as_str(self) -> &'static str {
43 match self {
44 Self::Openai => "openai",
45 Self::Gemini => "gemini",
46 Self::Vertex => "vertex",
47 Self::Anthropic => "anthropic",
48 }
49 }
50}
51
52#[derive(Debug, Clone, ClapArgs)]
53#[command(trailing_var_arg = true)]
54#[allow(clippy::struct_excessive_bools)]
55pub struct WithFormalAiArgs {
56 #[arg(
58 short = 'g',
59 long = "global",
60 alias = "globally",
61 default_value_t = false
62 )]
63 pub global: bool,
64
65 #[arg(long, default_value_t = false)]
67 pub undo: bool,
68
69 #[arg(long, default_value_t = false)]
71 pub all: bool,
72
73 #[arg(long, default_value = DEFAULT_BASE_URL)]
75 pub base_url: String,
76
77 #[arg(long)]
79 pub port: Option<u16>,
80
81 #[arg(long, default_value_t = false)]
83 pub start_server: bool,
84
85 #[arg(long, default_value_t = false, conflicts_with = "start_server")]
87 pub no_start_server: bool,
88
89 #[arg(long, alias = "keep-summarization", default_value_t = false)]
91 pub summarize: bool,
92
93 #[arg(long, default_value_t = false, conflicts_with = "non_interactive")]
95 pub interactive: bool,
96
97 #[arg(long, alias = "print", alias = "one-shot", default_value_t = false)]
99 pub non_interactive: bool,
100
101 #[arg(long, value_enum)]
103 pub protocol: Option<ClientProtocol>,
104
105 #[arg(long, default_value = DEFAULT_MODEL)]
107 pub model: String,
108
109 #[arg(value_name = "TOOL")]
111 pub tool: Option<String>,
112
113 #[arg(
115 value_name = "ARGS",
116 allow_hyphen_values = true,
117 trailing_var_arg = true
118 )]
119 pub tool_args: Vec<String>,
120}
121
122#[derive(Debug, Clone)]
123struct RenderContext {
124 protocol: String,
125 base_url: String,
126 endpoint_base_url: String,
127 openai_endpoint_base_url: String,
128 anthropic_endpoint_base_url: String,
129 provider_id: String,
130 model: String,
131 model_selector: String,
132 api_key_env: String,
133 api_key: String,
134 protocol_base_env: String,
135 google_auth_type: String,
136 model_catalog_path: String,
137}
138
139struct ServerGuard {
140 child: Child,
141}
142
143impl Drop for ServerGuard {
144 fn drop(&mut self) {
145 let _ = self.child.kill();
146 let _ = self.child.wait();
147 }
148}
149
150pub fn run_with_formal_ai(args: &WithFormalAiArgs) -> Result<(), Box<dyn Error>> {
151 let integrations = seed_client_integrations();
152 if args.global || args.undo {
153 let selected = select_integrations(args, &integrations)?;
154 for integration in selected {
155 if args.undo {
156 undo_global_config(integration, args)?;
157 } else {
158 write_global_config(integration, args)?;
159 }
160 }
161 return Ok(());
162 }
163
164 if args.all {
165 return Err("--all is only valid with --global or --undo".into());
166 }
167 let tool = args
168 .tool
169 .as_deref()
170 .ok_or("missing tool; pass one of the supported tool names")?;
171 let integration = find_integration(tool, &integrations)?;
172 let context = render_context(integration, args)?;
173 let _server = if args.start_server || !args.no_start_server {
174 let server = maybe_start_server(&context.base_url, args.port)?;
175 if server.is_some() {
176 eprintln!("formal-ai: started a temporary server in agent mode (tool and shell execution enabled)");
177 }
178 server
179 } else {
180 None
181 };
182 run_ephemeral(
183 integration,
184 &args.tool_args,
185 &context,
186 args.summarize,
187 args.interactive,
188 args.non_interactive,
189 )
190}
191
192fn select_integrations<'a>(
193 args: &WithFormalAiArgs,
194 integrations: &'a [ClientIntegration],
195) -> Result<Vec<&'a ClientIntegration>, Box<dyn Error>> {
196 if args.all {
197 return Ok(integrations.iter().collect());
198 }
199 let tool = args
200 .tool
201 .as_deref()
202 .ok_or("missing tool; pass a tool name or --all")?;
203 Ok(vec![find_integration(tool, integrations)?])
204}
205
206fn find_integration<'a>(
207 tool: &str,
208 integrations: &'a [ClientIntegration],
209) -> Result<&'a ClientIntegration, Box<dyn Error>> {
210 integrations
211 .iter()
212 .find(|integration| {
213 integration.id == tool || integration.aliases.iter().any(|alias| alias == tool)
214 })
215 .ok_or_else(|| {
216 let supported = integrations
217 .iter()
218 .flat_map(|integration| {
219 std::iter::once(integration.id.as_str())
220 .chain(integration.aliases.iter().map(String::as_str))
221 })
222 .collect::<Vec<_>>()
223 .join(", ");
224 format!("unsupported tool `{tool}`; supported tools: {supported}").into()
225 })
226}
227
228fn render_context(
229 integration: &ClientIntegration,
230 args: &WithFormalAiArgs,
231) -> Result<RenderContext, Box<dyn Error>> {
232 let protocol = args
233 .protocol
234 .map_or(integration.default_protocol.as_str(), |protocol| {
235 protocol.as_str()
236 });
237 if !integration
238 .supported_protocols
239 .iter()
240 .any(|supported| supported == protocol)
241 {
242 return Err(format!("{} does not support protocol `{protocol}`", integration.id).into());
243 }
244 let endpoint_path = integration
245 .endpoint_path_for(protocol)
246 .ok_or_else(|| format!("{} has no endpoint for {protocol}", integration.id))?;
247 let base_url = base_url_with_port(&args.base_url, args.port);
248 let endpoint_base_url = join_url_path(&base_url, endpoint_path);
249 let openai_endpoint_base_url = integration
250 .endpoint_path_for("openai")
251 .map_or_else(String::new, |path| join_url_path(&base_url, path));
252 let anthropic_endpoint_base_url = integration
253 .endpoint_path_for("anthropic")
254 .map_or_else(String::new, |path| join_url_path(&base_url, path));
255 let api_key = std::env::var(&integration.api_key_env)
256 .ok()
257 .filter(|value| !value.is_empty())
258 .or_else(|| std::env::var("FORMAL_AI_API_KEY").ok())
259 .filter(|value| !value.is_empty())
260 .unwrap_or_else(|| integration.api_key_default.clone());
261 let protocol_base_env = match protocol {
262 "vertex" => "GOOGLE_VERTEX_BASE_URL",
263 "gemini" => "GOOGLE_GEMINI_BASE_URL",
264 "openai" => "OPENAI_BASE_URL",
265 "anthropic" => "ANTHROPIC_BASE_URL",
266 _ => "FORMAL_AI_BASE_URL",
267 }
268 .to_string();
269 let google_auth_type = match protocol {
270 "vertex" => "vertex-ai",
271 "gemini" => "gemini-api-key",
272 _ => "",
273 }
274 .to_string();
275
276 let mut context = RenderContext {
277 protocol: protocol.to_string(),
278 base_url,
279 endpoint_base_url,
280 openai_endpoint_base_url,
281 anthropic_endpoint_base_url,
282 provider_id: integration.provider_id.clone(),
283 model: args.model.clone(),
284 model_selector: String::new(),
285 api_key_env: integration.api_key_env.clone(),
286 api_key,
287 protocol_base_env,
288 google_auth_type,
289 model_catalog_path: String::new(),
290 };
291 context.model_selector = if integration.model_selector.is_empty() {
292 context.model.clone()
293 } else {
294 render_template(&integration.model_selector, &context)
295 };
296 Ok(context)
297}
298
299fn run_ephemeral(
300 integration: &ClientIntegration,
301 user_args: &[String],
302 context: &RenderContext,
303 keep_summarization: bool,
304 force_interactive: bool,
305 force_non_interactive: bool,
306) -> Result<(), Box<dyn Error>> {
307 let invocation = &integration.invocation;
308 let mut context = context.clone();
309 let mut temp_dirs = Vec::new();
310 let mut session_home = None;
311 let resolved_command = resolve_integration_command(integration);
312 let mut command = Command::new(&resolved_command);
313 for env in &invocation.env {
314 command.env(
315 render_template(&env.key, &context),
316 render_template(&env.value, &context),
317 );
318 }
319 if !invocation.config_json_settings.is_empty() {
320 let config_json = render_json_settings(&invocation.config_json_settings, &context)?;
321 if !invocation.config_content_env.is_empty() {
322 command.env(
323 render_template(&invocation.config_content_env, &context),
324 &config_json,
325 );
326 }
327 if !invocation.config_env.is_empty() || !invocation.config_dir_env.is_empty() {
328 let temp = TempConfigDir::new(&integration.id)?;
329 let config_path = temp.path.join(format!("{}.json", integration.id));
330 fs::write(&config_path, config_json)?;
331 if !invocation.config_env.is_empty() {
332 command.env(&invocation.config_env, &config_path);
333 }
334 if !invocation.config_dir_env.is_empty() {
335 command.env(&invocation.config_dir_env, &temp.path);
336 }
337 temp_dirs.push(temp);
338 }
339 }
340 if !invocation.temp_home_env.is_empty() {
341 let temp = TempConfigDir::new(&format!("{}-home", integration.id))?;
342 session_home = Some(temp.path.clone());
343 if !invocation.model_catalog_path.is_empty() {
344 let relative_catalog_path = render_template(&invocation.model_catalog_path, &context);
345 let catalog_path = temp_scoped_path(&temp.path, &relative_catalog_path)?;
346 context.model_catalog_path = catalog_path.display().to_string();
347 write_file(&catalog_path, &codex_model_catalog(&context.model)?)?;
348 }
349 if !invocation.temp_home_config_path.is_empty() {
350 let relative_config_path = render_template(&invocation.temp_home_config_path, &context);
351 let config_path = temp_scoped_path(&temp.path, &relative_config_path)?;
352 if let Some(parent) = config_path.parent() {
353 fs::create_dir_all(parent)?;
354 }
355 let contents = if invocation.temp_home_toml_settings.is_empty() {
356 render_json_settings(&invocation.temp_home_json_settings, &context)?
357 } else {
358 render_toml_settings(&invocation.temp_home_toml_settings, "", &context)?
359 };
360 fs::write(&config_path, contents)?;
361 }
362 command.env(
363 render_template(&invocation.temp_home_env, &context),
364 &temp.path,
365 );
366 temp_dirs.push(temp);
367 }
368
369 let session_root = if invocation.session_root.is_empty() {
370 None
371 } else {
372 let base = session_home.map_or_else(user_home_dir, Ok)?;
373 Some(base.join(&invocation.session_root))
374 };
375 let session_before = session_root
376 .as_deref()
377 .map(|root| session_file_snapshot(root, &invocation.session_file_suffix))
378 .unwrap_or_default();
379
380 let final_args = build_invocation_args(
381 integration,
382 user_args,
383 &context,
384 keep_summarization,
385 force_interactive,
386 force_non_interactive,
387 );
388 command.args(final_args);
389 let status = command.status()?;
390 let session_file = session_root.as_deref().and_then(|root| {
391 newest_changed_session_file(root, &invocation.session_file_suffix, &session_before)
392 });
393 let server_log = std::env::var_os("FORMAL_AI_PROXY_LOG")
394 .map(PathBuf::from)
395 .filter(|path| path.exists())
396 .map(|path| fs::canonicalize(&path).unwrap_or(path));
397 print_session_files(integration, session_file.as_deref(), server_log.as_deref());
398 let preserve_temp = session_file
399 .as_deref()
400 .is_some_and(|path| temp_dirs.iter().any(|temp| path.starts_with(&temp.path)));
401 if preserve_temp {
402 for temp in temp_dirs {
403 temp.preserve();
404 }
405 } else {
406 drop(temp_dirs);
407 }
408 if status.success() {
409 return Ok(());
410 }
411 Err(format!(
412 "{} exited with status {}",
413 resolved_command.display(),
414 status
415 .code()
416 .map_or_else(|| String::from("signal"), |code| code.to_string())
417 )
418 .into())
419}
420
421fn temp_scoped_path(root: &Path, relative: &str) -> Result<PathBuf, Box<dyn Error>> {
422 let path = Path::new(relative);
423 if path.as_os_str().is_empty() || path.is_absolute() {
424 return Err(format!("temporary config path must be relative: {relative}").into());
425 }
426 for component in path.components() {
427 match component {
428 Component::Normal(_) | Component::CurDir => {}
429 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
430 return Err(format!("temporary config path escapes its root: {relative}").into());
431 }
432 }
433 }
434 Ok(root.join(path))
435}
436
437fn build_invocation_args(
438 integration: &ClientIntegration,
439 user_args: &[String],
440 context: &RenderContext,
441 keep_summarization: bool,
442 force_interactive: bool,
443 force_non_interactive: bool,
444) -> Vec<String> {
445 let invocation = &integration.invocation;
446 let mut args = invocation
447 .prepend_args
448 .iter()
449 .chain(invocation.args.iter())
450 .map(|arg| render_template(arg, context))
451 .collect::<Vec<_>>();
452 let interactive = force_interactive || (!force_non_interactive && user_args.is_empty());
453 let mode_args: &[String] =
454 if interactive && invocation.interactive_args_require_prompt && user_args.is_empty() {
455 &[]
456 } else if interactive {
457 &invocation.interactive_args
458 } else {
459 &invocation.non_interactive_args
460 };
461 let rendered_mode_args = if mode_args
462 .iter()
463 .any(|mode_arg| user_args.contains(mode_arg))
464 {
465 Vec::new()
466 } else {
467 mode_args
468 .iter()
469 .map(|arg| render_template(arg, context))
470 .collect::<Vec<_>>()
471 };
472 if invocation.mode_arg_position == Some(ModeArgPosition::BeforeInvocation) {
473 args.splice(0..0, rendered_mode_args.iter().cloned());
474 }
475 if !keep_summarization {
476 args.extend(
477 invocation
478 .no_summarize_args
479 .iter()
480 .map(|arg| render_template(arg, context)),
481 );
482 }
483 let mut effective_user_args = Vec::new();
484 if invocation.mode_arg_position != Some(ModeArgPosition::BeforeInvocation) {
485 effective_user_args.extend(rendered_mode_args);
486 }
487 effective_user_args.extend(user_args.iter().cloned());
488 if invocation.model_arg.is_empty() || contains_model_arg(user_args) {
489 args.extend(effective_user_args);
490 return args;
491 }
492
493 let model_arg = render_template(&invocation.model_arg, context);
494 let model_value = context.model_selector.clone();
495 match invocation.model_arg_position {
496 Some(ModelArgPosition::AfterFirstArg)
497 if invocation.mode_arg_position == Some(ModeArgPosition::BeforeInvocation)
498 && !args.is_empty() =>
499 {
500 args.insert(1, model_value);
501 args.insert(1, model_arg);
502 args.extend(effective_user_args);
503 }
504 Some(ModelArgPosition::AfterFirstArg) if !effective_user_args.is_empty() => {
505 args.push(effective_user_args[0].clone());
506 args.push(model_arg);
507 args.push(model_value);
508 args.extend(effective_user_args.iter().skip(1).cloned());
509 }
510 _ => {
511 args.push(model_arg);
512 args.push(model_value);
513 args.extend(effective_user_args);
514 }
515 }
516 args
517}
518
519fn contains_model_arg(args: &[String]) -> bool {
520 args.iter()
521 .any(|arg| matches!(arg.as_str(), "-m" | "--model") || arg.starts_with("--model="))
522}
523
524fn write_global_config(
525 integration: &ClientIntegration,
526 args: &WithFormalAiArgs,
527) -> Result<(), Box<dyn Error>> {
528 let mut context = render_context(integration, args)?;
529 let global_config = integration.global_config_for(&context.protocol);
530 if !global_config.model_catalog_path.is_empty() {
531 let catalog_path = global_config_path(&global_config.model_catalog_path)?;
532 let catalog_backup = backup_path(&catalog_path, &global_config.backup_suffix);
533 ensure_backup(&catalog_path, &catalog_backup)?;
534 context.model_catalog_path = catalog_path.display().to_string();
535 write_file(&catalog_path, &codex_model_catalog(&context.model)?)?;
536 }
537 let path = global_config_path(&global_config.path)?;
538 let backup_path = backup_path(&path, &global_config.backup_suffix);
539 ensure_backup(&path, &backup_path)?;
540 let existing = fs::read_to_string(&path).unwrap_or_default();
541 let next = match global_config.format {
542 ConfigFormat::Toml => {
543 render_toml_settings(&global_config.toml_settings, &existing, &context)?
544 }
545 ConfigFormat::Json => merge_json_config(global_config, &existing, &context)?,
546 ConfigFormat::ShellEnv => {
547 merge_shell_env_config(&integration.id, global_config, &existing, &context)
548 }
549 };
550 if next == existing {
551 println!(
552 "{} already configured at {}",
553 integration.id,
554 path.display()
555 );
556 } else {
557 write_file(&path, &next)?;
558 println!("configured {} at {}", integration.id, path.display());
559 }
560 Ok(())
561}
562
563fn undo_global_config(
564 integration: &ClientIntegration,
565 args: &WithFormalAiArgs,
566) -> Result<(), Box<dyn Error>> {
567 let context = render_context(integration, args)?;
568 let global_config = integration.global_config_for(&context.protocol);
569 let path = global_config_path(&global_config.path)?;
570 let config_backup_path = backup_path(&path, &global_config.backup_suffix);
571 let mut restored = if config_backup_path.exists() {
572 restore_backup(&path, &config_backup_path)?;
573 true
574 } else {
575 false
576 };
577 if !global_config.model_catalog_path.is_empty() {
578 let catalog_path = global_config_path(&global_config.model_catalog_path)?;
579 let catalog_backup_path = backup_path(&catalog_path, &global_config.backup_suffix);
580 if catalog_backup_path.exists() {
581 restore_backup(&catalog_path, &catalog_backup_path)?;
582 restored = true;
583 }
584 }
585 if !restored {
586 println!(
587 "no formal-ai backup for {} at {}",
588 integration.id,
589 path.display()
590 );
591 return Ok(());
592 }
593 println!(
594 "restored {} from {}",
595 integration.id,
596 config_backup_path.display()
597 );
598 Ok(())
599}
600
601fn restore_backup(path: &Path, backup_path: &Path) -> Result<(), Box<dyn Error>> {
602 if !backup_path.exists() {
603 return Ok(());
604 }
605 let backup = fs::read_to_string(backup_path)?;
606 if backup == EMPTY_BACKUP_SENTINEL {
607 match fs::remove_file(path) {
608 Ok(()) => {}
609 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
610 Err(error) => return Err(error.into()),
611 }
612 } else {
613 write_file(path, &backup)?;
614 }
615 fs::remove_file(backup_path)?;
616 Ok(())
617}
618
619fn ensure_backup(path: &Path, backup_path: &Path) -> Result<(), Box<dyn Error>> {
620 if backup_path.exists() {
621 return Ok(());
622 }
623 if let Some(parent) = backup_path.parent() {
624 fs::create_dir_all(parent)?;
625 }
626 if path.exists() {
627 fs::copy(path, backup_path)?;
628 } else {
629 fs::write(backup_path, EMPTY_BACKUP_SENTINEL)?;
630 }
631 Ok(())
632}
633
634fn render_toml_settings(
635 settings: &[(String, String)],
636 existing: &str,
637 context: &RenderContext,
638) -> Result<String, Box<dyn Error>> {
639 let mut document = if existing.trim().is_empty() {
640 DocumentMut::new()
641 } else {
642 existing.parse::<DocumentMut>()?
643 };
644 for (path, value) in settings {
645 set_toml_string(
646 document.as_table_mut(),
647 &render_template(path, context),
648 &render_template(value, context),
649 )?;
650 }
651 Ok(ensure_trailing_newline(document.to_string()))
652}
653
654fn set_toml_string(
655 table: &mut Table,
656 dotted_path: &str,
657 value: &str,
658) -> Result<(), Box<dyn Error>> {
659 let parts = dotted_path
660 .split('.')
661 .map(str::trim)
662 .filter(|part| !part.is_empty())
663 .collect::<Vec<_>>();
664 let Some((last, parents)) = parts.split_last() else {
665 return Err("empty TOML setting path".into());
666 };
667 let parent = table_at_path_mut(table, parents);
668 parent[*last] = toml_value(value);
669 Ok(())
670}
671
672fn table_at_path_mut<'a>(mut table: &'a mut Table, parts: &[&str]) -> &'a mut Table {
673 for part in parts {
674 let item = table
675 .entry(part)
676 .or_insert_with(|| Item::Table(Table::new()));
677 if !item.is_table() {
678 *item = Item::Table(Table::new());
679 }
680 table = item.as_table_mut().expect("table item");
681 }
682 table
683}
684
685fn merge_json_config(
686 global_config: &crate::seed::ClientIntegrationGlobalConfig,
687 existing: &str,
688 context: &RenderContext,
689) -> Result<String, Box<dyn Error>> {
690 let mut base = if existing.trim().is_empty() {
691 Value::Object(serde_json::Map::new())
692 } else {
693 serde_json::from_str(existing)?
694 };
695 let overlay = json_settings_value(&global_config.json_settings, context)?;
696 merge_json_value(&mut base, overlay);
697 Ok(format!("{}\n", serde_json::to_string_pretty(&base)?))
698}
699
700fn render_json_settings(
701 settings: &[(String, String)],
702 context: &RenderContext,
703) -> Result<String, Box<dyn Error>> {
704 Ok(format!(
705 "{}\n",
706 serde_json::to_string_pretty(&json_settings_value(settings, context)?)?
707 ))
708}
709
710fn json_settings_value(
711 settings: &[(String, String)],
712 context: &RenderContext,
713) -> Result<Value, Box<dyn Error>> {
714 let mut value = Value::Object(serde_json::Map::new());
715 for (path, setting_value) in settings {
716 set_json_string(&mut value, path, setting_value, context)?;
717 }
718 Ok(value)
719}
720
721fn set_json_string(
722 root: &mut Value,
723 dotted_path: &str,
724 value: &str,
725 context: &RenderContext,
726) -> Result<(), Box<dyn Error>> {
727 let parts = dotted_path
728 .split('.')
729 .map(str::trim)
730 .filter(|part| !part.is_empty())
731 .map(|part| render_template(part, context))
732 .collect::<Vec<_>>();
733 let Some((last, parents)) = parts.split_last() else {
734 return Err("empty JSON setting path".into());
735 };
736
737 let mut current = root;
738 for part in parents {
739 let object = current
740 .as_object_mut()
741 .ok_or("JSON setting path conflicts with a scalar value")?;
742 current = object
743 .entry(part.clone())
744 .or_insert_with(|| Value::Object(serde_json::Map::new()));
745 }
746 let object = current
747 .as_object_mut()
748 .ok_or("JSON setting path conflicts with a scalar value")?;
749 object.insert(last.clone(), Value::String(render_template(value, context)));
750 Ok(())
751}
752
753fn merge_json_value(base: &mut Value, overlay: Value) {
754 match (base, overlay) {
755 (Value::Object(base_map), Value::Object(overlay_map)) => {
756 for (key, overlay_value) in overlay_map {
757 match base_map.get_mut(&key) {
758 Some(base_value) => merge_json_value(base_value, overlay_value),
759 None => {
760 base_map.insert(key, overlay_value);
761 }
762 }
763 }
764 }
765 (base_value, overlay_value) => *base_value = overlay_value,
766 }
767}
768
769fn merge_shell_env_config(
770 integration_id: &str,
771 global_config: &crate::seed::ClientIntegrationGlobalConfig,
772 existing: &str,
773 context: &RenderContext,
774) -> String {
775 let mut next = remove_managed_block(existing, integration_id);
776 if !next.is_empty() && !next.ends_with('\n') {
777 next.push('\n');
778 }
779 let _ = writeln!(next, "# >>> formal-ai {integration_id}");
780 for env in &global_config.shell_env {
781 next.push_str("export ");
782 next.push_str(&render_template(&env.key, context));
783 next.push('=');
784 next.push_str(&shell_double_quote(&render_template(&env.value, context)));
785 next.push('\n');
786 }
787 let _ = writeln!(next, "# <<< formal-ai {integration_id}");
788 next
789}
790
791fn remove_managed_block(existing: &str, tool: &str) -> String {
792 let start = format!("# >>> formal-ai {tool}");
793 let end = format!("# <<< formal-ai {tool}");
794 let mut out = String::new();
795 let mut skipping = false;
796 for line in existing.lines() {
797 if line == start {
798 skipping = true;
799 continue;
800 }
801 if skipping {
802 if line == end {
803 skipping = false;
804 }
805 continue;
806 }
807 out.push_str(line);
808 out.push('\n');
809 }
810 out
811}
812
813fn shell_double_quote(value: &str) -> String {
814 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
815 format!("\"{escaped}\"")
816}
817
818fn render_template(template: &str, context: &RenderContext) -> String {
819 template
820 .replace("{provider_id}", &context.provider_id)
821 .replace("{model}", &context.model)
822 .replace("{model_selector}", &context.model_selector)
823 .replace("{endpoint_base_url}", &context.endpoint_base_url)
824 .replace(
825 "{openai_endpoint_base_url}",
826 &context.openai_endpoint_base_url,
827 )
828 .replace(
829 "{anthropic_endpoint_base_url}",
830 &context.anthropic_endpoint_base_url,
831 )
832 .replace("{base_url}", &context.base_url)
833 .replace("{api_key_env}", &context.api_key_env)
834 .replace("{api_key}", &context.api_key)
835 .replace("{protocol_base_env}", &context.protocol_base_env)
836 .replace("{google_auth_type}", &context.google_auth_type)
837 .replace("{model_catalog_path}", &context.model_catalog_path)
838}
839
840fn codex_model_catalog(model: &str) -> Result<String, Box<dyn Error>> {
841 let context = ContextCapacity::current()?;
842 let catalog = serde_json::json!({
843 "models": [{
844 "slug": model,
845 "display_name": model,
846 "description": "Formal AI symbolic model",
847 "default_reasoning_level": "none",
848 "supported_reasoning_levels": [],
849 "shell_type": "shell_command",
850 "visibility": "list",
851 "supported_in_api": true,
852 "priority": 0,
853 "availability_nux": null,
854 "upgrade": null,
855 "base_instructions": "",
856 "supports_reasoning_summaries": false,
857 "supports_reasoning_summary_parameter": false,
858 "default_reasoning_summary": "none",
859 "support_verbosity": false,
860 "default_verbosity": null,
861 "apply_patch_tool_type": "freeform",
862 "web_search_tool_type": "text",
863 "truncation_policy": {"mode": "tokens", "limit": 8192},
864 "supports_parallel_tool_calls": true,
865 "context_window": context.context_window_tokens,
866 "max_context_window": context.context_window_tokens,
867 "context": context,
868 "effective_context_window_percent": 100,
869 "experimental_supported_tools": [],
870 "input_modalities": ["text"]
871 }]
872 });
873 Ok(format!("{}\n", serde_json::to_string_pretty(&catalog)?))
874}
875
876fn global_config_path(relative: &str) -> Result<PathBuf, Box<dyn Error>> {
877 let path = Path::new(relative);
878 if path.is_absolute() {
879 return Ok(path.to_path_buf());
880 }
881 let home = std::env::var_os("HOME")
882 .or_else(|| std::env::var_os("USERPROFILE"))
883 .ok_or("HOME is not set; cannot resolve global config path")?;
884 Ok(PathBuf::from(home).join(path))
885}
886
887fn backup_path(path: &Path, suffix: &str) -> PathBuf {
888 let mut backup = path.as_os_str().to_os_string();
889 backup.push(suffix);
890 PathBuf::from(backup)
891}
892
893fn write_file(path: &Path, contents: &str) -> Result<(), Box<dyn Error>> {
894 if let Some(parent) = path.parent() {
895 fs::create_dir_all(parent)?;
896 }
897 fs::write(path, contents)?;
898 Ok(())
899}
900
901fn ensure_trailing_newline(mut value: String) -> String {
902 if !value.ends_with('\n') {
903 value.push('\n');
904 }
905 value
906}
907
908fn maybe_start_server(
909 base_url: &str,
910 port_override: Option<u16>,
911) -> Result<Option<ServerGuard>, Box<dyn Error>> {
912 let (host, port) = parse_host_port(base_url, port_override)?;
913 let address = format!("{host}:{port}");
914 if TcpStream::connect(&address).is_ok() {
915 return Ok(None);
916 }
917 let binary = formal_ai_binary_path()?;
918 let mut child = Command::new(binary)
919 .args([
920 "serve",
921 "--agent-mode",
922 "--host",
923 &host,
924 "--port",
925 &port.to_string(),
926 ])
927 .spawn()?;
928 wait_for_server(&address, &mut child)?;
929 Ok(Some(ServerGuard { child }))
930}
931
932fn parse_host_port(
933 base_url: &str,
934 port_override: Option<u16>,
935) -> Result<(String, u16), Box<dyn Error>> {
936 let (_, rest) = base_url
937 .split_once("://")
938 .ok_or("base URL must include a scheme, for example http://127.0.0.1:8080")?;
939 let authority = rest.split('/').next().unwrap_or(rest);
940 let (host, parsed_port) = if let Some(stripped) = authority.strip_prefix('[') {
941 let (inside, after) = stripped
942 .split_once(']')
943 .ok_or("invalid bracketed IPv6 host in base URL")?;
944 let port = after.strip_prefix(':').and_then(|value| value.parse().ok());
945 (inside.to_string(), port)
946 } else if let Some((host, port)) = authority.split_once(':') {
947 (host.to_string(), port.parse().ok())
948 } else {
949 (authority.to_string(), None)
950 };
951 let port = port_override.or(parsed_port).unwrap_or(8080);
952 Ok((host, port))
953}
954
955fn formal_ai_binary_path() -> Result<PathBuf, Box<dyn Error>> {
956 let current = std::env::current_exe()?;
957 let stem = current.file_stem().and_then(|value| value.to_str());
958 if stem == Some("formal-ai") {
959 return Ok(current);
960 }
961 let sibling = current.with_file_name(format!("formal-ai{}", std::env::consts::EXE_SUFFIX));
962 if sibling.exists() {
963 return Ok(sibling);
964 }
965 Ok(PathBuf::from("formal-ai"))
966}
967
968fn wait_for_server(address: &str, child: &mut Child) -> Result<(), Box<dyn Error>> {
969 let deadline = Instant::now() + Duration::from_secs(5);
970 while Instant::now() < deadline {
971 if let Some(status) = child.try_wait()? {
972 return Err(format!("formal-ai serve exited before listening: {status}").into());
973 }
974 if TcpStream::connect(address).is_ok() {
975 return Ok(());
976 }
977 std::thread::sleep(Duration::from_millis(50));
978 }
979 Err(format!("formal-ai serve did not listen on {address}").into())
980}