Skip to main content

codex_external_agent_migration/
service.rs

1use crate::config_values::is_empty_toml_table;
2use crate::config_values::merge_missing_mcp_servers;
3use crate::config_values::merge_missing_toml_values;
4use crate::config_values::migrated_mcp_server_names;
5use crate::config_values::write_toml_file;
6use crate::memory_import;
7use crate::migration_source::ExternalAgentSource;
8use crate::migration_source::InstructionSourceGroup;
9pub use crate::model::ExternalAgentConfigDetectOptions;
10pub use crate::model::ExternalAgentConfigImportItemResult;
11pub use crate::model::ExternalAgentConfigImportOutcome;
12pub use crate::model::ExternalAgentConfigImportRawError;
13pub use crate::model::ExternalAgentConfigImportSuccess;
14pub use crate::model::ExternalAgentConfigMigrationItem;
15pub use crate::model::ExternalAgentConfigMigrationItemType;
16pub use crate::model::ExternalAgentSessionImportLimits;
17pub use crate::model::MigrationDetails;
18pub use crate::model::NamedMigration;
19pub use crate::model::PendingPluginImport;
20pub use crate::model::PluginImportOutcome;
21pub use crate::model::PluginsMigration;
22use crate::reporting::emit_migration_metric;
23#[cfg(test)]
24use crate::reporting::migration_metric_tags;
25pub use crate::reporting::record_import_error;
26use crate::scope::MigrationScope;
27use crate::sessions::SessionMetadataMode;
28#[cfg(test)]
29use crate::source_cla::KNOWN_MARKETPLACES_PATH as EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH;
30#[cfg(test)]
31use crate::source_cla::OFFICIAL_MARKETPLACE_NAME as EXTERNAL_OFFICIAL_MARKETPLACE_NAME;
32use crate::utils::copy_dir_recursive;
33use crate::utils::display_source_paths;
34use crate::utils::invalid_data_error;
35use crate::utils::is_missing_or_empty_text_file;
36pub(super) use crate::utils::read_json_file as read_external_settings;
37use crate::utils::rewrite_external_agent_terms;
38use codex_analytics::AnalyticsEventsClient;
39use codex_core::config::Config;
40use codex_core_plugins::PluginsManager;
41use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
42use codex_protocol::protocol::Product;
43use codex_rollout::StateDbHandle;
44use serde_json::Value as JsonValue;
45use std::collections::BTreeMap;
46use std::collections::HashSet;
47use std::ffi::OsString;
48use std::fs;
49use std::io;
50use std::path::Path;
51use std::path::PathBuf;
52use toml::Value as TomlValue;
53
54#[cfg(test)]
55const EXTERNAL_AGENT_DIR: &str = crate::ClaSource::CONFIG_DIR;
56#[cfg(test)]
57const EXTERNAL_AGENT_CONFIG_MD: &str = crate::ClaSource::CONFIG_MD;
58
59const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
60
61#[derive(Clone)]
62pub struct ExternalAgentConfigService {
63    pub(super) codex_home: PathBuf,
64    pub(super) connector_metadata_roots: Vec<PathBuf>,
65    pub(crate) external_agent_home: PathBuf,
66    pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
67    pub(crate) source: ExternalAgentSource,
68    pub(crate) session_import_limits: ExternalAgentSessionImportLimits,
69    state_db: Option<StateDbHandle>,
70}
71
72impl ExternalAgentConfigService {
73    pub fn new(
74        codex_home: PathBuf,
75        analytics_events_client: AnalyticsEventsClient,
76        state_db: Option<StateDbHandle>,
77    ) -> Self {
78        let source = ExternalAgentSource::default();
79        let external_agent_home = default_external_agent_home(source);
80        let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home);
81        Self {
82            codex_home,
83            connector_metadata_roots,
84            external_agent_home,
85            analytics_events_client: Some(analytics_events_client),
86            source,
87            session_import_limits: ExternalAgentSessionImportLimits::default(),
88            state_db,
89        }
90    }
91
92    pub fn with_migration_source(&self, migration_source: Option<&str>) -> Self {
93        let source = ExternalAgentSource::from_migration_source(migration_source);
94        let external_agent_home = default_external_agent_home(source);
95        let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home);
96        Self {
97            codex_home: self.codex_home.clone(),
98            connector_metadata_roots,
99            external_agent_home,
100            analytics_events_client: self.analytics_events_client.clone(),
101            source,
102            session_import_limits: self.session_import_limits,
103            state_db: self.state_db.clone(),
104        }
105    }
106
107    pub fn with_session_import_limits(&self, limits: ExternalAgentSessionImportLimits) -> Self {
108        let mut service = self.clone();
109        service.session_import_limits = limits;
110        service
111    }
112
113    pub fn session_metadata_mode(&self) -> SessionMetadataMode {
114        self.source.session_metadata_mode()
115    }
116
117    pub fn connector_metadata_roots(&self) -> &[PathBuf] {
118        &self.connector_metadata_roots
119    }
120
121    pub fn codex_home(&self) -> &Path {
122        &self.codex_home
123    }
124
125    #[cfg(test)]
126    fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
127        let source = ExternalAgentSource::default();
128        let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home);
129        Self {
130            codex_home,
131            connector_metadata_roots,
132            external_agent_home,
133            analytics_events_client: None,
134            source,
135            session_import_limits: ExternalAgentSessionImportLimits::default(),
136            state_db: None,
137        }
138    }
139
140    pub fn external_agent_session_source_path(&self, path: &Path) -> io::Result<Option<PathBuf>> {
141        if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
142            return Ok(None);
143        }
144        let path = match fs::canonicalize(path) {
145            Ok(path) => path,
146            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
147            Err(err) => return Err(err),
148        };
149        let projects_root = match fs::canonicalize(self.external_agent_home.join("projects")) {
150            Ok(projects_root) => projects_root,
151            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
152            Err(err) => return Err(err),
153        };
154        Ok(path.starts_with(projects_root).then_some(path))
155    }
156
157    pub async fn import(
158        &self,
159        migration_items: Vec<ExternalAgentConfigMigrationItem>,
160    ) -> ExternalAgentConfigImportOutcome {
161        let mut outcome = ExternalAgentConfigImportOutcome::default();
162        for migration_item in migration_items {
163            let item_type = migration_item.item_type;
164            let description = migration_item.description.clone();
165            let cwd_for_log = migration_item.cwd.clone();
166            let mut item_result = ExternalAgentConfigImportItemResult::new(
167                item_type,
168                description.clone(),
169                cwd_for_log.clone(),
170            );
171            let import_result = match migration_item.item_type {
172                ExternalAgentConfigMigrationItemType::Config => (|| {
173                    if let Some((source, target)) =
174                        self.import_config(migration_item.cwd.as_deref())?
175                    {
176                        item_result.record_success(Some(source), Some(target));
177                    }
178                    emit_migration_metric(
179                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
180                        ExternalAgentConfigMigrationItemType::Config,
181                        /*skills_count*/ None,
182                    );
183                    Ok(())
184                })(),
185                ExternalAgentConfigMigrationItemType::Skills => (|| {
186                    let imported_skills = self.import_skills(migration_item.cwd.as_deref())?;
187                    emit_migration_metric(
188                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
189                        ExternalAgentConfigMigrationItemType::Skills,
190                        Some(imported_skills.len()),
191                    );
192                    for skill_name in imported_skills {
193                        item_result.record_success(Some(skill_name.clone()), Some(skill_name));
194                    }
195                    Ok(())
196                })(),
197                ExternalAgentConfigMigrationItemType::AgentsMd => (|| {
198                    if let Some((source, target)) =
199                        self.import_agents_md(migration_item.cwd.as_deref())?
200                    {
201                        item_result.record_success(Some(source), Some(target));
202                    }
203                    emit_migration_metric(
204                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
205                        ExternalAgentConfigMigrationItemType::AgentsMd,
206                        /*skills_count*/ None,
207                    );
208                    Ok(())
209                })(),
210                ExternalAgentConfigMigrationItemType::Plugins => {
211                    async {
212                        let cwd = migration_item.cwd;
213                        let details = match migration_item.details {
214                            Some(details) => details,
215                            None => {
216                                let err = invalid_data_error(
217                                    "plugins migration item is missing details".to_string(),
218                                );
219                                record_import_error(
220                                    &mut item_result,
221                                    "plugin_import",
222                                    /*sub_error_type*/ None,
223                                    err.to_string(),
224                                    /*source*/ None,
225                                );
226                                return Err(err);
227                            }
228                        };
229                        let (local_details, remote_details) = match self
230                            .partition_plugin_migration_details(cwd.as_deref(), details)
231                        {
232                            Ok(details) => details,
233                            Err(err) => {
234                                record_import_error(
235                                    &mut item_result,
236                                    "plugin_import",
237                                    /*sub_error_type*/ None,
238                                    err.to_string(),
239                                    /*source*/ None,
240                                );
241                                return Err(err);
242                            }
243                        };
244
245                        if let Some(local_details) = local_details {
246                            let plugin_outcome = match self
247                                .import_plugins(cwd.as_deref(), Some(local_details))
248                                .await
249                            {
250                                Ok(plugin_outcome) => plugin_outcome,
251                                Err(err) => {
252                                    record_import_error(
253                                        &mut item_result,
254                                        "plugin_import",
255                                        /*sub_error_type*/ None,
256                                        err.to_string(),
257                                        /*source*/ None,
258                                    );
259                                    return Err(err);
260                                }
261                            };
262                            for plugin_id in plugin_outcome.succeeded_plugin_ids {
263                                item_result
264                                    .record_success(Some(plugin_id.clone()), Some(plugin_id));
265                            }
266                            for raw_error in plugin_outcome.raw_errors {
267                                item_result.record_error(raw_error);
268                            }
269                        }
270                        if let Some(remote_details) = remote_details {
271                            outcome.pending_plugin_imports.push(PendingPluginImport {
272                                cwd,
273                                description: description.clone(),
274                                details: remote_details,
275                            });
276                        }
277                        emit_migration_metric(
278                            EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
279                            ExternalAgentConfigMigrationItemType::Plugins,
280                            /*skills_count*/ None,
281                        );
282                        Ok(())
283                    }
284                    .await
285                }
286                ExternalAgentConfigMigrationItemType::McpServerConfig => (|| {
287                    let migrated_server_names =
288                        self.import_mcp_server_config(migration_item.cwd.as_deref())?;
289                    emit_migration_metric(
290                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
291                        ExternalAgentConfigMigrationItemType::McpServerConfig,
292                        /*skills_count*/ None,
293                    );
294                    for server_name in migrated_server_names {
295                        item_result.record_success(Some(server_name.clone()), Some(server_name));
296                    }
297                    Ok(())
298                })(),
299                ExternalAgentConfigMigrationItemType::Subagents => (|| {
300                    let imported_subagents =
301                        self.import_subagents(migration_item.cwd.as_deref())?;
302                    emit_migration_metric(
303                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
304                        ExternalAgentConfigMigrationItemType::Subagents,
305                        Some(imported_subagents.len()),
306                    );
307                    for subagent_name in imported_subagents {
308                        item_result
309                            .record_success(Some(subagent_name.clone()), Some(subagent_name));
310                    }
311                    Ok(())
312                })(),
313                ExternalAgentConfigMigrationItemType::Hooks => (|| {
314                    let migrated_hook_names = self.import_hooks(migration_item.cwd.as_deref())?;
315                    emit_migration_metric(
316                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
317                        ExternalAgentConfigMigrationItemType::Hooks,
318                        /*skills_count*/ None,
319                    );
320                    for hook_name in migrated_hook_names {
321                        item_result.record_success(Some(hook_name.clone()), Some(hook_name));
322                    }
323                    Ok(())
324                })(),
325                ExternalAgentConfigMigrationItemType::Commands => (|| {
326                    let imported_commands = self.import_commands(migration_item.cwd.as_deref())?;
327                    emit_migration_metric(
328                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
329                        ExternalAgentConfigMigrationItemType::Commands,
330                        Some(imported_commands.len()),
331                    );
332                    for command_name in imported_commands {
333                        item_result.record_success(Some(command_name.clone()), Some(command_name));
334                    }
335                    Ok(())
336                })(),
337                ExternalAgentConfigMigrationItemType::Memory if self.source.supports_memory() => {
338                    async {
339                        let selected_memory = migration_item
340                            .details
341                            .as_ref()
342                            .map(|details| details.memory.as_slice())
343                            .unwrap_or_default();
344                        let memory_outcome = memory_import::import(
345                            &self.codex_home,
346                            &self.external_agent_home,
347                            self.state_db.as_ref(),
348                            selected_memory,
349                        )
350                        .await?;
351                        emit_migration_metric(
352                            EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
353                            ExternalAgentConfigMigrationItemType::Memory,
354                            /*skills_count*/ None,
355                        );
356                        let target_path = memory_import::resources_root(&self.codex_home);
357                        for project_key in memory_outcome.synchronized_projects {
358                            item_result.record_success(
359                                Some(project_key),
360                                Some(target_path.display().to_string()),
361                            );
362                        }
363                        for failure in memory_outcome.failures {
364                            record_import_error(
365                                &mut item_result,
366                                "memory_import",
367                                /*sub_error_type*/ None,
368                                failure.message,
369                                Some(failure.project_key),
370                            );
371                        }
372                        Ok(())
373                    }
374                    .await
375                }
376                ExternalAgentConfigMigrationItemType::Memory => Err(invalid_data_error(
377                    "memory import is not supported for the selected migration source".to_string(),
378                )),
379                ExternalAgentConfigMigrationItemType::Sessions => Ok(()),
380            };
381            if let Err(err) = import_result
382                && item_type != ExternalAgentConfigMigrationItemType::Plugins
383            {
384                let message = err.to_string();
385                let error_type = if message.contains("invalid existing config.toml") {
386                    "invalid_existing_config"
387                } else {
388                    "external_agent_config_import_error"
389                };
390                item_result.record_error(ExternalAgentConfigImportRawError {
391                    item_type,
392                    error_type: Some(error_type.to_string()),
393                    sub_error_type: None,
394                    failure_stage: "import_request_failed".to_string(),
395                    message,
396                    cwd: item_result.cwd.clone(),
397                    source: None,
398                });
399            }
400            outcome.item_results.push(item_result);
401        }
402
403        outcome
404    }
405
406    pub(crate) fn home_target_skills_dir(&self) -> PathBuf {
407        self.codex_home
408            .parent()
409            .map(|parent| parent.join(".agents").join("skills"))
410            .unwrap_or_else(|| PathBuf::from(".agents").join("skills"))
411    }
412
413    pub(crate) fn source_config_dir(&self, scope: &MigrationScope) -> PathBuf {
414        scope.repo_root().map_or_else(
415            || self.external_agent_home.clone(),
416            |repo_root| repo_root.join(self.source.config_dir()),
417        )
418    }
419
420    pub(crate) fn source_settings(&self, scope: &MigrationScope) -> PathBuf {
421        self.source_config_dir(scope)
422            .join(self.source.settings_file_name(scope))
423    }
424
425    pub(crate) fn effective_source_settings(
426        &self,
427        scope: &MigrationScope,
428    ) -> io::Result<Option<JsonValue>> {
429        let source_settings = self.source_settings(scope);
430        self.source
431            .effective_settings(self.source_config_dir(scope).as_path(), &source_settings)
432    }
433
434    pub(crate) fn build_mcp_config(
435        &self,
436        scope: &MigrationScope,
437        settings: Option<JsonValue>,
438    ) -> io::Result<TomlValue> {
439        let settings = self.mcp_settings(scope, settings)?;
440        self.source.build_mcp_config(
441            self.source_root(scope).as_path(),
442            self.source_config_dir(scope).as_path(),
443            self.external_agent_home.as_path(),
444            settings.as_ref(),
445        )
446    }
447
448    pub(crate) fn repo_agents_md_source_groups(
449        &self,
450        repo_root: &Path,
451    ) -> io::Result<Vec<InstructionSourceGroup>> {
452        self.source.repo_instruction_source_groups(repo_root)
453    }
454
455    pub(crate) fn home_agents_md_sources(&self) -> io::Result<Vec<PathBuf>> {
456        self.source
457            .home_instruction_sources(self.external_agent_home.as_path())
458    }
459
460    fn mcp_settings(
461        &self,
462        scope: &MigrationScope,
463        source_settings: Option<JsonValue>,
464    ) -> io::Result<Option<JsonValue>> {
465        if !scope.is_home() && source_settings.is_none() {
466            let home_scope = MigrationScope::home();
467            let home_settings = self.source_settings(&home_scope);
468            match self.effective_source_settings(&home_scope) {
469                Ok(settings) => Ok(settings),
470                Err(err) => {
471                    tracing::warn!(
472                        path = %home_settings.display(),
473                        error = %err,
474                        "ignoring invalid external agent home settings during repo MCP migration"
475                    );
476                    Ok(None)
477                }
478            }
479        } else {
480            Ok(source_settings)
481        }
482    }
483
484    pub(crate) fn source_root(&self, scope: &MigrationScope) -> PathBuf {
485        scope.repo_root().map_or_else(
486            || {
487                self.external_agent_home
488                    .parent()
489                    .map(Path::to_path_buf)
490                    .unwrap_or_else(|| PathBuf::from("."))
491            },
492            Path::to_path_buf,
493        )
494    }
495
496    fn import_config(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
497        let Some(scope) = MigrationScope::from_cwd(cwd)? else {
498            return Ok(None);
499        };
500        let source_settings = self.source_settings(&scope);
501        let target_config = match &scope {
502            MigrationScope::Home => self.codex_home.join("config.toml"),
503            MigrationScope::Repository { root } => root.join(".codex").join("config.toml"),
504        };
505        let Some(settings) = self.effective_source_settings(&scope)? else {
506            return Ok(None);
507        };
508        let migrated = self.source.build_config(&settings)?;
509        if is_empty_toml_table(&migrated) {
510            return Ok(None);
511        }
512
513        let Some(target_parent) = target_config.parent() else {
514            return Err(invalid_data_error("config target path has no parent"));
515        };
516        fs::create_dir_all(target_parent)?;
517        if !target_config.exists() {
518            write_toml_file(&target_config, &migrated)?;
519            return Ok(Some((
520                source_settings.display().to_string(),
521                target_config.display().to_string(),
522            )));
523        }
524
525        let existing_raw = fs::read_to_string(&target_config)?;
526        let mut existing = if existing_raw.trim().is_empty() {
527            TomlValue::Table(Default::default())
528        } else {
529            toml::from_str::<TomlValue>(&existing_raw)
530                .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
531        };
532
533        let changed = merge_missing_toml_values(&mut existing, &migrated)?;
534        if !changed {
535            return Ok(None);
536        }
537
538        write_toml_file(&target_config, &existing)?;
539        Ok(Some((
540            source_settings.display().to_string(),
541            target_config.display().to_string(),
542        )))
543    }
544
545    fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
546        let Some(scope) = MigrationScope::from_cwd(cwd)? else {
547            return Ok(Vec::new());
548        };
549        let target_config = match &scope {
550            MigrationScope::Home => self.codex_home.join("config.toml"),
551            MigrationScope::Repository { root } => root.join(".codex").join("config.toml"),
552        };
553        let settings = self.effective_source_settings(&scope)?;
554        let migrated = self.build_mcp_config(&scope, settings)?;
555        if is_empty_toml_table(&migrated) {
556            return Ok(Vec::new());
557        }
558
559        let Some(target_parent) = target_config.parent() else {
560            return Err(invalid_data_error("config target path has no parent"));
561        };
562        fs::create_dir_all(target_parent)?;
563        if !target_config.exists() {
564            let migrated_server_names = migrated_mcp_server_names(&migrated);
565            write_toml_file(&target_config, &migrated)?;
566            return Ok(migrated_server_names);
567        }
568
569        let existing_raw = fs::read_to_string(&target_config)?;
570        let mut existing = if existing_raw.trim().is_empty() {
571            TomlValue::Table(Default::default())
572        } else {
573            toml::from_str::<TomlValue>(&existing_raw)
574                .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
575        };
576        let merged_server_names = merge_missing_mcp_servers(&mut existing, &migrated)?;
577        if !merged_server_names.is_empty() {
578            write_toml_file(&target_config, &existing)?;
579        }
580        Ok(merged_server_names)
581    }
582
583    fn import_subagents(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
584        let Some(scope) = MigrationScope::from_cwd(cwd)? else {
585            return Ok(Vec::new());
586        };
587        let (source_agents, target_agents) = match scope {
588            MigrationScope::Home => (
589                self.external_agent_home.join("agents"),
590                self.codex_home.join("agents"),
591            ),
592            MigrationScope::Repository { root } => (
593                root.join(self.source.config_dir()).join("agents"),
594                root.join(".codex").join("agents"),
595            ),
596        };
597
598        self.source.import_subagents(&source_agents, &target_agents)
599    }
600
601    fn import_hooks(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
602        let Some(scope) = MigrationScope::from_cwd(cwd)? else {
603            return Ok(Vec::new());
604        };
605        let target_hooks = match &scope {
606            MigrationScope::Home => self.codex_home.join("hooks.json"),
607            MigrationScope::Repository { root } => root.join(".codex").join("hooks.json"),
608        };
609        let source_external_agent_dir = self.source_config_dir(&scope);
610
611        let hook_names = self
612            .source
613            .hook_event_names(&source_external_agent_dir, &target_hooks)?;
614        if self
615            .source
616            .import_hooks(&source_external_agent_dir, &target_hooks)?
617        {
618            Ok(hook_names)
619        } else {
620            Ok(Vec::new())
621        }
622    }
623
624    fn import_commands(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
625        let Some(scope) = MigrationScope::from_cwd(cwd)? else {
626            return Ok(Vec::new());
627        };
628        let (source_commands, target_skills) = match scope {
629            MigrationScope::Home => (
630                self.external_agent_home.join("commands"),
631                self.home_target_skills_dir(),
632            ),
633            MigrationScope::Repository { root } => (
634                root.join(self.source.config_dir()).join("commands"),
635                root.join(".agents").join("skills"),
636            ),
637        };
638
639        self.source
640            .import_commands(&source_commands, &target_skills)
641    }
642
643    fn import_skills(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
644        let Some(scope) = MigrationScope::from_cwd(cwd)? else {
645            return Ok(Vec::new());
646        };
647        let (source_skills, target_skills) = match scope {
648            MigrationScope::Home => (
649                self.external_agent_home.join("skills"),
650                self.home_target_skills_dir(),
651            ),
652            MigrationScope::Repository { root } => (
653                root.join(self.source.config_dir()).join("skills"),
654                root.join(".agents").join("skills"),
655            ),
656        };
657        if !source_skills.is_dir() {
658            return Ok(Vec::new());
659        }
660
661        fs::create_dir_all(&target_skills)?;
662        let mut copied_names = Vec::new();
663
664        for entry in fs::read_dir(&source_skills)? {
665            let entry = entry?;
666            let file_type = entry.file_type()?;
667            if !file_type.is_dir() {
668                continue;
669            }
670
671            let target = target_skills.join(entry.file_name());
672            if target.exists() {
673                continue;
674            }
675
676            copy_dir_recursive(&entry.path(), &target, self.source.rewrite_profile())?;
677            copied_names.push(entry.file_name().to_string_lossy().to_string());
678        }
679
680        Ok(copied_names)
681    }
682
683    fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
684        let Some(scope) = MigrationScope::from_cwd(cwd)? else {
685            return Ok(None);
686        };
687        let (source_agents_md, target_agents_md) = match scope {
688            MigrationScope::Repository { root } => {
689                let Some(group) = self
690                    .repo_agents_md_source_groups(&root)?
691                    .into_iter()
692                    .find(|group| group.scope == root)
693                else {
694                    return Ok(None);
695                };
696                let target_agents_md = group.scope.join("AGENTS.md");
697                (group.sources, target_agents_md)
698            }
699            MigrationScope::Home => {
700                let source_agents_md = self.home_agents_md_sources()?;
701                if source_agents_md.is_empty() {
702                    return Ok(None);
703                }
704                (source_agents_md, self.codex_home.join("AGENTS.md"))
705            }
706        };
707        if !is_missing_or_empty_text_file(&target_agents_md)? {
708            return Ok(None);
709        }
710
711        let Some(target_parent) = target_agents_md.parent() else {
712            return Err(invalid_data_error("AGENTS.md target path has no parent"));
713        };
714        fs::create_dir_all(target_parent)?;
715
716        let source_contents = source_agents_md
717            .iter()
718            .map(|source| {
719                self.source.read_instruction_source(source).map(|contents| {
720                    rewrite_external_agent_terms(&contents, self.source.rewrite_profile())
721                })
722            })
723            .collect::<io::Result<Vec<_>>>()?
724            .join("\n\n");
725        fs::write(&target_agents_md, source_contents)?;
726        Ok(Some((
727            display_source_paths(&source_agents_md),
728            target_agents_md.display().to_string(),
729        )))
730    }
731}
732
733fn default_external_agent_home(source: ExternalAgentSource) -> PathBuf {
734    if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
735        return PathBuf::from(home).join(source.config_dir());
736    }
737
738    PathBuf::from(source.config_dir())
739}
740
741pub(crate) fn configured_marketplace_plugins(
742    config: &Config,
743    plugins_manager: &PluginsManager,
744) -> io::Result<BTreeMap<String, HashSet<String>>> {
745    let plugins_input = config.plugins_config_input();
746    let marketplaces = plugins_manager
747        .list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
748        .map_err(|err| {
749            invalid_data_error(format!("failed to list configured marketplaces: {err}"))
750        })?;
751    let mut marketplace_plugins = BTreeMap::new();
752    for marketplace in marketplaces.marketplaces {
753        let plugins = marketplace
754            .plugins
755            .into_iter()
756            .filter(|plugin| {
757                plugin.policy.installation != MarketplacePluginInstallPolicy::NotAvailable
758            })
759            .filter(|plugin| {
760                plugin
761                    .policy
762                    .products
763                    .as_deref()
764                    .is_none_or(|products| Product::Codex.matches_product_restriction(products))
765            })
766            .map(|plugin| plugin.name)
767            .collect::<HashSet<_>>();
768        marketplace_plugins.insert(marketplace.name, plugins);
769    }
770    Ok(marketplace_plugins)
771}
772
773fn collect_subdirectory_names(path: &Path) -> io::Result<HashSet<OsString>> {
774    let mut names = HashSet::new();
775    if !path.is_dir() {
776        return Ok(names);
777    }
778
779    for entry in fs::read_dir(path)? {
780        let entry = entry?;
781        if entry.file_type()?.is_dir() {
782            names.insert(entry.file_name());
783        }
784    }
785
786    Ok(names)
787}
788
789pub(crate) fn missing_subdirectory_names(source: &Path, target: &Path) -> io::Result<Vec<String>> {
790    let source_names = collect_subdirectory_names(source)?;
791    let target_names = collect_subdirectory_names(target)?;
792    let mut missing_names = source_names
793        .into_iter()
794        .filter(|name| !target_names.contains(name))
795        .map(|name| name.to_string_lossy().into_owned())
796        .collect::<Vec<_>>();
797    missing_names.sort();
798    Ok(missing_names)
799}
800
801pub(crate) fn named_migrations(names: Vec<String>) -> Vec<NamedMigration> {
802    names
803        .into_iter()
804        .map(|name| NamedMigration { name })
805        .collect()
806}
807
808#[cfg(test)]
809#[path = "service_tests.rs"]
810mod tests;