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 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 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 None,
223 err.to_string(),
224 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 None,
238 err.to_string(),
239 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 None,
256 err.to_string(),
257 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 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 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 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 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 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, &[], 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;