hatch-xclam 0.7.1.dev1__py3-none-any.whl → 0.7.1.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,10 @@ strategies with decorator registration following Hatchling patterns.
8
8
 
9
9
  import platform
10
10
  import json
11
+ import tomllib # Python 3.11+ built-in
12
+ import tomli_w # TOML writing
11
13
  from pathlib import Path
12
- from typing import Optional, Dict, Any
14
+ from typing import Optional, Dict, Any, TextIO
13
15
  import logging
14
16
 
15
17
  from .host_management import MCPHostStrategy, register_host_strategy
@@ -607,3 +609,172 @@ class GeminiHostStrategy(MCPHostStrategy):
607
609
  except Exception as e:
608
610
  logger.error(f"Failed to write Gemini configuration: {e}")
609
611
  return False
612
+
613
+
614
+ @register_host_strategy(MCPHostType.CODEX)
615
+ class CodexHostStrategy(MCPHostStrategy):
616
+ """Configuration strategy for Codex IDE with TOML support.
617
+
618
+ Codex uses TOML configuration at ~/.codex/config.toml with a unique
619
+ structure using [mcp_servers.<server-name>] tables.
620
+ """
621
+
622
+ def __init__(self):
623
+ self.config_format = "toml"
624
+ self._preserved_features = {} # Preserve [features] section
625
+
626
+ def get_config_path(self) -> Optional[Path]:
627
+ """Get Codex configuration path."""
628
+ return Path.home() / ".codex" / "config.toml"
629
+
630
+ def get_config_key(self) -> str:
631
+ """Codex uses 'mcp_servers' key (note: underscore, not camelCase)."""
632
+ return "mcp_servers"
633
+
634
+ def is_host_available(self) -> bool:
635
+ """Check if Codex is available by checking for config directory."""
636
+ codex_dir = Path.home() / ".codex"
637
+ return codex_dir.exists()
638
+
639
+ def validate_server_config(self, server_config: MCPServerConfig) -> bool:
640
+ """Codex validation - supports both STDIO and HTTP servers."""
641
+ return server_config.command is not None or server_config.url is not None
642
+
643
+ def read_configuration(self) -> HostConfiguration:
644
+ """Read Codex TOML configuration file."""
645
+ config_path = self.get_config_path()
646
+ if not config_path or not config_path.exists():
647
+ return HostConfiguration(servers={})
648
+
649
+ try:
650
+ with open(config_path, 'rb') as f:
651
+ toml_data = tomllib.load(f)
652
+
653
+ # Preserve [features] section for later write
654
+ self._preserved_features = toml_data.get('features', {})
655
+
656
+ # Extract MCP servers from [mcp_servers.*] tables
657
+ mcp_servers = toml_data.get(self.get_config_key(), {})
658
+
659
+ servers = {}
660
+ for name, server_data in mcp_servers.items():
661
+ try:
662
+ # Flatten nested env section if present
663
+ flat_data = self._flatten_toml_server(server_data)
664
+ servers[name] = MCPServerConfig(**flat_data)
665
+ except Exception as e:
666
+ logger.warning(f"Invalid server config for {name}: {e}")
667
+ continue
668
+
669
+ return HostConfiguration(servers=servers)
670
+
671
+ except Exception as e:
672
+ logger.error(f"Failed to read Codex configuration: {e}")
673
+ return HostConfiguration(servers={})
674
+
675
+ def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool:
676
+ """Write Codex TOML configuration file with backup support."""
677
+ config_path = self.get_config_path()
678
+ if not config_path:
679
+ return False
680
+
681
+ try:
682
+ config_path.parent.mkdir(parents=True, exist_ok=True)
683
+
684
+ # Read existing configuration to preserve non-MCP settings
685
+ existing_data = {}
686
+ if config_path.exists():
687
+ try:
688
+ with open(config_path, 'rb') as f:
689
+ existing_data = tomllib.load(f)
690
+ except Exception:
691
+ pass
692
+
693
+ # Preserve [features] section
694
+ if 'features' in existing_data:
695
+ self._preserved_features = existing_data['features']
696
+
697
+ # Convert servers to TOML structure
698
+ servers_data = {}
699
+ for name, server_config in config.servers.items():
700
+ servers_data[name] = self._to_toml_server(server_config)
701
+
702
+ # Build final TOML structure
703
+ final_data = {}
704
+
705
+ # Preserve [features] at top
706
+ if self._preserved_features:
707
+ final_data['features'] = self._preserved_features
708
+
709
+ # Add MCP servers
710
+ final_data[self.get_config_key()] = servers_data
711
+
712
+ # Preserve other top-level keys
713
+ for key, value in existing_data.items():
714
+ if key not in ('features', self.get_config_key()):
715
+ final_data[key] = value
716
+
717
+ # Use atomic write with TOML serializer
718
+ backup_manager = MCPHostConfigBackupManager()
719
+ atomic_ops = AtomicFileOperations()
720
+
721
+ def toml_serializer(data: Any, f: TextIO) -> None:
722
+ # tomli_w.dumps returns a string, write it to the file
723
+ toml_str = tomli_w.dumps(data)
724
+ f.write(toml_str)
725
+
726
+ atomic_ops.atomic_write_with_serializer(
727
+ file_path=config_path,
728
+ data=final_data,
729
+ serializer=toml_serializer,
730
+ backup_manager=backup_manager,
731
+ hostname="codex",
732
+ skip_backup=no_backup
733
+ )
734
+
735
+ return True
736
+
737
+ except Exception as e:
738
+ logger.error(f"Failed to write Codex configuration: {e}")
739
+ return False
740
+
741
+ def _flatten_toml_server(self, server_data: Dict[str, Any]) -> Dict[str, Any]:
742
+ """Flatten nested TOML server structure to flat dict.
743
+
744
+ TOML structure:
745
+ [mcp_servers.name]
746
+ command = "npx"
747
+ args = ["-y", "package"]
748
+ [mcp_servers.name.env]
749
+ VAR = "value"
750
+
751
+ Becomes:
752
+ {"command": "npx", "args": [...], "env": {"VAR": "value"}}
753
+
754
+ Also maps Codex-specific 'http_headers' to universal 'headers' field.
755
+ """
756
+ # TOML already parses nested tables into nested dicts
757
+ # So [mcp_servers.name.env] becomes {"env": {...}}
758
+ data = dict(server_data)
759
+
760
+ # Map Codex 'http_headers' to universal 'headers' for MCPServerConfig
761
+ if 'http_headers' in data:
762
+ data['headers'] = data.pop('http_headers')
763
+
764
+ return data
765
+
766
+ def _to_toml_server(self, server_config: MCPServerConfig) -> Dict[str, Any]:
767
+ """Convert MCPServerConfig to TOML-compatible dict structure.
768
+
769
+ Maps universal 'headers' field back to Codex-specific 'http_headers'.
770
+ """
771
+ data = server_config.model_dump(exclude_unset=True)
772
+
773
+ # Remove 'name' field as it's the table key in TOML
774
+ data.pop('name', None)
775
+
776
+ # Map universal 'headers' to Codex 'http_headers' for TOML
777
+ if 'headers' in data:
778
+ data['http_headers'] = data.pop('headers')
779
+
780
+ return data
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hatch-xclam
3
- Version: 0.7.1.dev1
3
+ Version: 0.7.1.dev3
4
4
  Summary: Package manager for the Cracking Shells ecosystem
