//! Migration helpers for importing external-agent configuration into Codex. mod hooks_cla; pub mod sessions; use serde_json::Value as JsonValue; use serde_yaml::Value as YamlValue; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fs; use std::io; use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; pub use hooks_cla::hook_migration_event_names_cla; pub use hooks_cla::hooks_migration_description_cla; pub use hooks_cla::import_hooks_cla; const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude"; const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json"; const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks"; const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks"; /// Describes source-specific terms that should be rewritten in migrated artifacts. #[derive(Clone, Copy)] pub struct RewriteProfile { doc_file_name: &'static str, term_variants: &'static [&'static str], case_sensitive_term_variants: &'static [&'static str], } impl RewriteProfile { pub const fn new(doc_file_name: &'static str, term_variants: &'static [&'static str]) -> Self { Self { doc_file_name, term_variants, case_sensitive_term_variants: &[], } } pub const fn with_case_sensitive_term_variants( mut self, term_variants: &'static [&'static str], ) -> Self { self.case_sensitive_term_variants = term_variants; self } pub const fn doc_file_name(self) -> &'static str { self.doc_file_name } pub const fn term_variants(self) -> &'static [&'static str] { self.term_variants } pub const fn case_sensitive_term_variants(self) -> &'static [&'static str] { self.case_sensitive_term_variants } } #[derive(Debug)] struct ParsedDocument { frontmatter: BTreeMap, body: String, frontmatter_error: Option, } #[derive(Debug)] enum FrontmatterValue { Scalar(String), Other, } #[derive(Debug)] struct AgentMetadata { name: String, description: String, permission_mode: Option, effort: Option, } pub fn build_mcp_config_from_external( source_root: &Path, external_agent_home: Option<&Path>, settings: Option<&JsonValue>, ) -> io::Result { let mcp_servers = read_external_mcp_servers(source_root, external_agent_home)?; build_mcp_config(mcp_servers, settings) } pub fn build_mcp_config_from_json_file(source_file: &Path) -> io::Result { if !source_file.is_file() { return Ok(TomlValue::Table(Default::default())); } let raw = fs::read_to_string(source_file)?; let parsed: JsonValue = serde_json::from_str(&raw) .map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?; let mut mcp_servers = BTreeMap::new(); append_mcp_servers_from_value(&parsed, &mut mcp_servers, McpServerMerge::Overwrite); build_mcp_config(mcp_servers, /*settings*/ None) } fn build_mcp_config( mcp_servers: BTreeMap, settings: Option<&JsonValue>, ) -> io::Result { if mcp_servers.is_empty() { return Ok(TomlValue::Table(Default::default())); } let enabled_servers = settings .and_then(|settings| settings.get("enabledMcpjsonServers")) .map(json_string_vec) .unwrap_or_default(); let disabled_servers = settings .and_then(|settings| settings.get("disabledMcpjsonServers")) .map(json_string_vec) .unwrap_or_default() .into_iter() .collect::>(); let mut servers = toml::map::Map::new(); for (server_name, server_config) in mcp_servers { if let Some(server) = mcp_server_toml_table( &server_name, server_config.as_object(), &enabled_servers, &disabled_servers, ) { servers.insert(server_name.clone(), TomlValue::Table(server)); } } if servers.is_empty() { return Ok(TomlValue::Table(Default::default())); } let mut root = toml::map::Map::new(); root.insert("mcp_servers".to_string(), TomlValue::Table(servers)); Ok(TomlValue::Table(root)) } pub fn count_missing_subagents(source_agents: &Path, target_agents: &Path) -> io::Result { Ok(missing_subagent_names(source_agents, target_agents)?.len()) } pub fn missing_subagent_names( source_agents: &Path, target_agents: &Path, ) -> io::Result> { let mut names = Vec::new(); for source_file in agent_source_files(source_agents)? { let document = parse_document(&source_file)?; let Some(metadata) = agent_metadata(&document) else { continue; }; let Some(target) = subagent_target_file(&source_file, target_agents) else { continue; }; if !target.exists() { names.push(metadata.name); } } Ok(names) } pub fn import_subagents_with_rewrite_profile( source_agents: &Path, target_agents: &Path, rewrite_profile: RewriteProfile, ) -> io::Result> { if !source_agents.is_dir() { return Ok(Vec::new()); } fs::create_dir_all(target_agents)?; let mut imported = Vec::new(); for source_file in agent_source_files(source_agents)? { let Some(target) = subagent_target_file(&source_file, target_agents) else { continue; }; if target.exists() { continue; } let document = parse_document(&source_file)?; let Some(metadata) = agent_metadata(&document) else { continue; }; fs::write( &target, render_agent_toml(&document.body, &metadata, rewrite_profile)?, )?; imported.push(metadata.name); } Ok(imported) } fn read_external_mcp_servers( source_root: &Path, external_agent_home: Option<&Path>, ) -> io::Result> { let mut servers = BTreeMap::new(); let project_config_file = external_agent_project_config_file(); for relative_path in [ EXTERNAL_AGENT_MCP_CONFIG_FILE.to_string(), project_config_file.clone(), ] { let source_file = source_root.join(&relative_path); if !source_file.is_file() { continue; } let raw = fs::read_to_string(&source_file)?; let parsed: JsonValue = serde_json::from_str(&raw) .map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?; append_mcp_servers_from_value(&parsed, &mut servers, McpServerMerge::Overwrite); if relative_path == project_config_file && let Some(projects) = parsed.get("projects").and_then(JsonValue::as_object) { for (project_path, project_config) in projects { if project_path_matches_source_root(project_path, source_root) { append_mcp_servers_from_value( project_config, &mut servers, McpServerMerge::Overwrite, ); } } } } if let Some(external_agent_root) = external_agent_home.and_then(Path::parent) && external_agent_root != source_root { append_external_agent_project_mcp_servers( &external_agent_root.join(external_agent_project_config_file()), source_root, &mut servers, )?; } Ok(servers) } fn append_external_agent_project_mcp_servers( source_file: &Path, source_root: &Path, servers: &mut BTreeMap, ) -> io::Result<()> { if !source_file.is_file() { return Ok(()); } let raw = fs::read_to_string(source_file)?; let parsed: JsonValue = serde_json::from_str(&raw) .map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?; let Some(projects) = parsed.get("projects").and_then(JsonValue::as_object) else { return Ok(()); }; for (project_path, project_config) in projects { if project_path_matches_source_root(project_path, source_root) { append_mcp_servers_from_value( project_config, servers, McpServerMerge::PreserveExisting, ); } } Ok(()) } #[derive(Clone, Copy)] enum McpServerMerge { Overwrite, PreserveExisting, } fn append_mcp_servers_from_value( value: &JsonValue, servers: &mut BTreeMap, merge: McpServerMerge, ) { let Some(mcp_servers) = value.get("mcpServers").and_then(JsonValue::as_object) else { return; }; for (server_name, server_config) in mcp_servers { match merge { McpServerMerge::Overwrite => { servers.insert(server_name.clone(), server_config.clone()); } McpServerMerge::PreserveExisting => { servers .entry(server_name.clone()) .or_insert_with(|| server_config.clone()); } } } } fn project_path_matches_source_root(project_path: &str, source_root: &Path) -> bool { let project_path = Path::new(project_path); if project_path == source_root { return true; } let Ok(project_path) = project_path.canonicalize() else { return false; }; source_root .canonicalize() .is_ok_and(|source_root| source_root == project_path) } fn mcp_server_toml_table( server_name: &str, server_config: Option<&serde_json::Map>, enabled_servers: &[String], disabled_servers: &BTreeSet, ) -> Option> { let mut table = toml::map::Map::new(); let server_config = server_config?; let transport_type = server_config.get("type").and_then(JsonValue::as_str); if mcp_server_is_disabled( server_name, server_config, enabled_servers, disabled_servers, ) { return None; } if let Some(command) = server_config.get("command").and_then(json_string) { if !matches!(transport_type, None | Some("stdio")) { return None; } if contains_env_placeholder(&command) { return None; } table.insert("command".to_string(), TomlValue::String(command)); if let Some(args) = server_config.get("args") { let args = json_string_vec(args); if args.iter().any(|arg| contains_env_placeholder(arg)) { return None; } let args = args.into_iter().map(TomlValue::String).collect::>(); if !args.is_empty() { table.insert("args".to_string(), TomlValue::Array(args)); } } if let Some(env) = server_config.get("env").and_then(JsonValue::as_object) { append_env_config(&mut table, env)?; } } else if let Some(url) = server_config.get("url").and_then(json_string) { if !matches!( transport_type, None | Some("http") | Some("streamable_http") ) { return None; } if contains_env_placeholder(&url) { return None; } table.insert("url".to_string(), TomlValue::String(url)); if let Some(headers) = server_config.get("headers").and_then(JsonValue::as_object) { append_header_config(&mut table, headers)?; } } else { return None; } Some(table) } fn mcp_server_is_disabled( server_name: &str, server_config: &serde_json::Map, enabled_servers: &[String], disabled_servers: &BTreeSet, ) -> bool { server_config .get("enabled") .and_then(JsonValue::as_bool) .is_some_and(|enabled| !enabled) || server_config .get("disabled") .and_then(JsonValue::as_bool) .unwrap_or(false) || (!enabled_servers.is_empty() && !enabled_servers.iter().any(|name| name == server_name)) || disabled_servers.contains(server_name) } fn append_header_config( table: &mut toml::map::Map, headers: &serde_json::Map, ) -> Option<()> { let mut static_headers = toml::map::Map::new(); let mut env_headers = toml::map::Map::new(); for (key, value) in headers { let header_value = json_string(value).unwrap_or_else(|| value.to_string()); if key.eq_ignore_ascii_case("authorization") && let Some(token_env) = header_value .strip_prefix("Bearer ") .and_then(parse_env_placeholder) { table.insert( "bearer_token_env_var".to_string(), TomlValue::String(token_env), ); continue; } if let Some(env_var) = parse_env_placeholder(&header_value) { env_headers.insert(key.clone(), TomlValue::String(env_var)); } else if contains_env_placeholder(&header_value) { return None; } else { static_headers.insert(key.clone(), TomlValue::String(header_value)); } } if !static_headers.is_empty() { table.insert("http_headers".to_string(), TomlValue::Table(static_headers)); } if !env_headers.is_empty() { table.insert( "env_http_headers".to_string(), TomlValue::Table(env_headers), ); } Some(()) } fn append_env_config( table: &mut toml::map::Map, env: &serde_json::Map, ) -> Option<()> { let mut static_env = toml::map::Map::new(); let mut env_vars = Vec::new(); for (key, value) in env { let env_value = json_string(value).unwrap_or_else(|| value.to_string()); if parse_env_placeholder(&env_value).as_deref() == Some(key.as_str()) { env_vars.push(TomlValue::String(key.clone())); } else if contains_env_placeholder(&env_value) { return None; } else { static_env.insert(key.clone(), TomlValue::String(env_value)); } } if !env_vars.is_empty() { table.insert("env_vars".to_string(), TomlValue::Array(env_vars)); } if !static_env.is_empty() { table.insert("env".to_string(), TomlValue::Table(static_env)); } Some(()) } fn parse_env_placeholder(value: &str) -> Option { let inner = value.strip_prefix("${")?.strip_suffix('}')?; let name = inner .split_once(":-") .map_or(inner, |(name, _default)| name); let mut chars = name.chars(); let first = chars.next()?; if !(first == '_' || first.is_ascii_alphabetic()) { return None; } if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) { return None; } Some(name.to_string()) } fn contains_env_placeholder(value: &str) -> bool { value.contains("${") } fn write_hook_migration( source_external_agent_dir: &Path, target_hooks: &Path, migration: serde_json::Map, ) -> io::Result { if migration.is_empty() || !is_missing_or_empty_text_file(target_hooks)? { return Ok(false); } let Some(parent) = target_hooks.parent() else { return Err(invalid_data_error("hooks target path has no parent")); }; fs::create_dir_all(parent)?; copy_hook_scripts(source_external_agent_dir, parent)?; let mut payload = serde_json::Map::new(); payload.insert("hooks".to_string(), JsonValue::Object(migration)); let rendered = serde_json::to_string_pretty(&JsonValue::Object(payload)) .map_err(|err| invalid_data_error(format!("failed to serialize hooks.json: {err}")))?; fs::write(target_hooks, format!("{rendered}\n"))?; Ok(true) } fn rewrite_hook_command_for_source( command: &str, target_config_dir: Option<&Path>, source_external_agent_dir: &Path, ) -> String { let Some(target_config_dir) = target_config_dir else { return command.to_string(); }; if looks_like_windows_hook_command(command) { return command.to_string(); } let target_hooks_dir = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR); let source_config_dir = source_external_agent_dir .file_name() .and_then(|name| name.to_str()) .map(str::to_string) .unwrap_or_else(external_agent_config_dir); let source_hooks_path = format!("{source_config_dir}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/"); let command = replace_quoted_hook_paths(command, '\'', &source_hooks_path, &target_hooks_dir); let command = replace_quoted_hook_paths(&command, '"', &source_hooks_path, &target_hooks_dir); replace_unquoted_hook_paths(&command, &source_hooks_path, &target_hooks_dir) } fn replace_quoted_hook_paths( command: &str, quote: char, source_hooks_path: &str, target_hooks_dir: &Path, ) -> String { let mut rewritten = command.to_string(); let mut search_start = 0usize; while let Some(relative_start) = rewritten[search_start..].find(quote) { let start = search_start + relative_start; let content_start = start + quote.len_utf8(); let Some(relative_end) = rewritten[content_start..].find(quote) else { break; }; let end = content_start + relative_end; let content = &rewritten[content_start..end]; if let Some(source_hooks_start) = content.find(source_hooks_path) { let suffix_start = source_hooks_start + source_hooks_path.len(); let suffix = &content[suffix_start..]; let Some(replacement) = target_hook_path_replacement(target_hooks_dir, content, source_hooks_start, suffix) else { search_start = end + quote.len_utf8(); continue; }; rewritten.replace_range(start..end + quote.len_utf8(), &replacement); search_start = start + replacement.len(); } else { search_start = end + quote.len_utf8(); } } rewritten } fn replace_unquoted_hook_paths( command: &str, source_hooks_path: &str, target_hooks_dir: &Path, ) -> String { let mut rewritten = command.to_string(); let mut search_start = 0usize; while let Some(source_hooks_start) = find_unquoted_source_hook_path(&rewritten, source_hooks_path, search_start) { let path_start = shell_path_start(&rewritten, source_hooks_start); let path_end = shell_path_end(&rewritten, source_hooks_start + source_hooks_path.len()); if is_assignment_value_start(&rewritten, path_start) { search_start = source_hooks_start + source_hooks_path.len(); continue; } let path = rewritten[path_start..path_end].to_string(); let suffix = rewritten[source_hooks_start + source_hooks_path.len()..path_end].to_string(); if let Some(replacement) = target_hook_path_replacement( target_hooks_dir, &path, source_hooks_start - path_start, &suffix, ) { rewritten.replace_range(path_start..path_end, &replacement); search_start = path_start + replacement.len(); } else { search_start = source_hooks_start + source_hooks_path.len(); } } rewritten } fn find_unquoted_source_hook_path( command: &str, source_hooks_path: &str, start: usize, ) -> Option { let mut in_single_quote = false; let mut in_double_quote = false; let mut escaped = false; for (offset, ch) in command[start..].char_indices() { let index = start + offset; if escaped { escaped = false; continue; } if !in_single_quote && ch == '\\' { escaped = true; continue; } match ch { '\'' if !in_double_quote => { in_single_quote = !in_single_quote; } '"' if !in_single_quote => { in_double_quote = !in_double_quote; } _ if !in_single_quote && !in_double_quote && command[index..].starts_with(source_hooks_path) => { return Some(index); } _ => {} } } None } fn is_pure_shell_path_content(content: &str, source_hooks_start: usize) -> bool { let prefix = &content[..source_hooks_start]; (prefix.is_empty() || prefix == "./" || prefix.ends_with('/')) && !prefix.chars().any(is_shell_path_boundary) } fn shell_path_start(command: &str, end: usize) -> usize { command[..end] .char_indices() .filter_map(|(index, ch)| is_shell_path_boundary(ch).then_some(index + ch.len_utf8())) .next_back() .unwrap_or(0) } fn shell_path_end(command: &str, start: usize) -> usize { let mut escaped = false; for (offset, ch) in command[start..].char_indices() { if escaped { escaped = false; continue; } if ch == '\\' { escaped = true; continue; } if is_shell_path_boundary(ch) { return start + offset; } } command.len() } fn is_shell_path_boundary(ch: char) -> bool { ch.is_whitespace() || matches!(ch, '=' | ';' | '|' | '&' | '<' | '>' | '(' | ')') } fn is_assignment_value_start(command: &str, path_start: usize) -> bool { command[..path_start] .chars() .next_back() .is_some_and(|ch| ch == '=') } fn target_hook_path_replacement( target_hooks_dir: &Path, path: &str, source_hooks_start: usize, suffix: &str, ) -> Option { if !is_pure_shell_path_content(path, source_hooks_start) || !is_static_hook_path_suffix(suffix) { return None; } Some(shell_single_quote( target_hooks_dir.join(suffix).to_string_lossy().as_ref(), )) } fn is_static_hook_path_suffix(suffix: &str) -> bool { !suffix.is_empty() && !suffix .chars() .any(|ch| matches!(ch, '\\' | '$' | '`' | '*' | '?' | '[' | '{' | '}')) } fn looks_like_windows_hook_command(command: &str) -> bool { let source_hooks_backslash_path = format!( r"{}\{EXTERNAL_AGENT_HOOKS_SUBDIR}\", external_agent_config_dir() ); let project_dir_env_var = external_agent_project_dir_env_var(); command.contains(&source_hooks_backslash_path) || command.contains(&format!("%{project_dir_env_var}%")) || command.contains(&format!("$env:{project_dir_env_var}")) } fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } fn copy_hook_scripts(source_external_agent_dir: &Path, target_config_dir: &Path) -> io::Result<()> { let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR); if !source_hooks.is_dir() { return Ok(()); } let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR); copy_dir_recursive_skip_existing(&source_hooks, &target_hooks) } fn copy_dir_recursive_skip_existing(source: &Path, target: &Path) -> io::Result<()> { fs::create_dir_all(target)?; for entry in fs::read_dir(source)? { let entry = entry?; let source_path = entry.path(); let target_path = target.join(entry.file_name()); let file_type = entry.file_type()?; if file_type.is_dir() { copy_dir_recursive_skip_existing(&source_path, &target_path)?; } else if file_type.is_file() && !target_path.exists() { fs::copy(source_path, target_path)?; } } Ok(()) } fn agent_source_files(source_agents: &Path) -> io::Result> { if !source_agents.is_dir() { return Ok(Vec::new()); } let mut files = Vec::new(); for entry in fs::read_dir(source_agents)? { let entry = entry?; let path = entry.path(); if !entry.file_type()?.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("md") { continue; } if path.file_stem().and_then(|stem| stem.to_str()) == Some("README") { continue; } files.push(path); } files.sort(); Ok(files) } fn subagent_target_file(source_file: &Path, target_agents: &Path) -> Option { Some(target_agents.join(format!("{}.toml", source_file.file_stem()?.to_str()?))) } fn parse_document(source_file: &Path) -> io::Result { let content = fs::read_to_string(source_file)?; Ok(parse_document_content(&content)) } fn parse_document_content(content: &str) -> ParsedDocument { let Some(rest) = content .strip_prefix("---\n") .or_else(|| content.strip_prefix("---\r\n")) else { return ParsedDocument { frontmatter: BTreeMap::new(), body: content.to_string(), frontmatter_error: None, }; }; let Some((end, body_start)) = frontmatter_end(rest) else { return ParsedDocument { frontmatter: BTreeMap::new(), body: content.to_string(), frontmatter_error: None, }; }; let raw_frontmatter = &rest[..end]; let body = &rest[body_start..]; let (frontmatter, frontmatter_error) = parse_frontmatter(raw_frontmatter); ParsedDocument { frontmatter, body: body.to_string(), frontmatter_error, } } fn frontmatter_end(rest: &str) -> Option<(usize, usize)> { [ "\r\n---\r\n", "\r\n---\n", "\n---\r\n", "\n---\n", "\r\n---", "\n---", ] .into_iter() .filter_map(|delimiter| rest.find(delimiter).map(|end| (end, end + delimiter.len()))) .min_by_key(|(end, _body_start)| *end) } fn parse_frontmatter( raw_frontmatter: &str, ) -> (BTreeMap, Option) { let parsed: YamlValue = match serde_yaml::from_str(raw_frontmatter) { Ok(parsed) => parsed, Err(err) => return (BTreeMap::new(), Some(err.to_string())), }; let Some(mapping) = parsed.as_mapping() else { return ( BTreeMap::new(), Some("frontmatter is not a YAML mapping".to_string()), ); }; let mut frontmatter = BTreeMap::new(); for (key, value) in mapping { let Some(key) = key.as_str().map(str::trim).filter(|key| !key.is_empty()) else { continue; }; frontmatter.insert(key.to_string(), frontmatter_value_from_yaml(value)); } (frontmatter, None) } fn frontmatter_value_from_yaml(value: &YamlValue) -> FrontmatterValue { match value { YamlValue::String(value) => FrontmatterValue::Scalar(value.trim().to_string()), YamlValue::Bool(value) => FrontmatterValue::Scalar(value.to_string()), YamlValue::Number(value) => FrontmatterValue::Scalar(value.to_string()), YamlValue::Null | YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => { FrontmatterValue::Other } } } fn agent_metadata(document: &ParsedDocument) -> Option { if document.frontmatter_error.is_some() || document.body.trim().is_empty() { return None; } let name = document .frontmatter .get("name") .and_then(FrontmatterValue::as_scalar) .filter(|value| !value.trim().is_empty()) .map(ToOwned::to_owned)?; let description = document .frontmatter .get("description") .and_then(FrontmatterValue::as_scalar) .filter(|value| !value.trim().is_empty()) .map(ToOwned::to_owned)?; Some(AgentMetadata { name, description, permission_mode: frontmatter_string(&document.frontmatter, "permissionMode"), effort: frontmatter_string(&document.frontmatter, "effort"), }) } fn render_agent_toml( body: &str, metadata: &AgentMetadata, rewrite_profile: RewriteProfile, ) -> io::Result { let mut document = toml::map::Map::new(); document.insert("name".to_string(), TomlValue::String(metadata.name.clone())); document.insert( "description".to_string(), TomlValue::String(rewrite_external_agent_terms( &metadata.description, rewrite_profile, )), ); if let Some(effort) = metadata.effort.as_ref() && let Some(effort) = map_agent_reasoning_effort(effort) { document.insert( "model_reasoning_effort".to_string(), TomlValue::String(effort), ); } if let Some(sandbox_mode) = metadata .permission_mode .as_deref() .and_then(map_agent_permission_mode) { document.insert( "sandbox_mode".to_string(), TomlValue::String(sandbox_mode.to_string()), ); } document.insert( "developer_instructions".to_string(), TomlValue::String(render_agent_body(body, rewrite_profile)), ); let serialized = toml::to_string_pretty(&TomlValue::Table(document)) .map_err(|err| invalid_data_error(format!("failed to serialize agent TOML: {err}")))?; Ok(format!("{}\n", serialized.trim_end())) } fn render_agent_body(body: &str, rewrite_profile: RewriteProfile) -> String { let body = rewrite_external_agent_terms(body.trim(), rewrite_profile); if body.is_empty() { "No subagent instructions were found.".to_string() } else { body } } fn frontmatter_string( frontmatter: &BTreeMap, key: &str, ) -> Option { frontmatter .get(key) .and_then(FrontmatterValue::as_scalar) .map(ToOwned::to_owned) } fn map_agent_reasoning_effort(effort: &str) -> Option { let mapped = match effort { "max" => "xhigh".to_string(), _ => effort.to_string(), }; matches!( mapped.as_str(), "none" | "minimal" | "low" | "medium" | "high" | "xhigh" ) .then_some(mapped) } fn map_agent_permission_mode(permission_mode: &str) -> Option<&'static str> { match permission_mode { "acceptEdits" => Some("workspace-write"), "readOnly" => Some("read-only"), _ => None, } } fn json_string_vec(value: &JsonValue) -> Vec { match value { JsonValue::Array(values) => values.iter().filter_map(json_string).collect(), _ => json_string(value).into_iter().collect(), } } fn json_string(value: &JsonValue) -> Option { match value { JsonValue::Null => None, JsonValue::String(value) => Some(value.clone()), JsonValue::Bool(value) => Some(value.to_string()), JsonValue::Number(value) => Some(value.to_string()), JsonValue::Array(_) | JsonValue::Object(_) => None, } } fn json_u64(value: &JsonValue) -> Option { if value.is_boolean() || value.is_null() { return None; } value.as_u64().or_else(|| value.as_str()?.parse().ok()) } impl FrontmatterValue { fn as_scalar(&self) -> Option<&str> { match self { Self::Scalar(value) => Some(value), Self::Other => None, } } } fn is_missing_or_empty_text_file(path: &Path) -> io::Result { if !path.exists() { return Ok(true); } if !path.is_file() { return Ok(false); } Ok(fs::read_to_string(path)?.trim().is_empty()) } fn rewrite_external_agent_terms(content: &str, rewrite_profile: RewriteProfile) -> String { let mut rewritten = replace_case_insensitive_with_boundaries( content, rewrite_profile.doc_file_name, "AGENTS.md", ); for from in rewrite_profile.term_variants { rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex"); } for from in rewrite_profile.case_sensitive_term_variants { rewritten = replace_with_boundaries(&rewritten, from, "Codex"); } rewritten } fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> String { if needle.is_empty() { return input.to_string(); } let bytes = input.as_bytes(); let mut output = String::with_capacity(input.len()); let mut last_emitted = 0usize; let mut search_start = 0usize; while let Some(relative_pos) = input[search_start..].find(needle) { let start = search_start + relative_pos; let end = start + needle.len(); let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]); let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]); if boundary_before && boundary_after { output.push_str(&input[last_emitted..start]); output.push_str(replacement); last_emitted = end; } search_start = end; } if last_emitted == 0 { return input.to_string(); } output.push_str(&input[last_emitted..]); output } fn replace_case_insensitive_with_boundaries( input: &str, needle: &str, replacement: &str, ) -> String { let needle_lower = needle.to_ascii_lowercase(); if needle_lower.is_empty() { return input.to_string(); } let haystack_lower = input.to_ascii_lowercase(); let bytes = input.as_bytes(); let mut output = String::with_capacity(input.len()); let mut last_emitted = 0usize; let mut search_start = 0usize; while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) { let start = search_start + relative_pos; let end = start + needle_lower.len(); let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]); let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]); if boundary_before && boundary_after { output.push_str(&input[last_emitted..start]); output.push_str(replacement); last_emitted = end; } search_start = start + 1; } if last_emitted == 0 { return input.to_string(); } output.push_str(&input[last_emitted..]); output } fn is_word_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || byte == b'_' } fn invalid_data_error(message: impl Into) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message.into()) } fn external_agent_config_dir() -> String { format!(".{SOURCE_EXTERNAL_AGENT_NAME}") } fn external_agent_project_config_file() -> String { format!(".{SOURCE_EXTERNAL_AGENT_NAME}.json") } fn external_agent_project_dir_env_var() -> String { format!( "{}_PROJECT_DIR", SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase() ) } #[cfg(test)] #[path = "lib_tests.rs"] mod tests;