//! Migration helpers for importing external-agent configuration into Codex.use codex_hooks::HOOK_EVENT_NAMES;use codex_hooks::HOOK_EVENT_NAMES_WITH_MATCHERS;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;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";const COMMAND_SKILL_PREFIX: &str = "source-command";const MAX_SKILL_NAME_LEN: usize = 64;const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;#[derive(Debug)]struct ParsedDocument { frontmatter: BTreeMap<String, FrontmatterValue>, body: String, frontmatter_error: Option<String>,}#[derive(Debug)]enum FrontmatterValue { Scalar(String), Other,}#[derive(Debug)]struct AgentMetadata { name: String, description: String, permission_mode: Option<String>, effort: Option<String>,}pub fn build_mcp_config_from_external( source_root: &Path, external_agent_home: Option<&Path>, settings: Option<&JsonValue>,) -> io::Result<TomlValue> { let mcp_servers = read_external_mcp_servers(source_root, external_agent_home)?; 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::<BTreeSet<_>>(); 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 hooks_migration_description( source_external_agent_dir: &Path, target_hooks: &Path,) -> io::Result<Option<String>> { if hook_migration_event_names(source_external_agent_dir, target_hooks)?.is_empty() { return Ok(None); } Ok(Some(format!( "Migrate hooks from {} to {}", source_external_agent_dir.display(), target_hooks.display() )))}pub fn hook_migration_event_names( source_external_agent_dir: &Path, target_hooks: &Path,) -> io::Result<Vec<String>> { let migration = hook_migration(source_external_agent_dir, target_hooks.parent())?; Ok(migration.keys().cloned().collect())}pub fn import_hooks(source_external_agent_dir: &Path, target_hooks: &Path) -> io::Result<bool> { let Some(parent) = target_hooks.parent() else { return Err(invalid_data_error("hooks target path has no parent")); }; let migration = hook_migration(source_external_agent_dir, Some(parent))?; if migration.is_empty() { return Ok(false); } fs::create_dir_all(parent)?; let mut wrote_active_hooks = false; if is_missing_or_empty_text_file(target_hooks)? { 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"))?; wrote_active_hooks = true; } Ok(wrote_active_hooks)}pub fn count_missing_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<usize> { Ok(missing_subagent_names(source_agents, target_agents)?.len())}pub fn missing_subagent_names( source_agents: &Path, target_agents: &Path,) -> io::Result<Vec<String>> { 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(source_agents: &Path, target_agents: &Path) -> io::Result<Vec<String>> { 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)?)?; imported.push(metadata.name); } Ok(imported)}pub fn count_missing_commands(source_commands: &Path, target_skills: &Path) -> io::Result<usize> { Ok(missing_command_names(source_commands, target_skills)?.len())}pub fn missing_command_names( source_commands: &Path, target_skills: &Path,) -> io::Result<Vec<String>> { Ok(unique_supported_command_sources(source_commands)? .into_iter() .filter(|(_source_file, name)| !target_skills.join(name).exists()) .map(|(_source_file, name)| name) .collect())}pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result<Vec<String>> { if !source_commands.is_dir() { return Ok(Vec::new()); } fs::create_dir_all(target_skills)?; let mut imported = Vec::new(); for (source_file, name) in unique_supported_command_sources(source_commands)? { let document = parse_document(&source_file)?; let target_dir = target_skills.join(&name); if target_dir.exists() { continue; } fs::create_dir_all(&target_dir)?; let source_name = command_source_name(source_commands, &source_file); let Some(description) = command_skill_description(&document, &source_name) else { continue; }; fs::write( target_dir.join("SKILL.md"), render_command_skill(&document.body, &name, &description, &source_name), )?; imported.push(name); } Ok(imported)}fn read_external_mcp_servers( source_root: &Path, external_agent_home: Option<&Path>,) -> io::Result<BTreeMap<String, JsonValue>> { 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<String, JsonValue>,) -> 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<String, JsonValue>, 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<String, JsonValue>>, enabled_servers: &[String], disabled_servers: &BTreeSet<String>,) -> Option<toml::map::Map<String, TomlValue>> { 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::<Vec<_>>(); 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<String, JsonValue>, enabled_servers: &[String], disabled_servers: &BTreeSet<String>,) -> 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<String, TomlValue>, headers: &serde_json::Map<String, JsonValue>,) -> 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<String, TomlValue>, env: &serde_json::Map<String, JsonValue>,) -> 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<String> { 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 hook_migration( source_external_agent_dir: &Path, target_config_dir: Option<&Path>,) -> io::Result<serde_json::Map<String, JsonValue>> { let mut settings_files = Vec::new(); let mut disable_all_hooks = None; for settings_name in ["settings.json", "settings.local.json"] { let settings_file = source_external_agent_dir.join(settings_name); if !settings_file.is_file() { continue; } let raw = fs::read_to_string(&settings_file)?; let settings: JsonValue = serde_json::from_str(&raw) .map_err(|err| invalid_data_error(format!("invalid hooks settings: {err}")))?; if let Some(disabled) = settings.get("disableAllHooks").and_then(JsonValue::as_bool) { disable_all_hooks = Some(disabled); } settings_files.push(settings); } if disable_all_hooks.unwrap_or(false) { return Ok(serde_json::Map::new()); } let mut migration = serde_json::Map::new(); for settings in settings_files { append_convertible_hook_groups(&settings, &mut migration, target_config_dir); } Ok(migration)}fn append_convertible_hook_groups( settings: &JsonValue, hooks_payload: &mut serde_json::Map<String, JsonValue>, target_config_dir: Option<&Path>,) { let Some(hooks_config) = settings.get("hooks").and_then(JsonValue::as_object) else { return; }; for event_name in HOOK_EVENT_NAMES { let Some(groups) = hooks_config.get(event_name).and_then(JsonValue::as_array) else { continue; }; for group in groups { let Some(group_object) = group.as_object() else { continue; }; if group_object.contains_key("if") || group_object .keys() .any(|key| !matches!(key.as_str(), "matcher" | "hooks")) { continue; } let mut hook_commands = Vec::new(); if let Some(hooks) = group_object.get("hooks").and_then(JsonValue::as_array) { for hook in hooks { let Some(hook_object) = hook.as_object() else { continue; }; let hook_type = hook_object .get("type") .and_then(JsonValue::as_str) .unwrap_or("command"); if hook_type != "command" { continue; } if hook_object.keys().any(|key| { !matches!( key.as_str(), "type" | "command" | "timeout" | "timeoutSec" | "statusMessage" | "async" ) }) { continue; } if hook_object .get("async") .and_then(JsonValue::as_bool) .unwrap_or(false) { continue; } if ["asyncRewake", "shell", "once"] .into_iter() .any(|field| hook_object.contains_key(field)) { continue; } let Some(command) = hook_object .get("command") .and_then(JsonValue::as_str) .map(str::trim) .filter(|command| !command.is_empty()) else { continue; }; let mut command_payload = serde_json::Map::new(); command_payload .insert("type".to_string(), JsonValue::String("command".to_string())); command_payload.insert( "command".to_string(), JsonValue::String(rewrite_hook_command(command, target_config_dir)), ); if let Some(timeout) = hook_object .get("timeout") .or_else(|| hook_object.get("timeoutSec")) .and_then(json_u64) { command_payload.insert( "timeout".to_string(), JsonValue::Number(serde_json::Number::from(timeout)), ); } if let Some(status_message) = hook_object.get("statusMessage").and_then(JsonValue::as_str) { command_payload.insert( "statusMessage".to_string(), JsonValue::String(rewrite_external_agent_terms(status_message)), ); } hook_commands.push(JsonValue::Object(command_payload)); } } if hook_commands.is_empty() { continue; } let mut group_payload = serde_json::Map::new(); if HOOK_EVENT_NAMES_WITH_MATCHERS.contains(&event_name) && let Some(matcher) = group_object.get("matcher").and_then(JsonValue::as_str) { group_payload.insert( "matcher".to_string(), JsonValue::String(matcher.to_string()), ); } group_payload.insert("hooks".to_string(), JsonValue::Array(hook_commands)); if let Some(groups) = hooks_payload .entry(event_name.to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())) .as_array_mut() { groups.push(JsonValue::Object(group_payload)); } } }}fn rewrite_hook_command(command: &str, target_config_dir: Option<&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_hooks_path = format!( "{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/", external_agent_config_dir() ); 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<usize> { 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<String> { 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<Vec<PathBuf>> { 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<PathBuf> { Some(target_agents.join(format!("{}.toml", source_file.file_stem()?.to_str()?)))}fn command_source_files(source_commands: &Path) -> io::Result<Vec<PathBuf>> { let mut files = Vec::new(); collect_markdown_files(source_commands, &mut files)?; files.sort(); Ok(files)}fn unique_supported_command_sources(source_commands: &Path) -> io::Result<Vec<(PathBuf, String)>> { let mut by_name = BTreeMap::<String, Vec<PathBuf>>::new(); for source_file in command_source_files(source_commands)? { let document = parse_document(&source_file)?; let Some(name) = command_skill_name_if_supported(source_commands, &source_file, &document) else { continue; }; by_name.entry(name).or_default().push(source_file); } Ok(by_name .into_iter() .filter_map(|(name, source_files)| { let [source_file] = source_files.as_slice() else { return None; }; Some((source_file.clone(), name)) }) .collect())}fn collect_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> { if !dir.is_dir() { return Ok(()); } for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; if file_type.is_dir() { collect_markdown_files(&path, files)?; } else if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md") { files.push(path); } } Ok(())}fn parse_document(source_file: &Path) -> io::Result<ParsedDocument> { 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<String, FrontmatterValue>, Option<String>) { 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<AgentMetadata> { 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) -> io::Result<String> { 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)), ); 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)), ); 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) -> String { let body = rewrite_external_agent_terms(body.trim()); if body.is_empty() { "No subagent instructions were found.".to_string() } else { body }}fn command_skill_name(source_commands: &Path, source_file: &Path) -> String { slugify_name(&format!( "{COMMAND_SKILL_PREFIX}-{}", command_source_name(source_commands, source_file) ))}fn command_skill_name_if_supported( source_commands: &Path, source_file: &Path, document: &ParsedDocument,) -> Option<String> { if source_file.file_stem().and_then(|stem| stem.to_str()) == Some("README") { return None; } let source_name = command_source_name(source_commands, source_file); let description = command_skill_description(document, &source_name)?; let name = command_skill_name(source_commands, source_file); if name.chars().count() > MAX_SKILL_NAME_LEN { return None; } if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN { return None; } if has_unsupported_command_template_features(&document.body) { return None; } Some(name)}fn command_skill_description(document: &ParsedDocument, _source_name: &str) -> Option<String> { document .frontmatter .get("description") .and_then(FrontmatterValue::as_scalar) .filter(|value| !value.trim().is_empty()) .map(ToOwned::to_owned)}fn command_source_name(source_commands: &Path, source_file: &Path) -> String { source_file .strip_prefix(source_commands) .unwrap_or(source_file) .with_extension("") .components() .filter_map(|component| component.as_os_str().to_str()) .collect::<Vec<_>>() .join("-")}fn render_command_skill(body: &str, name: &str, description: &str, source_name: &str) -> String { let body = rewrite_external_agent_terms(body.trim()); let template_body = if body.is_empty() { "No command template body was found.".to_string() } else { body }; format!( "---\nname: {}\ndescription: {}\n---\n\n# {name}\n\nUse this skill when the user asks to run the migrated source command `{source_name}`.\n\n## Command Template\n\n{template_body}\n", yaml_string(name), yaml_string(&rewrite_external_agent_terms(description)), )}fn has_unsupported_command_template_features(template: &str) -> bool { template.contains("$ARGUMENTS") || contains_numbered_argument_placeholder(template) || (template.contains("{{") && template.contains("}}")) || template.contains("!`") || template.contains("! `") || template .split_whitespace() .any(|token| token.strip_prefix('@').is_some_and(|rest| !rest.is_empty()))}fn contains_numbered_argument_placeholder(template: &str) -> bool { let bytes = template.as_bytes(); bytes .windows(2) .any(|window| window[0] == b'$' && window[1].is_ascii_digit())}fn frontmatter_string( frontmatter: &BTreeMap<String, FrontmatterValue>, key: &str,) -> Option<String> { frontmatter .get(key) .and_then(FrontmatterValue::as_scalar) .map(ToOwned::to_owned)}fn map_agent_reasoning_effort(effort: &str) -> Option<String> { 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<String> { match value { JsonValue::Array(values) => values.iter().filter_map(json_string).collect(), _ => json_string(value).into_iter().collect(), }}fn json_string(value: &JsonValue) -> Option<String> { 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<u64> { if value.is_boolean() || value.is_null() { return None; } value.as_u64().or_else(|| value.as_str()?.parse().ok())}fn yaml_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))}fn slugify_name(value: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for ch in value.chars() { if ch.is_ascii_alphanumeric() { slug.push(ch.to_ascii_lowercase()); last_was_dash = false; } else if !last_was_dash { slug.push('-'); last_was_dash = true; } } let slug = slug.trim_matches('-').to_string(); if slug.is_empty() { "migrated".to_string() } else { slug }}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<bool> { 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) -> String { let mut rewritten = replace_case_insensitive_with_boundaries( content, &external_agent_doc_file_name(), "AGENTS.md", ); for from in external_agent_term_variants() { rewritten = replace_case_insensitive_with_boundaries(&rewritten, &from, "Codex"); } rewritten}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<String>) -> 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() )}fn external_agent_doc_file_name() -> String { format!("{SOURCE_EXTERNAL_AGENT_NAME}.md")}fn external_agent_term_variants() -> [String; 5] { [ format!("{SOURCE_EXTERNAL_AGENT_NAME} code"), format!("{SOURCE_EXTERNAL_AGENT_NAME}-code"), format!("{SOURCE_EXTERNAL_AGENT_NAME}_code"), format!("{SOURCE_EXTERNAL_AGENT_NAME}code"), SOURCE_EXTERNAL_AGENT_NAME.to_string(), ]}#[cfg(test)]mod tests { use super::*; use pretty_assertions::assert_eq; fn source_path(relative_path: &str) -> PathBuf { Path::new("/repo") .join(external_agent_config_dir()) .join(relative_path) } fn source_hook_command(script_name: &str) -> String { format!( "python3 {}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}", external_agent_config_dir() ) } fn source_hook_command_with_project_dir(script_name: &str) -> String { format!( "python3 \"${}\"/{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}", external_agent_project_dir_env_var(), external_agent_config_dir() ) } fn migrated_hook_command(script_name: &str) -> String { migrated_quoted_hook_command(script_name) } fn migrated_quoted_hook_command(script_name: &str) -> String { let hook_path = Path::new("/repo/.codex") .join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR) .join(script_name); format!( "python3 {}", shell_single_quote(hook_path.to_string_lossy().as_ref()) ) } #[test] fn env_placeholder_accepts_defaults() { assert_eq!( parse_env_placeholder("${TOKEN:-fallback}"), Some("TOKEN".to_string()) ); } #[test] fn mcp_migration_skips_placeholder_args() { let root = tempfile::TempDir::new().expect("tempdir"); fs::write( root.path().join(".mcp.json"), r#"{"mcpServers":{"db":{"command":"db-server","args":["${DATABASE_URL}"]}}}"#, ) .expect("write mcp"); assert_eq!( build_mcp_config_from_external( root.path(), /*external_agent_home*/ None, /*settings*/ None, ) .unwrap(), TomlValue::Table(Default::default()) ); } #[test] fn mcp_migration_prefers_command_transport_for_mixed_server_config() { let root = tempfile::TempDir::new().expect("tempdir"); fs::write( root.path().join(".mcp.json"), r#"{ "mcpServers": { "mixedTransport": { "command": "mcp-remote-proxy", "args": [ "https://example.com/mixed-transport", "--transport", "http" ], "url": "https://example.com/mixed-transport" } } }"#, ) .expect("write mcp"); assert_eq!( build_mcp_config_from_external( root.path(), /*external_agent_home*/ None, /*settings*/ None, ) .unwrap(), toml::from_str( r#"[mcp_servers.mixedTransport]command = "mcp-remote-proxy"args = [ "https://example.com/mixed-transport", "--transport", "http",]"# ) .unwrap() ); } #[test] fn mcp_migration_skips_unsupported_transports() { let root = tempfile::TempDir::new().expect("tempdir"); fs::write( root.path().join(".mcp.json"), r#"{ "mcpServers": { "legacy-sse": {"type": "sse", "url": "https://example.invalid/sse"}, "vault": { "url": "https://example.invalid/vault", "headers": {"Authorization": "Bearer ${VAULT_TOKEN:-dev-token}"} } } }"#, ) .expect("write mcp"); assert_eq!( build_mcp_config_from_external( root.path(), /*external_agent_home*/ None, /*settings*/ None, ) .unwrap(), toml::from_str( r#"[mcp_servers.vault]url = "https://example.invalid/vault"bearer_token_env_var = "VAULT_TOKEN""# ) .unwrap() ); } #[test] fn mcp_migration_reads_matching_project_entries_from_repo_external_project_config() { let root = tempfile::TempDir::new().expect("tempdir"); let project = root.path().join("repo"); fs::create_dir_all(&project).expect("create repo"); let other = root.path().join("other"); fs::create_dir_all(&other).expect("create other"); fs::write( project.join(external_agent_project_config_file()), serde_json::json!({ "mcpServers": { "top": {"command": "top-server"} }, "projects": { project.display().to_string(): { "mcpServers": { "repo": {"command": "repo-server"} } }, other.display().to_string(): { "mcpServers": { "other": {"command": "other-server"} } } } }) .to_string(), ) .expect("write external agent project config"); assert_eq!( build_mcp_config_from_external( &project, /*external_agent_home*/ None, /*settings*/ None, ) .unwrap(), toml::from_str( r#"[mcp_servers.repo]command = "repo-server"[mcp_servers.top]command = "top-server""# ) .unwrap() ); } #[test] fn mcp_migration_reads_matching_project_entries_from_home_external_project_config() { let root = tempfile::TempDir::new().expect("tempdir"); let project = root.path().join("repo"); fs::create_dir_all(&project).expect("create repo"); let external_agent_home = root.path().join(external_agent_config_dir()); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( root.path().join(external_agent_project_config_file()), serde_json::json!({ "projects": { project.display().to_string(): { "mcpServers": { "repo": {"command": "repo-server"} } } } }) .to_string(), ) .expect("write external agent project config"); assert_eq!( build_mcp_config_from_external( &project, Some(&external_agent_home), /*settings*/ None, ) .unwrap(), toml::from_str( r#"[mcp_servers.repo]command = "repo-server""# ) .unwrap() ); } #[test] fn mcp_migration_preserves_repo_servers_over_home_project_entries() { let root = tempfile::TempDir::new().expect("tempdir"); let project = root.path().join("repo"); fs::create_dir_all(&project).expect("create repo"); let external_agent_home = root.path().join(external_agent_config_dir()); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( project.join(EXTERNAL_AGENT_MCP_CONFIG_FILE), serde_json::json!({ "mcpServers": { "shared": {"command": "repo-server"} } }) .to_string(), ) .expect("write repo mcp"); fs::write( root.path().join(external_agent_project_config_file()), serde_json::json!({ "projects": { project.display().to_string(): { "mcpServers": { "home-only": {"command": "home-only-server"}, "shared": {"command": "home-server"} } } } }) .to_string(), ) .expect("write external agent project config"); assert_eq!( build_mcp_config_from_external( &project, Some(&external_agent_home), /*settings*/ None, ) .unwrap(), toml::from_str( r#"[mcp_servers.home-only]command = "home-only-server"[mcp_servers.shared]command = "repo-server""# ) .unwrap() ); } #[test] fn mcp_migration_skips_disabled_servers() { let root = tempfile::TempDir::new().expect("tempdir"); fs::write( root.path().join(".mcp.json"), r#"{ "mcpServers": { "enabled": {"command": "enabled-server"}, "explicit-disabled": {"command": "disabled-server", "disabled": true}, "not-enabled": {"command": "not-enabled-server"} } }"#, ) .expect("write mcp"); let settings = serde_json::json!({ "enabledMcpjsonServers": ["enabled"], "disabledMcpjsonServers": ["explicit-disabled"] }); assert_eq!( build_mcp_config_from_external( root.path(), /*external_agent_home*/ None, Some(&settings), ) .unwrap(), toml::from_str( r#"[mcp_servers.enabled]command = "enabled-server""# ) .unwrap() ); } #[test] fn command_skill_names_include_nested_paths() { let root = source_path("commands"); let file = source_path("commands/pr/review.md"); assert_eq!(command_skill_name(&root, &file), "source-command-pr-review"); } #[test] fn command_skill_names_must_fit_codex_skill_loader_limit() { let root = source_path("commands"); let file = source_path("commands/this/is/a/deeply/nested/command/with/a/very/long/name.md"); let document = parse_document_content("---\ndescription: Review PR\n---\nReview\n"); assert!(command_skill_name_if_supported(&root, &file, &document).is_none()); } #[test] fn commands_with_provider_runtime_expansion_are_skipped() { let root = source_path("commands"); let file = source_path("commands/deploy.md"); let document = parse_document_content( "---\ndescription: Deploy\n---\nDeploy $ARGUMENTS from @release.yaml\n", ); assert!(command_skill_name_if_supported(&root, &file, &document).is_none()); } #[test] fn commands_without_description_are_skipped() { let root = source_path("commands"); let file = source_path("commands/README.md"); let document = parse_document_content("# Notes\n\nThis documents commands.\n"); assert!(command_skill_name_if_supported(&root, &file, &document).is_none()); } #[test] fn command_slug_collisions_are_skipped() { let root = tempfile::TempDir::new().expect("tempdir"); let commands = root.path().join("commands"); fs::create_dir_all(&commands).expect("create commands"); fs::write( commands.join("foo-bar.md"), "---\ndescription: First\n---\nRun the first command.\n", ) .expect("write first command"); fs::write( commands.join("foo_bar.md"), "---\ndescription: Second\n---\nRun the second command.\n", ) .expect("write second command"); assert_eq!( unique_supported_command_sources(&commands).unwrap(), Vec::<(PathBuf, String)>::new() ); } #[test] fn subagent_accepts_yaml_block_lists_by_ignoring_unsupported_fields() { let document = parse_document_content( "---\nname: cloud-incident\ndescription: Debug incidents\nskills:\n - runbook-reader\ntools:\n - Read\n - Bash\ndisallowedTools:\n - Write\n---\nInvestigate carefully.\n", ); assert!(agent_metadata(&document).is_some()); } #[test] fn subagent_requires_minimum_codex_agent_fields() { let missing_description = parse_document_content("---\nname: incomplete\n---\nInvestigate carefully.\n"); let missing_body = parse_document_content("---\nname: incomplete\ndescription: Missing body\n---\n"); assert!(agent_metadata(&missing_description).is_none()); assert!(agent_metadata(&missing_body).is_none()); } #[test] fn subagent_preserves_default_model_when_source_model_is_present() { let document = parse_document_content( "---\nname: reviewer\ndescription: Review code\nmodel: source-opus\neffort: max\n---\nReview carefully.\n", ); let metadata = agent_metadata(&document).expect("metadata"); let rendered: TomlValue = toml::from_str(&render_agent_toml(&document.body, &metadata).expect("render agent")) .expect("parse rendered agent"); let expected: TomlValue = toml::from_str( r#"name = "reviewer"description = "Review code"model_reasoning_effort = "xhigh"developer_instructions = """Review carefully.""""#, ) .expect("parse expected agent"); assert_eq!(rendered, expected); } #[test] fn subagent_target_preserves_dotted_file_stem() { let target_agents = Path::new("/repo/.codex/agents"); let source_file = source_path("agents/security.audit.md"); assert_eq!( subagent_target_file(&source_file, target_agents), Some(PathBuf::from("/repo/.codex/agents/security.audit.toml")) ); } #[test] fn frontmatter_accepts_crlf_delimiters() { let document = parse_document_content( "---\r\nname: reviewer\r\ndescription: Review code\r\n---\r\nReview carefully.\r\n", ); assert_eq!( ( document .frontmatter .get("name") .and_then(FrontmatterValue::as_scalar), document .frontmatter .get("description") .and_then(FrontmatterValue::as_scalar), document.body.as_str(), ), ( Some("reviewer"), Some("Review code"), "Review carefully.\r\n" ) ); } #[test] fn hook_migration_ignores_unsupported_handlers() { let settings = serde_json::json!({ "hooks": { "PreToolUse": [{ "matcher": "Bash", "if": "tool_input.command contains 'rm'", "hooks": [{ "type": "command", "command": source_hook_command("policy_gate.py") }] }, { "matcher": "Edit", "hooks": [ { "type": "command", "if": "Bash(rm *)", "command": source_hook_command("policy_gate.py") }, { "type": "http", "url": "https://example.invalid/hook" } ] }], "PermissionRequest": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": source_hook_command("approve.py") }] }], "SubagentStart": [{ "matcher": "worker", "hooks": [{"type": "prompt", "prompt": "check"}] }] } }); let mut migration = serde_json::Map::new(); append_convertible_hook_groups(&settings, &mut migration, Some(Path::new("/repo/.codex"))); assert_eq!( migration, serde_json::json!({ "PermissionRequest": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": migrated_hook_command("approve.py") }] }] }) .as_object() .cloned() .expect("object") ); } #[test] fn hook_migration_honors_disable_all_hooks() { let root = tempfile::TempDir::new().expect("tempdir"); fs::write( root.path().join("settings.json"), r#"{ "disableAllHooks": true, "hooks": { "SessionStart": [{ "matcher": "startup", "hooks": [{"type": "command", "command": "echo setup"}] }] } }"#, ) .expect("write settings"); assert_eq!( hook_migration(root.path(), /*target_config_dir*/ None).unwrap(), serde_json::Map::new() ); } #[test] fn hook_migration_honors_settings_local_disable_override() { let root = tempfile::TempDir::new().expect("tempdir"); fs::write( root.path().join("settings.json"), r#"{ "disableAllHooks": true, "hooks": { "SessionStart": [{ "matcher": "project", "hooks": [{"type": "command", "command": "echo project"}] }] } }"#, ) .expect("write project settings"); fs::write( root.path().join("settings.local.json"), r#"{ "disableAllHooks": false, "hooks": { "SessionStart": [{ "matcher": "local", "hooks": [{"type": "command", "command": "echo local"}] }] } }"#, ) .expect("write local settings"); assert_eq!( hook_migration(root.path(), /*target_config_dir*/ None).unwrap(), serde_json::json!({ "SessionStart": [{ "matcher": "project", "hooks": [{ "type": "command", "command": "echo project" }] }, { "matcher": "local", "hooks": [{ "type": "command", "command": "echo local" }] }] }) .as_object() .cloned() .expect("object") ); } #[test] fn hook_command_paths_rewrite_to_target_hook_dir() { let project_dir_env_var = external_agent_project_dir_env_var(); let plugin_root_env_var = format!( "{}_PLUGIN_ROOT", SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase() ); let source_hooks_path = format!( "{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}", external_agent_config_dir() ); assert_eq!( rewrite_hook_command( &source_hook_command_with_project_dir("check.py"), Some(Path::new("/repo/.codex")), ), migrated_hook_command("check.py") ); assert_eq!( rewrite_hook_command( &format!("\"${project_dir_env_var}\"/{source_hooks_path}/check-style.sh"), Some(Path::new("/repo/.codex")), ), shell_single_quote( Path::new("/repo/.codex") .join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR) .join("check-style.sh") .to_string_lossy() .as_ref() ) ); assert_eq!( rewrite_hook_command( &source_hook_command("check.py"), Some(Path::new("/repo/.codex")), ), migrated_hook_command("check.py") ); assert_eq!( rewrite_hook_command( &format!("python3 ./{source_hooks_path}/check.py"), Some(Path::new("/repo/.codex")), ), migrated_hook_command("check.py") ); assert_eq!( rewrite_hook_command( &format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/check.py'"), Some(Path::new("/repo/.codex")), ), migrated_quoted_hook_command("check.py") ); assert_eq!( rewrite_hook_command( &format!("python3 \"${{{project_dir_env_var}}}/{source_hooks_path}/check.py\""), Some(Path::new("/repo/.codex")), ), migrated_quoted_hook_command("check.py") ); assert_eq!( rewrite_hook_command( &format!("bash -lc \"python3 {source_hooks_path}/check.py\""), Some(Path::new("/repo/.codex")), ), format!("bash -lc \"python3 {source_hooks_path}/check.py\"") ); assert_eq!( rewrite_hook_command( &format!( "HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\"" ), Some(Path::new("/repo/.codex")), ), format!( "HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\"" ) ); assert_eq!( rewrite_hook_command( &format!("python3 {source_hooks_path}/${{SCRIPT}}.py"), Some(Path::new("/repo/.codex")), ), format!("python3 {source_hooks_path}/${{SCRIPT}}.py") ); assert_eq!( rewrite_hook_command( &format!("python3 {source_hooks_path}/{{lint,fmt}}.sh"), Some(Path::new("/repo/.codex")), ), format!("python3 {source_hooks_path}/{{lint,fmt}}.sh") ); assert_eq!( rewrite_hook_command( &format!("python3 {source_hooks_path}/my\\ script.py"), Some(Path::new("/repo/.codex")), ), format!("python3 {source_hooks_path}/my\\ script.py") ); assert_eq!( rewrite_hook_command( &format!("python3 .{SOURCE_EXTERNAL_AGENT_NAME}\\hooks\\check.py"), Some(Path::new("/repo/.codex")), ), format!("python3 .{}\\hooks\\check.py", SOURCE_EXTERNAL_AGENT_NAME) ); assert_eq!( rewrite_hook_command( &format!( "python3 \"%{}%\\{}\\hooks\\check.py\"", project_dir_env_var, external_agent_config_dir() ), Some(Path::new("/repo/.codex")), ), format!( "python3 \"%{}%\\{}\\hooks\\check.py\"", project_dir_env_var, external_agent_config_dir() ) ); assert_eq!( rewrite_hook_command( &format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/my script.py'"), Some(Path::new("/repo/.codex")), ), migrated_quoted_hook_command("my script.py") ); assert_eq!( rewrite_hook_command( &format!("/repo/{source_hooks_path}/check.py 2>/dev/null || true"), Some(Path::new("/repo/.codex")), ), format!( "{} 2>/dev/null || true", shell_single_quote( Path::new("/repo/.codex") .join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR) .join("check.py") .to_string_lossy() .as_ref() ) ) ); let plugin_script_command = format!("${{{plugin_root_env_var}}}/scripts/format.sh"); assert_eq!( rewrite_hook_command(&plugin_script_command, Some(Path::new("/repo/.codex")),), plugin_script_command ); } #[test] fn hook_script_copy_keeps_existing_target_scripts() { let root = tempfile::TempDir::new().expect("tempdir"); let source_external_agent_dir = root.path().join(external_agent_config_dir()); let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR); let target_config_dir = root.path().join(".codex"); let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR); fs::create_dir_all(&source_hooks).expect("create source hooks"); fs::create_dir_all(&target_hooks).expect("create target hooks"); fs::write(source_hooks.join("check.py"), "new script").expect("write source hook"); fs::write(target_hooks.join("check.py"), "existing script").expect("write target hook"); copy_hook_scripts(&source_external_agent_dir, &target_config_dir).expect("copy hooks"); assert_eq!( fs::read_to_string(target_hooks.join("check.py")).expect("read target hook"), "existing script" ); } #[test] fn hook_migration_drops_negative_timeouts() { let settings = serde_json::json!({ "hooks": { "SessionStart": [{ "matcher": "startup", "hooks": [{ "type": "command", "command": "echo setup", "timeout": -1 }] }] } }); let mut migration = serde_json::Map::new(); append_convertible_hook_groups(&settings, &mut migration, /*target_config_dir*/ None); assert_eq!( migration, serde_json::json!({ "SessionStart": [{ "matcher": "startup", "hooks": [{ "type": "command", "command": "echo setup" }] }] }) .as_object() .cloned() .expect("object") ); }}