5
5
  Author: Cracking Shells Team
6
6
  Project-URL: Homepage, https://github.com/CrackingShells/Hatch
@@ -17,6 +17,7 @@ Requires-Dist: packaging>=20.0
17
17
  Requires-Dist: docker>=7.1.0
18
18
  Requires-Dist: pydantic>=2.0.0
19
19
  Requires-Dist: hatch-validator>=0.8.0
20
+ Requires-Dist: tomli-w>=1.0.0
20
21
  Provides-Extra: docs
21
22
  Requires-Dist: mkdocs>=1.4.0; extra == "docs"
22
23
  Requires-Dist: mkdocstrings[python]>=0.20.0; extra == "docs"
@@ -30,7 +31,7 @@ Dynamic: license-file
30
31
 
31
32
  ## Introduction
32
33
 
33
- Hatch is the package manager for managing Model Context Protocol (MCP) servers with environment isolation, multi-type dependency resolution, and multi-host deployment. Deploy MCP servers to Claude Desktop, VS Code, Cursor, Kiro, and other platforms with automatic dependency management.
34
+ Hatch is the package manager for managing Model Context Protocol (MCP) servers with environment isolation, multi-type dependency resolution, and multi-host deployment. Deploy MCP servers to Claude Desktop, VS Code, Cursor, Kiro, Codex, and other platforms with automatic dependency management.
34
35
 
