Codex Handbook
external-agent-migration/src/lib.rs 2184 lines
//! 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")        );    }}