Skip to main content

formal_ai/
client_integrations.rs

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    /// Permanently configure the selected tool instead of running it once.
57    #[arg(
58        short = 'g',
59        long = "global",
60        alias = "globally",
61        default_value_t = false
62    )]
63    pub global: bool,
64
65    /// Restore the backup created by a previous global configuration.
66    #[arg(long, default_value_t = false)]
67    pub undo: bool,
68
69    /// Configure or undo every supported tool from the seed registry.
70    #[arg(long, default_value_t = false)]
71    pub all: bool,
72
73    /// Formal AI server root URL. Protocol-specific paths are added from seed data.
74    #[arg(long, default_value = DEFAULT_BASE_URL)]
75    pub base_url: String,
76
77    /// Override the port in --base-url.
78    #[arg(long)]
79    pub port: Option<u16>,
80
81    /// Explicitly start `formal-ai serve` when the target loopback port is not listening.
82    #[arg(long, default_value_t = false)]
83    pub start_server: bool,
84
85    /// Do not auto-start a temporary server when the target is not listening.
86    #[arg(long, default_value_t = false, conflicts_with = "start_server")]
87    pub no_start_server: bool,
88
89    /// Keep the wrapped tool's normal summarization/compaction behavior.
90    #[arg(long, alias = "keep-summarization", default_value_t = false)]
91    pub summarize: bool,
92
93    /// Force the wrapped CLI to stay in its interactive mode.
94    #[arg(long, default_value_t = false, conflicts_with = "non_interactive")]
95    pub interactive: bool,
96
97    /// Force one-shot/headless output (aliases: --print and --one-shot).
98    #[arg(long, alias = "print", alias = "one-shot", default_value_t = false)]
99    pub non_interactive: bool,
100
101    /// Protocol namespace to use for tools that support more than one protocol.
102    #[arg(long, value_enum)]
103    pub protocol: Option<ClientProtocol>,
104
105    /// Model alias to configure for the target tool.
106    #[arg(long, default_value = DEFAULT_MODEL)]
107    pub model: String,
108
109    /// External client target from the seed registry (for example codex or opencode-vscode).
110    #[arg(value_name = "TOOL")]
111    pub tool: Option<String>,
112
113    /// Arguments passed through to the external CLI.
114    #[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}