35
36
  The canonical documentation is at `docs/index.md` and published at <https://hatch.readthedocs.io/en/latest/>.
36
37
 
@@ -38,7 +39,7 @@ The canonical documentation is at `docs/index.md` and published at <https://hatc
38
39
 
39
40
  - **Environment Isolation** — Create separate, isolated workspaces for different projects without conflicts
40
41
  - **Multi-Type Dependency Resolution** — Automatically resolve and install system packages, Python packages, Docker containers, and Hatch packages
41
- - **Multi-Host Deployment** — Deploy MCP servers to Claude Desktop, Claude Code, VS Code, Cursor, Kiro, LM Studio, and Google Gemini CLI
42
+ - **Multi-Host Deployment** — Configure MCP servers on multiple host platforms
42
43
  - **Package Validation** — Ensure packages meet schema requirements before distribution
43
44
  - **Development-Focused** — Optimized for rapid development and testing of MCP server ecosystems
44
45
 
@@ -51,6 +52,7 @@ Hatch supports deployment to the following MCP host platforms:
51
52
  - **VS Code** — Visual Studio Code with the MCP extension for tool integration
52
53
  - **Cursor** — AI-first code editor with built-in MCP server support
53
54
  - **Kiro** — Kiro IDE with MCP support
55
+ - **Codex** — OpenAI Codex with MCP server configuration support
54
56
  - **LM Studio** — Local LLM inference platform with MCP server integration
55
57
  - **Google Gemini CLI** — Command-line interface for Google's Gemini model with MCP support
56
58
 
@@ -1,5 +1,5 @@
1
1
  hatch/__init__.py,sha256=5JFQZiaZQewEWg8WktQKEdT8IeH0KstndZf27VH7sq4,594
2
- hatch/cli_hatch.py,sha256=mDWKVA8Cg8lpXElkJbj8-7xyzI1AiJk3nlp2kOtpwPk,109150
2
+ hatch/cli_hatch.py,sha256=Z7-N0DdWT1xK23EL0Gr5mx2wjqhDFzFROt1nUxPAnZM,112093
3
3
  hatch/environment_manager.py,sha256=9R9PJYPKQLmWeGXBrOzXxty20la33LgCCYY8o2aMFBQ,60757
4
4
  hatch/package_loader.py,sha256=Sa2JIoio1QlMT2tOGwZhC6pFJIs419cYyoodzyaTDl4,11269
5
5
  hatch/python_environment_manager.py,sha256=guU3zz4_WG3ptuX_ATGCRIi_fDxNHlaQtMv3kiRSo8k,28894
@@ -15,13 +15,13 @@ hatch/installers/installer_base.py,sha256=mId6Q_DLOQPZriq3wu3BCU-ckouom3EZgbWJQq
15
15
  hatch/installers/python_installer.py,sha256=MS9Q8wKjMAy7MEWk7zcAAiFgN0KzOVJFmMzXt1MSH8g,13632
16
16
  hatch/installers/registry.py,sha256=ZOEEMJy_kL5LVj5Mf7s1_CIovDnUVag6nB01dEU9Xeg,6831
17
17
  hatch/installers/system_installer.py,sha256=bdrmw3I9g2EU2E94-4vtJj01RhmekX9GxylU1RPT3Lk,22869
18
- hatch/mcp_host_config/__init__.py,sha256=YZ9LEbR5E5p6padhPt114wvcsLCz16c2RPduhvvsj9I,1727
19
- hatch/mcp_host_config/backup.py,sha256=vl2YLL0P0bobGvx5moSAe4wI47vp25d-NpwNO95J3zU,16875
18
+ hatch/mcp_host_config/__init__.py,sha256=STHzYwcyO6blKSwcMRibcD_4VKHgpEjru-uY1OQM9yA,1781
19
+ hatch/mcp_host_config/backup.py,sha256=X6wnLkPFYv4e5ObrWQSgQ_a_6rcmsFgysQwBZF_osWM,17831
20
20
  hatch/mcp_host_config/host_management.py,sha256=sXyGluFQpfXKggxAVvV9riGRis29JnoEM2dTWSIwb24,23905
21
- hatch/mcp_host_config/models.py,sha256=OM5sKtQF-1hylXijNNRctKpHFQbPA88C3DorwyhGv20,25710
21
+ hatch/mcp_host_config/models.py,sha256=1Nd3PDyGGbBm4oTgMEQ67j4b_qy23iibv9N4Tm4d9oE,29110
22
22
  hatch/mcp_host_config/reporting.py,sha256=Q8UKBJRfvJTbb5PM9xwLEOh3OJjf19AKpWKxs-2622k,6889
23
- hatch/mcp_host_config/strategies.py,sha256=WyelujfNtP0Y-KP5hS6u5Z9QSbMWcIxSv30nBQ1Ug7o,23998
24
- hatch_xclam-0.7.1.dev1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
23
+ hatch/mcp_host_config/strategies.py,sha256=NdA8hcbAi5xGkFRy51csmdEJESg25L8JkwTDVg2AeMw,30302
24
+ hatch_xclam-0.7.1.dev3.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
25
25
  tests/__init__.py,sha256=4I3aQWv143Y1QY_nRIBWnY9MIL-aoQOJuVlpoPQz24E,53
26
26
  tests/run_environment_tests.py,sha256=bWCr8UsPgU80SM8f_VSi0TCwDI6JNqZpvZ2W9-b2Lqk,7302
27
27
  tests/test_cli_version.py,sha256=lU8TBZfzn_8AenFNXYrLMARht91fI5twBN13L-iJebc,4778
@@ -33,9 +33,9 @@ tests/test_hatch_installer.py,sha256=qHCEcKpbe0fHvkzib2rcUJUw1Z-PCRBBJhbjoyOYiVM
33
33
  tests/test_installer_base.py,sha256=0xZiPDMf8LeFJs2SmnYhbIgL7mgvG_HKwL1myQovEZI,11818
34
34
  tests/test_mcp_atomic_operations.py,sha256=QmwUDRNZUz6b2i50yRAMBntRDaMMYZt6flHFJfVkzNE,10563
35
35
  tests/test_mcp_backup_integration.py,sha256=Auw6Bx1EXGwmCA-mRIy31DRLuRWyV33eB7GJiOvIPXQ,12360
36
- tests/test_mcp_cli_all_host_specific_args.py,sha256=xLmWCgvupfYo5Z107rYyuz4VJdgWFAWBAw7eKokSscw,11302
36
+ tests/test_mcp_cli_all_host_specific_args.py,sha256=hvXUetFyoZFtHsEjpzZ0GNdEuvci3Hv8P8thVjBPm-A,18748
37
37
  tests/test_mcp_cli_backup_management.py,sha256=GlUUNu5K1w8S2jTQ6YISp5KeXH5c-H9F0lJX2SG0JWM,14019
38
- tests/test_mcp_cli_direct_management.py,sha256=NOlRGP7M3CJ6dzR9JNtqIswJaCT8WIOoy2PhKXlGqSM,21906
38
+ tests/test_mcp_cli_direct_management.py,sha256=7GRwHXezmImCUfpHhVjvSkGfT-OBi1tMMLgExMXjals,22184
39
39
  tests/test_mcp_cli_discovery_listing.py,sha256=kdrCU6POLyGW9ejowNV-dUVDFVseMd_vibvgIDjZUCM,26595
40
40
  tests/test_mcp_cli_host_config_integration.py,sha256=dD6maHP0wHWnFZwxJ5LgSK1GsrYqB4WdicZkotqIANo,32512
41
41
  tests/test_mcp_cli_package_management.py,sha256=YFMhyh3dueel1f2R5_VMNr9AewDmVrqGbU1kj5bhdeo,14590
@@ -59,6 +59,9 @@ tests/test_system_installer.py,sha256=bWuyEKakhvi51iM8xHJh62zv83HUTd8QnPlqUUMWx9
59
59
  tests/integration/__init__.py,sha256=2mG53dv1VqjxZYHuglneK9VgDMoWCxpfmPByXXd3zVM,125
60
60
  tests/integration/test_mcp_kiro_integration.py,sha256=9y2XPacd3Y6zkqUUcCDjzj8Jv2Humby2LZ5CLSeFYCg,5566
61
61
  tests/regression/__init__.py,sha256=0pFnFuEaMf7gPFFXMv-b_vNRNyLV-wU2lYspZHFH_Uo,127
62
+ tests/regression/test_mcp_codex_backup_integration.py,sha256=15G8tCPFtukEcZvE5NBI8BvxlD27KwmE3z8-8ZZ3GxM,7107
63
+ tests/regression/test_mcp_codex_host_strategy.py,sha256=6ehG8IruT9U-fO7kDMoJfogXbpq7JV4XYl-Ho68nt1U,6413
64
+ tests/regression/test_mcp_codex_model_validation.py,sha256=LkGexJrDusxRg_5Bpsm7sXRt7LYwhqqj4Y7VIynslyw,4512
62
65
  tests/regression/test_mcp_kiro_backup_integration.py,sha256=oBEnSSLrnHIurkrBtjSG-HT6DyODV2z9tZeZotnsR1k,9584
63
66
  tests/regression/test_mcp_kiro_cli_integration.py,sha256=jDQE73yJTRb2e6MPShxLHmLDtN5VBMHMAUBojPEnVBI,5123
64
67
  tests/regression/test_mcp_kiro_decorator_registration.py,sha256=_H9FdKdKCv__IYBS0tfnZGUP574KoDjqKInmFiDEKPc,2556
@@ -95,8 +98,8 @@ tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py,sha256=_B5aqX
95
98
  tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py,sha256=rinhVySJpjXKd2sRCS0ps7xTrVqImWcZ8l4aYbidYR8,238
96
99
  tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py,sha256=FT14llzHlA4i8I__8GugzBRowhg_CbLmsOwjq0IWFsY,368
97
100
  tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py,sha256=BRPAyyAseE2CGR3W647SwjlluYfi7ejhZck0An5581I,467
98
- hatch_xclam-0.7.1.dev1.dist-info/METADATA,sha256=SMSh9ptwXOL_vYr8I881UViJS5LbPXXh6B9nXGsmfek,5901
99
- hatch_xclam-0.7.1.dev1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
100
- hatch_xclam-0.7.1.dev1.dist-info/entry_points.txt,sha256=6xbkwFUtr7nRa56vUFMyJk2wjwFQ_XVaU53ruecWKI0,47
101
- hatch_xclam-0.7.1.dev1.dist-info/top_level.txt,sha256=GZP3Ivciwal8jVITQkQr7dSNlLJRzfNOhA76VN7Jp4Y,12
102
- hatch_xclam-0.7.1.dev1.dist-info/RECORD,,
101
+ hatch_xclam-0.7.1.dev3.dist-info/METADATA,sha256=-QwerIq3PVxHdoIVq32lneCmCgh2kQBHm9N1jrIAIdY,5947
102
+ hatch_xclam-0.7.1.dev3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
103
+ hatch_xclam-0.7.1.dev3.dist-info/entry_points.txt,sha256=6xbkwFUtr7nRa56vUFMyJk2wjwFQ_XVaU53ruecWKI0,47
104
+ hatch_xclam-0.7.1.dev3.dist-info/top_level.txt,sha256=GZP3Ivciwal8jVITQkQr7dSNlLJRzfNOhA76VN7Jp4Y,12
105
+ hatch_xclam-0.7.1.dev3.dist-info/RECORD,,
@@ -0,0 +1,162 @@
1
+ """
2
+ Codex MCP Backup Integration Tests
3
+
4
+ Tests for Codex TOML backup integration including backup creation,
5
+ restoration, and the no_backup parameter.
6
+ """
7
+
8
+ import unittest
9
+ import tempfile
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ from wobble.decorators import regression_test
14
+
15
+ from hatch.mcp_host_config.strategies import CodexHostStrategy
16
+ from hatch.mcp_host_config.models import MCPServerConfig, HostConfiguration
17
+ from hatch.mcp_host_config.backup import MCPHostConfigBackupManager, BackupInfo
18
+
19
+
20
+ class TestCodexBackupIntegration(unittest.TestCase):
21
+ """Test suite for Codex backup integration."""
22
+
23
+ def setUp(self):
24
+ """Set up test environment."""
25
+ self.strategy = CodexHostStrategy()
26
+
27
+ @regression_test
28
+ def test_write_configuration_creates_backup_by_default(self):
29
+ """Test that write_configuration creates backup by default when file exists."""
30
+ with tempfile.TemporaryDirectory() as tmpdir:
31
+ config_path = Path(tmpdir) / "config.toml"
32
+ backup_dir = Path(tmpdir) / "backups"
33
+
34
+ # Create initial config
35
+ initial_toml = """[mcp_servers.old-server]
36
+ command = "old-command"
37
+ """
38
+ config_path.write_text(initial_toml)
39
+
40
+ # Create new configuration
41
+ new_config = HostConfiguration(servers={
42
+ 'new-server': MCPServerConfig(
43
+ command='new-command',
44
+ args=['--test']
45
+ )
46
+ })
47
+
48
+ # Patch paths
49
+ from unittest.mock import patch
50
+ with patch.object(self.strategy, 'get_config_path', return_value=config_path):
51
+ with patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') as MockBackupManager:
52
+ # Create a real backup manager with custom backup dir
53
+ real_backup_manager = MCPHostConfigBackupManager(backup_root=backup_dir)
54
+ MockBackupManager.return_value = real_backup_manager
55
+
56
+ # Write configuration (should create backup)
57
+ success = self.strategy.write_configuration(new_config, no_backup=False)
58
+ self.assertTrue(success)
59
+
60
+ # Verify backup was created
61
+ backup_files = list(backup_dir.glob('codex/*.toml.*'))
62
+ self.assertGreater(len(backup_files), 0, "Backup file should be created")
63
+
64
+ @regression_test
65
+ def test_write_configuration_skips_backup_when_requested(self):
66
+ """Test that write_configuration skips backup when no_backup=True."""
67
+ with tempfile.TemporaryDirectory() as tmpdir:
68
+ config_path = Path(tmpdir) / "config.toml"
69
+ backup_dir = Path(tmpdir) / "backups"
70
+
71
+ # Create initial config
72
+ initial_toml = """[mcp_servers.old-server]
73
+ command = "old-command"
74
+ """
75
+ config_path.write_text(initial_toml)
76
+
77
+ # Create new configuration
78
+ new_config = HostConfiguration(servers={
79
+ 'new-server': MCPServerConfig(
80
+ command='new-command'
81
+ )
82
+ })
83
+
84
+ # Patch paths
85
+ from unittest.mock import patch
86
+ with patch.object(self.strategy, 'get_config_path', return_value=config_path):
87
+ with patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') as MockBackupManager:
88
+ real_backup_manager = MCPHostConfigBackupManager(backup_root=backup_dir)
89
+ MockBackupManager.return_value = real_backup_manager
90
+
91
+ # Write configuration with no_backup=True
92
+ success = self.strategy.write_configuration(new_config, no_backup=True)
93
+ self.assertTrue(success)
94
+
95
+ # Verify no backup was created
96
+ if backup_dir.exists():
97
+ backup_files = list(backup_dir.glob('codex/*.toml.*'))
98
+ self.assertEqual(len(backup_files), 0, "No backup should be created when no_backup=True")
99
+
100
+ @regression_test
101
+ def test_write_configuration_no_backup_for_new_file(self):
102
+ """Test that no backup is created when writing to a new file."""
103
+ with tempfile.TemporaryDirectory() as tmpdir:
104
+ config_path = Path(tmpdir) / "config.toml"
105
+ backup_dir = Path(tmpdir) / "backups"
106
+
107
+ # Don't create initial file - this is a new file
108
+
109
+ # Create new configuration
110
+ new_config = HostConfiguration(servers={
111
+ 'new-server': MCPServerConfig(
112
+ command='new-command'
113
+ )
114
+ })
115
+
116
+ # Patch paths
117
+ from unittest.mock import patch
118
+ with patch.object(self.strategy, 'get_config_path', return_value=config_path):
119
+ with patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') as MockBackupManager:
120
+ real_backup_manager = MCPHostConfigBackupManager(backup_root=backup_dir)
121
+ MockBackupManager.return_value = real_backup_manager
122
+
123
+ # Write configuration to new file
124
+ success = self.strategy.write_configuration(new_config, no_backup=False)
125
+ self.assertTrue(success)
126
+
127
+ # Verify file was created
128
+ self.assertTrue(config_path.exists())
129
+
130
+ # Verify no backup was created (nothing to backup)
131
+ if backup_dir.exists():
132
+ backup_files = list(backup_dir.glob('codex/*.toml.*'))
133
+ self.assertEqual(len(backup_files), 0, "No backup for new file")
134
+
135
+ @regression_test
136
+ def test_codex_hostname_supported_in_backup_system(self):
137
+ """Test that 'codex' hostname is supported by the backup system."""
138
+ with tempfile.TemporaryDirectory() as tmpdir:
139
+ config_path = Path(tmpdir) / "config.toml"
140
+ backup_dir = Path(tmpdir) / "backups"
141
+
142
+ # Create a config file
143
+ config_path.write_text("[mcp_servers.test]\ncommand = 'test'\n")
144
+
145
+ # Create backup manager
146
+ backup_manager = MCPHostConfigBackupManager(backup_root=backup_dir)
147
+
148
+ # Create backup with 'codex' hostname - should not raise validation error
149
+ result = backup_manager.create_backup(config_path, 'codex')
150
+
151
+ # Verify backup succeeded
152
+ self.assertTrue(result.success, "Backup with 'codex' hostname should succeed")
153
+ self.assertIsNotNone(result.backup_path)
154
+
155
+ # Verify backup filename follows pattern
156
+ backup_filename = result.backup_path.name
157
+ self.assertTrue(backup_filename.startswith('config.toml.codex.'))
158
+
159
+
160
+ if __name__ == '__main__':
161
+ unittest.main()
162
+
@@ -0,0 +1,163 @@
1
+ """
2
+ Codex MCP Host Strategy Tests
3
+
4
+ Tests for CodexHostStrategy implementation including path resolution,
5
+ configuration read/write, TOML handling, and host detection.
6
+ """
7
+
8
+ import unittest
9
+ import tempfile
10
+ import tomllib
11
+ from unittest.mock import patch, mock_open, MagicMock
12
+ from pathlib import Path
13
+
14
+ from wobble.decorators import regression_test
15
+
16
+ from hatch.mcp_host_config.strategies import CodexHostStrategy
17
+ from hatch.mcp_host_config.models import MCPServerConfig, HostConfiguration
18
+
19
+ # Import test data loader from local tests module
20
+ import sys
21
+ from pathlib import Path
22
+ sys.path.insert(0, str(Path(__file__).parent.parent))
23
+ from test_data_utils import MCPHostConfigTestDataLoader
24
+
25
+
26
+ class TestCodexHostStrategy(unittest.TestCase):
27
+ """Test suite for CodexHostStrategy implementation."""
28
+
29
+ def setUp(self):
30
+ """Set up test environment."""
31
+ self.strategy = CodexHostStrategy()
32
+ self.test_data_loader = MCPHostConfigTestDataLoader()
33
+
34
+ @regression_test
35
+ def test_codex_config_path_resolution(self):
36
+ """Test Codex configuration path resolution."""
37
+ config_path = self.strategy.get_config_path()
38
+
39
+ # Verify path structure (use normalized path for cross-platform compatibility)
40
+ self.assertIsNotNone(config_path)
41
+ normalized_path = str(config_path).replace('\\', '/')
42
+ self.assertTrue(normalized_path.endswith('.codex/config.toml'))
43
+ self.assertEqual(config_path.name, 'config.toml')
44
+ self.assertEqual(config_path.suffix, '.toml') # Verify TOML extension
45
+
46
+ @regression_test
47
+ def test_codex_config_key(self):
48
+ """Test Codex configuration key."""
49
+ config_key = self.strategy.get_config_key()
50
+ # Codex uses underscore, not camelCase
51
+ self.assertEqual(config_key, "mcp_servers")
52
+ self.assertNotEqual(config_key, "mcpServers") # Verify different from other hosts
53
+
54
+ @regression_test
55
+ def test_codex_server_config_validation_stdio(self):
56
+ """Test Codex STDIO server configuration validation."""
57
+ # Test local server validation
58
+ local_config = MCPServerConfig(
59
+ command="npx",
60
+ args=["-y", "package"]
61
+ )
62
+ self.assertTrue(self.strategy.validate_server_config(local_config))
63
+
64
+ @regression_test
65
+ def test_codex_server_config_validation_http(self):
66
+ """Test Codex HTTP server configuration validation."""
67
+ # Test remote server validation
68
+ remote_config = MCPServerConfig(
69
+ url="https://api.example.com/mcp"
70
+ )
71
+ self.assertTrue(self.strategy.validate_server_config(remote_config))
72
+
73
+ @patch('pathlib.Path.exists')
74
+ @regression_test
75
+ def test_codex_host_availability_detection(self, mock_exists):
76
+ """Test Codex host availability detection."""
77
+ # Test when Codex directory exists
78
+ mock_exists.return_value = True
79
+ self.assertTrue(self.strategy.is_host_available())
80
+
81
+ # Test when Codex directory doesn't exist
82
+ mock_exists.return_value = False
83
+ self.assertFalse(self.strategy.is_host_available())
84
+
85
+ @regression_test
86
+ def test_codex_read_configuration_success(self):
87
+ """Test successful Codex TOML configuration reading."""
88
+ # Load test data
89
+ test_toml_path = Path(__file__).parent.parent / "test_data" / "codex" / "valid_config.toml"
90
+
91
+ with patch.object(self.strategy, 'get_config_path', return_value=test_toml_path):
92
+ config = self.strategy.read_configuration()
93
+
94
+ # Verify configuration was read
95
+ self.assertIsInstance(config, HostConfiguration)
96
+ self.assertIn('context7', config.servers)
97
+
98
+ # Verify server details
99
+ server = config.servers['context7']
100
+ self.assertEqual(server.command, 'npx')
101
+ self.assertEqual(server.args, ['-y', '@upstash/context7-mcp'])
102
+
103
+ # Verify nested env section was parsed correctly
104
+ self.assertIsNotNone(server.env)
105
+ self.assertEqual(server.env.get('MY_VAR'), 'value')
106
+
107
+ @regression_test
108
+ def test_codex_read_configuration_file_not_exists(self):
109
+ """Test Codex configuration reading when file doesn't exist."""
110
+ non_existent_path = Path("/non/existent/path/config.toml")
111
+
112
+ with patch.object(self.strategy, 'get_config_path', return_value=non_existent_path):
113
+ config = self.strategy.read_configuration()
114
+
115
+ # Should return empty configuration without error
116
+ self.assertIsInstance(config, HostConfiguration)
117
+ self.assertEqual(len(config.servers), 0)
118
+
119
+ @regression_test
120
+ def test_codex_write_configuration_preserves_features(self):
121
+ """Test that write_configuration preserves [features] section."""
122
+ with tempfile.TemporaryDirectory() as tmpdir:
123
+ config_path = Path(tmpdir) / "config.toml"
124
+
125
+ # Create initial config with features section
126
+ initial_toml = """[features]
127
+ rmcp_client = true
128
+
129
+ [mcp_servers.existing]
130
+ command = "old-command"
131
+ """
132
+ config_path.write_text(initial_toml)
133
+
134
+ # Create new configuration to write
135
+ new_config = HostConfiguration(servers={
136
+ 'new-server': MCPServerConfig(
137
+ command='new-command',
138
+ args=['--test']
139
+ )
140
+ })
141
+
142
+ # Write configuration
143
+ with patch.object(self.strategy, 'get_config_path', return_value=config_path):
144
+ success = self.strategy.write_configuration(new_config, no_backup=True)
145
+ self.assertTrue(success)
146
+
147
+ # Read back and verify features section preserved
148
+ with open(config_path, 'rb') as f:
149
+ result_data = tomllib.load(f)
150
+
151
+ # Verify features section preserved
152
+ self.assertIn('features', result_data)
153
+ self.assertTrue(result_data['features'].get('rmcp_client'))
154
+
155
+ # Verify new server added
156
+ self.assertIn('mcp_servers', result_data)
157
+ self.assertIn('new-server', result_data['mcp_servers'])
158
+ self.assertEqual(result_data['mcp_servers']['new-server']['command'], 'new-command')
159
+
160
+
161
+ if __name__ == '__main__':
162
+ unittest.main()
163
+