diff --git a/CHANGELOG.md b/CHANGELOG.md index aa030e9..5a64665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-02-14 + +### Added +- **OpenAI Codex CLI Support** - Added support for the OpenAI Codex CLI (#37) + - Instructions managed via section markers in `AGENTS.md` at project root + - Section-based install/uninstall using HTML comment markers (``) + - Multiple instructions coexist in a single file without conflicts + - Detection via `codex` binary on PATH + - Package system support for instructions and resources + - IDE capability registry entry for Codex CLI + - Component detector recognizes `AGENTS.md` files + ## [0.7.0] - 2026-02-14 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 8270355..7aec3dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ ai-config-kit/ │ ├── kiro.py # Kiro (.kiro/steering/*.md) │ ├── roo.py # Roo Code (.roo/rules/*.md) │ ├── winsurf.py # Windsurf (.windsurf/rules/*.md) +│ ├── codex.py # OpenAI Codex CLI (AGENTS.md sections) │ ├── copilot.py # GitHub Copilot (.github/instructions/*.md) │ └── detector.py # Tool detection logic ├── cli/ # Typer CLI commands @@ -395,6 +396,7 @@ aiconfig package uninstall package-name --yes Different IDEs support different component types: - **Claude Code**: All components (instructions, MCP, hooks, commands, resources) - **Cline**: Instructions and resources only +- **Codex CLI**: Instructions and resources only (via AGENTS.md sections) - **Cursor**: Instructions and resources only - **Kiro**: Instructions and resources only - **Roo Code**: Instructions, MCP, commands, and resources @@ -407,6 +409,7 @@ Unsupported components are automatically skipped and counted separately. Components are translated to IDE-specific formats: - **Claude Code**: `.md` files in `.claude/rules/`, `.claude/hooks/`, `.claude/commands/` - **Cline**: `.md` files in `.clinerules/` +- **Codex CLI**: Sections in `AGENTS.md` at project root (using HTML comment markers) - **Cursor**: `.mdc` files in `.cursor/rules/` - **Roo Code**: `.md` files in `.roo/rules/`, `.roo/commands/` - **Kiro**: `.md` files in `.kiro/steering/` diff --git a/README.md b/README.md index 2291c50..49377a4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -**Works with:** Claude Code • Claude Desktop • Cline • Cursor • GitHub Copilot • Kiro • Roo Code • Windsurf +**Works with:** Claude Code • Claude Desktop • Cline • Codex CLI • Cursor • GitHub Copilot • Kiro • Roo Code • Windsurf @@ -260,16 +260,16 @@ Any IDE-specific content from Git repositories: Complete configuration bundles with multiple component types: -| Component | Claude | Cline | Cursor | Kiro | Roo Code | Windsurf | Copilot | -|-----------|--------|-------|--------|------|----------|----------|---------| -| Instructions | `.claude/rules/` | `.clinerules/` | `.cursor/rules/` | `.kiro/steering/` | `.roo/rules/` | `.windsurf/rules/` | `.github/instructions/` | -| MCP Servers | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | -| Hooks | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | -| Commands | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| Skills | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | -| Workflows | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | -| Memory Files | ✅ (CLAUDE.md) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | -| Resources | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Component | Claude | Cline | Codex CLI | Cursor | Kiro | Roo Code | Windsurf | Copilot | +|-----------|--------|-------|----------|--------|------|----------|----------|---------| +| Instructions | `.claude/rules/` | `.clinerules/` | `AGENTS.md` | `.cursor/rules/` | `.kiro/steering/` | `.roo/rules/` | `.windsurf/rules/` | `.github/instructions/` | +| MCP Servers | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | +| Hooks | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Commands | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| Skills | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Workflows | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| Memory Files | ✅ (CLAUDE.md) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Resources | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ### MCP Server Configurations @@ -289,7 +289,7 @@ Model Context Protocol server setups for enhanced AI capabilities: - Python 3.10 or higher - Git (for cloning template repositories) -- One of: Claude Code, Claude Desktop, Cline, Cursor, GitHub Copilot, Kiro, Roo Code, or Windsurf +- One of: Claude Code, Claude Desktop, Cline, Codex CLI, Cursor, GitHub Copilot, Kiro, Roo Code, or Windsurf ### Install DevSync diff --git a/devsync/ai_tools/base.py b/devsync/ai_tools/base.py index 58c34a9..d0f8ac7 100644 --- a/devsync/ai_tools/base.py +++ b/devsync/ai_tools/base.py @@ -12,7 +12,7 @@ class AITool(ABC): """ Abstract base class for AI coding tool integrations. - Each AI tool (Cline, Cursor, Copilot, Kiro, Roo Code, Winsurf, Claude) implements this interface + Each AI tool (Cline, Codex, Cursor, Copilot, Kiro, Roo Code, Winsurf, Claude) implements this interface to provide tool-specific installation logic. """ diff --git a/devsync/ai_tools/capability_registry.py b/devsync/ai_tools/capability_registry.py index 33098b9..3a507fd 100644 --- a/devsync/ai_tools/capability_registry.py +++ b/devsync/ai_tools/capability_registry.py @@ -187,6 +187,27 @@ def supports_component(self, component_type: ComponentType) -> bool: "Slash commands in .roo/commands/. Global rules at ~/.roo/rules/." ), ), + AIToolType.CODEX: IDECapability( + tool_type=AIToolType.CODEX, + tool_name="OpenAI Codex CLI", + supported_components={ + ComponentType.INSTRUCTION, + ComponentType.RESOURCE, + }, + instructions_directory="", # AGENTS.md at project root + instruction_file_extension=".md", + supports_project_scope=True, + supports_global_scope=False, + mcp_config_path=None, + mcp_project_config_path=None, + hooks_directory=None, + commands_directory=None, + notes=( + "OpenAI Codex CLI uses a single AGENTS.md file at the project root. " + "DevSync manages sections within this file using HTML comment markers. " + "No MCP, hooks, or commands support." + ), + ), AIToolType.COPILOT: IDECapability( tool_type=AIToolType.COPILOT, tool_name="GitHub Copilot", diff --git a/devsync/ai_tools/codex.py b/devsync/ai_tools/codex.py new file mode 100644 index 0000000..0f55fc5 --- /dev/null +++ b/devsync/ai_tools/codex.py @@ -0,0 +1,219 @@ +"""OpenAI Codex CLI AI tool integration.""" + +import re +import shutil +from pathlib import Path +from typing import Optional + +from devsync.ai_tools.base import AITool +from devsync.core.models import AIToolType, InstallationScope, Instruction + +START_MARKER = "" +END_MARKER = "" +SECTION_PATTERN = r"\n.*?\n" + + +class CodexTool(AITool): + """Integration for OpenAI Codex CLI. + + Codex CLI reads a single AGENTS.md file at the project root. + DevSync manages individual instruction sections using HTML comment markers: + + + ... instruction content ... + + """ + + @property + def tool_type(self) -> AIToolType: + """Return the AI tool type identifier.""" + return AIToolType.CODEX + + @property + def tool_name(self) -> str: + """Return human-readable tool name.""" + return "OpenAI Codex CLI" + + def is_installed(self) -> bool: + """Check if OpenAI Codex CLI is installed on the system. + + Returns: + True if codex binary is found on PATH + """ + return shutil.which("codex") is not None + + def get_instructions_directory(self) -> Path: + """Get the directory where instructions should be installed. + + Raises: + NotImplementedError: Codex CLI only supports project-level installation + """ + raise NotImplementedError( + f"{self.tool_name} global installation is not supported. " + "OpenAI Codex CLI uses project-level AGENTS.md only. " + "Please use project-level installation instead (--scope project)." + ) + + def get_instruction_file_extension(self) -> str: + """Get the file extension for Codex instructions. + + Returns: + File extension including the dot + """ + return ".md" + + def get_project_instructions_directory(self, project_root: Path) -> Path: + """Get the directory for project-specific Codex instructions. + + AGENTS.md lives at the project root, so the directory is the root itself. + + Args: + project_root: Path to the project root directory + + Returns: + Path to project root (AGENTS.md lives at root) + """ + return project_root + + def get_instruction_path( + self, + instruction_name: str, + scope: InstallationScope = InstallationScope.GLOBAL, + project_root: Optional[Path] = None, + ) -> Path: + """Get the path to AGENTS.md. + + Always returns project_root / AGENTS.md regardless of instruction name. + + Args: + instruction_name: Name of the instruction (unused for path) + scope: Installation scope (must be PROJECT) + project_root: Project root path + + Returns: + Path to AGENTS.md + + Raises: + ValueError: If scope is PROJECT but project_root is None + NotImplementedError: If scope is GLOBAL + """ + if scope == InstallationScope.GLOBAL: + raise NotImplementedError( + f"{self.tool_name} global installation is not supported. " + "Please use project-level installation instead (--scope project)." + ) + if project_root is None: + raise ValueError("project_root is required for PROJECT scope") + return project_root / "AGENTS.md" + + def instruction_exists( + self, + instruction_name: str, + scope: InstallationScope = InstallationScope.GLOBAL, + project_root: Optional[Path] = None, + ) -> bool: + """Check if an instruction section exists in AGENTS.md. + + Args: + instruction_name: Name of the instruction + scope: Installation scope + project_root: Project root path + + Returns: + True if the instruction's section markers exist in AGENTS.md + """ + try: + path = self.get_instruction_path(instruction_name, scope, project_root) + if not path.exists(): + return False + content = path.read_text(encoding="utf-8") + start = START_MARKER.format(name=instruction_name) + return start in content + except (FileNotFoundError, ValueError, NotImplementedError): + return False + + def install_instruction( + self, + instruction: Instruction, + overwrite: bool = False, + scope: InstallationScope = InstallationScope.GLOBAL, + project_root: Optional[Path] = None, + ) -> Path: + """Install an instruction as a section in AGENTS.md. + + Appends a new section with markers, or replaces an existing section + if overwrite is True. + + Args: + instruction: Instruction to install + overwrite: Whether to overwrite existing section + scope: Installation scope + project_root: Project root path + + Returns: + Path to AGENTS.md + + Raises: + FileExistsError: If instruction section exists and overwrite=False + """ + path = self.get_instruction_path(instruction.name, scope, project_root) + + start = START_MARKER.format(name=instruction.name) + end = END_MARKER.format(name=instruction.name) + section = f"{start}\n{instruction.content}\n{end}" + + if path.exists(): + content = path.read_text(encoding="utf-8") + if start in content: + if not overwrite: + raise FileExistsError(f"Instruction already exists in AGENTS.md: {instruction.name}") + pattern = SECTION_PATTERN.format(name=re.escape(instruction.name)) + content = re.sub(pattern, section, content, flags=re.DOTALL) + path.write_text(content, encoding="utf-8") + return path + if content and not content.endswith("\n"): + content += "\n" + content += "\n" + section + "\n" + path.write_text(content, encoding="utf-8") + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(section + "\n", encoding="utf-8") + + return path + + def uninstall_instruction( + self, + instruction_name: str, + scope: InstallationScope = InstallationScope.GLOBAL, + project_root: Optional[Path] = None, + ) -> bool: + """Remove an instruction section from AGENTS.md. + + Args: + instruction_name: Name of instruction to remove + scope: Installation scope + project_root: Project root path + + Returns: + True if section was removed, False if it didn't exist + """ + try: + path = self.get_instruction_path(instruction_name, scope, project_root) + if not path.exists(): + return False + + content = path.read_text(encoding="utf-8") + start = START_MARKER.format(name=instruction_name) + if start not in content: + return False + + pattern = SECTION_PATTERN.format(name=re.escape(instruction_name)) + new_content = re.sub(pattern, "", content, flags=re.DOTALL) + # Clean up extra blank lines + new_content = re.sub(r"\n{3,}", "\n\n", new_content).strip() + if new_content: + new_content += "\n" + path.write_text(new_content, encoding="utf-8") + return True + except (FileNotFoundError, ValueError, NotImplementedError): + return False diff --git a/devsync/ai_tools/detector.py b/devsync/ai_tools/detector.py index 6b05460..a177a32 100644 --- a/devsync/ai_tools/detector.py +++ b/devsync/ai_tools/detector.py @@ -5,6 +5,7 @@ from devsync.ai_tools.base import AITool from devsync.ai_tools.claude import ClaudeTool from devsync.ai_tools.cline import ClineTool +from devsync.ai_tools.codex import CodexTool from devsync.ai_tools.copilot import CopilotTool from devsync.ai_tools.cursor import CursorTool from devsync.ai_tools.kiro import KiroTool @@ -26,6 +27,7 @@ def __init__(self) -> None: AIToolType.KIRO: KiroTool(), AIToolType.CLINE: ClineTool(), AIToolType.ROO: RooTool(), + AIToolType.CODEX: CodexTool(), } def detect_installed_tools(self) -> list[AITool]: @@ -87,6 +89,7 @@ def get_primary_tool(self) -> Optional[AITool]: AIToolType.KIRO, AIToolType.CLINE, AIToolType.ROO, + AIToolType.CODEX, ] for tool_type in priority: diff --git a/devsync/core/component_detector.py b/devsync/core/component_detector.py index eacd31c..8c2d151 100644 --- a/devsync/core/component_detector.py +++ b/devsync/core/component_detector.py @@ -241,6 +241,7 @@ class ComponentDetector: # Single-file instruction locations (not directories) SINGLE_INSTRUCTION_FILES = { ".github/copilot-instructions.md": "copilot", + "AGENTS.md": "codex", } INSTRUCTION_EXTENSIONS = {".md", ".mdc", ".instructions.md"} diff --git a/devsync/core/models.py b/devsync/core/models.py index 5ecee02..caaabe0 100644 --- a/devsync/core/models.py +++ b/devsync/core/models.py @@ -16,6 +16,7 @@ class AIToolType(Enum): KIRO = "kiro" CLINE = "cline" ROO = "roo" + CODEX = "codex" class ConflictResolution(Enum): @@ -416,7 +417,7 @@ def __post_init__(self) -> None: """Validate template file data.""" if not self.path: raise ValueError("Template file path cannot be empty") - valid_ides = ["all", "cursor", "claude", "windsurf", "copilot", "kiro", "cline", "roo"] + valid_ides = ["all", "cursor", "claude", "windsurf", "copilot", "kiro", "cline", "roo", "codex"] if self.ide not in valid_ides: raise ValueError(f"Invalid IDE type: {self.ide}. Must be one of {valid_ides}") diff --git a/pyproject.toml b/pyproject.toml index 19b7b63..869f881 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "devsync" -version = "0.7.0" +version = "0.8.0" description = "Distribute and sync dev tool configurations across teams" readme = "README.md" authors = [{ name = "Troy Larson", email = "troy@calvinware.com" }] requires-python = ">=3.10" license = { text = "MIT License" } -keywords = ["cli", "ai", "config", "mcp", "cursor", "copilot", "claude", "cline", "kiro", "roo", "windsurf"] +keywords = ["cli", "ai", "config", "mcp", "cursor", "copilot", "claude", "cline", "codex", "kiro", "roo", "windsurf"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", diff --git a/tests/unit/packages/test_capability_registry.py b/tests/unit/packages/test_capability_registry.py index 1117ebe..9a00816 100644 --- a/tests/unit/packages/test_capability_registry.py +++ b/tests/unit/packages/test_capability_registry.py @@ -70,6 +70,7 @@ def test_registry_contains_all_tools(self) -> None: assert AIToolType.KIRO in CAPABILITY_REGISTRY assert AIToolType.CLINE in CAPABILITY_REGISTRY assert AIToolType.ROO in CAPABILITY_REGISTRY + assert AIToolType.CODEX in CAPABILITY_REGISTRY def test_cursor_capabilities(self) -> None: """Test Cursor IDE capabilities.""" @@ -248,7 +249,7 @@ def test_get_supported_tools_for_instruction(self) -> None: tools = get_supported_tools_for_component(ComponentType.INSTRUCTION) # All tools support instructions - assert len(tools) == 7 + assert len(tools) == 8 assert AIToolType.CURSOR in tools assert AIToolType.CLAUDE in tools assert AIToolType.WINSURF in tools @@ -256,6 +257,7 @@ def test_get_supported_tools_for_instruction(self) -> None: assert AIToolType.KIRO in tools assert AIToolType.CLINE in tools assert AIToolType.ROO in tools + assert AIToolType.CODEX in tools def test_get_supported_tools_for_mcp_server(self) -> None: """Test getting tools that support MCP servers.""" @@ -290,14 +292,15 @@ def test_get_supported_tools_for_resource(self) -> None: """Test getting tools that support resources.""" tools = get_supported_tools_for_component(ComponentType.RESOURCE) - # Cursor, Claude, Windsurf, Kiro, Cline, and Roo Code support resources (not Copilot) - assert len(tools) == 6 + # Cursor, Claude, Windsurf, Kiro, Cline, Roo Code, and Codex support resources (not Copilot) + assert len(tools) == 7 assert AIToolType.CURSOR in tools assert AIToolType.CLAUDE in tools assert AIToolType.WINSURF in tools assert AIToolType.KIRO in tools assert AIToolType.CLINE in tools assert AIToolType.ROO in tools + assert AIToolType.CODEX in tools assert AIToolType.COPILOT not in tools # Instructions only def test_validate_component_support_true(self) -> None: diff --git a/tests/unit/test_ai_tools_codex.py b/tests/unit/test_ai_tools_codex.py new file mode 100644 index 0000000..4676ba9 --- /dev/null +++ b/tests/unit/test_ai_tools_codex.py @@ -0,0 +1,275 @@ +"""Tests for OpenAI Codex CLI AI tool integration.""" + +import pytest + +from devsync.ai_tools.codex import CodexTool +from devsync.core.models import AIToolType, InstallationScope, Instruction + + +@pytest.fixture +def codex_tool(): + """Create a Codex tool instance.""" + return CodexTool() + + +@pytest.fixture +def sample_instruction(): + """Create a sample instruction for testing.""" + return Instruction( + name="test-instruction", + description="Test instruction", + content="# Test Instruction\n\nThis is test content.", + file_path="test.md", + tags=["test"], + ) + + +@pytest.fixture +def second_instruction(): + """Create a second instruction for testing multi-section behavior.""" + return Instruction( + name="second-instruction", + description="Second instruction", + content="# Second Instruction\n\nMore content here.", + file_path="second.md", + tags=["test"], + ) + + +class TestCodexTool: + """Test suite for CodexTool.""" + + def test_tool_type(self, codex_tool: CodexTool) -> None: + assert codex_tool.tool_type == AIToolType.CODEX + + def test_tool_name(self, codex_tool: CodexTool) -> None: + assert codex_tool.tool_name == "OpenAI Codex CLI" + + def test_is_installed_when_present(self, codex_tool: CodexTool, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("devsync.ai_tools.codex.shutil.which", lambda cmd: "/usr/local/bin/codex") + assert codex_tool.is_installed() is True + + def test_is_installed_when_absent(self, codex_tool: CodexTool, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("devsync.ai_tools.codex.shutil.which", lambda cmd: None) + assert codex_tool.is_installed() is False + + def test_get_instructions_directory_raises_not_implemented(self, codex_tool: CodexTool) -> None: + with pytest.raises(NotImplementedError) as exc_info: + codex_tool.get_instructions_directory() + assert "global installation is not supported" in str(exc_info.value).lower() + + def test_get_instruction_file_extension(self, codex_tool: CodexTool) -> None: + assert codex_tool.get_instruction_file_extension() == ".md" + + def test_get_project_instructions_directory(self, codex_tool: CodexTool, temp_dir) -> None: # type: ignore[no-untyped-def] + project_root = temp_dir / "project" + project_root.mkdir() + assert codex_tool.get_project_instructions_directory(project_root) == project_root + + def test_get_instruction_path(self, codex_tool: CodexTool, temp_dir) -> None: # type: ignore[no-untyped-def] + project_root = temp_dir / "project" + project_root.mkdir() + path = codex_tool.get_instruction_path("test", scope=InstallationScope.PROJECT, project_root=project_root) + assert path == project_root / "AGENTS.md" + + def test_get_instruction_path_global_raises(self, codex_tool: CodexTool) -> None: + with pytest.raises(NotImplementedError): + codex_tool.get_instruction_path("test", scope=InstallationScope.GLOBAL) + + def test_get_instruction_path_no_project_root_raises(self, codex_tool: CodexTool) -> None: + with pytest.raises(ValueError): + codex_tool.get_instruction_path("test", scope=InstallationScope.PROJECT, project_root=None) + + def test_install_creates_agents_md( + self, codex_tool: CodexTool, temp_dir, sample_instruction: Instruction # type: ignore[no-untyped-def] + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + path = codex_tool.install_instruction( + sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root + ) + + assert path == project_root / "AGENTS.md" + assert path.exists() + content = path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "# Test Instruction" in content + + def test_install_appends_to_existing( + self, + codex_tool: CodexTool, + temp_dir, # type: ignore[no-untyped-def] + sample_instruction: Instruction, + second_instruction: Instruction, + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + codex_tool.install_instruction(sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + codex_tool.install_instruction(second_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + + content = (project_root / "AGENTS.md").read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "# Test Instruction" in content + assert "# Second Instruction" in content + + def test_install_existing_raises_without_overwrite( + self, codex_tool: CodexTool, temp_dir, sample_instruction: Instruction # type: ignore[no-untyped-def] + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + codex_tool.install_instruction(sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + + with pytest.raises(FileExistsError): + codex_tool.install_instruction( + sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root + ) + + def test_install_overwrite_replaces_section( + self, codex_tool: CodexTool, temp_dir, sample_instruction: Instruction # type: ignore[no-untyped-def] + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + codex_tool.install_instruction(sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + + updated = Instruction( + name="test-instruction", + description="Updated", + content="# Updated Content", + file_path="test.md", + ) + codex_tool.install_instruction( + updated, overwrite=True, scope=InstallationScope.PROJECT, project_root=project_root + ) + + content = (project_root / "AGENTS.md").read_text(encoding="utf-8") + assert "# Updated Content" in content + assert "# Test Instruction" not in content + assert content.count("") == 1 + + def test_install_overwrite_preserves_other_sections( + self, + codex_tool: CodexTool, + temp_dir, # type: ignore[no-untyped-def] + sample_instruction: Instruction, + second_instruction: Instruction, + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + codex_tool.install_instruction(sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + codex_tool.install_instruction(second_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + + updated = Instruction( + name="test-instruction", + description="Updated", + content="# Updated", + file_path="test.md", + ) + codex_tool.install_instruction( + updated, overwrite=True, scope=InstallationScope.PROJECT, project_root=project_root + ) + + content = (project_root / "AGENTS.md").read_text(encoding="utf-8") + assert "# Updated" in content + assert "# Second Instruction" in content + + def test_instruction_exists_true( + self, codex_tool: CodexTool, temp_dir, sample_instruction: Instruction # type: ignore[no-untyped-def] + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + codex_tool.install_instruction(sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + + assert ( + codex_tool.instruction_exists( + "test-instruction", scope=InstallationScope.PROJECT, project_root=project_root + ) + is True + ) + + def test_instruction_exists_false_no_file(self, codex_tool: CodexTool, temp_dir) -> None: # type: ignore[no-untyped-def] + project_root = temp_dir / "project" + project_root.mkdir() + + assert ( + codex_tool.instruction_exists("nonexistent", scope=InstallationScope.PROJECT, project_root=project_root) + is False + ) + + def test_instruction_exists_false_different_name( + self, codex_tool: CodexTool, temp_dir, sample_instruction: Instruction # type: ignore[no-untyped-def] + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + codex_tool.install_instruction(sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + + assert ( + codex_tool.instruction_exists("other-name", scope=InstallationScope.PROJECT, project_root=project_root) + is False + ) + + def test_uninstall_removes_section( + self, codex_tool: CodexTool, temp_dir, sample_instruction: Instruction # type: ignore[no-untyped-def] + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + codex_tool.install_instruction(sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + + result = codex_tool.uninstall_instruction( + "test-instruction", scope=InstallationScope.PROJECT, project_root=project_root + ) + + assert result is True + content = (project_root / "AGENTS.md").read_text(encoding="utf-8") + assert "" not in content + assert "# Test Instruction" not in content + + def test_uninstall_preserves_other_sections( + self, + codex_tool: CodexTool, + temp_dir, # type: ignore[no-untyped-def] + sample_instruction: Instruction, + second_instruction: Instruction, + ) -> None: + project_root = temp_dir / "project" + project_root.mkdir() + + codex_tool.install_instruction(sample_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + codex_tool.install_instruction(second_instruction, scope=InstallationScope.PROJECT, project_root=project_root) + + codex_tool.uninstall_instruction("test-instruction", scope=InstallationScope.PROJECT, project_root=project_root) + + content = (project_root / "AGENTS.md").read_text(encoding="utf-8") + assert "" not in content + assert "" in content + assert "# Second Instruction" in content + + def test_uninstall_nonexistent_returns_false(self, codex_tool: CodexTool, temp_dir) -> None: # type: ignore[no-untyped-def] + project_root = temp_dir / "project" + project_root.mkdir() + + result = codex_tool.uninstall_instruction( + "nonexistent", scope=InstallationScope.PROJECT, project_root=project_root + ) + assert result is False + + def test_uninstall_no_file_returns_false(self, codex_tool: CodexTool, temp_dir) -> None: # type: ignore[no-untyped-def] + project_root = temp_dir / "project" + project_root.mkdir() + + result = codex_tool.uninstall_instruction("test", scope=InstallationScope.PROJECT, project_root=project_root) + assert result is False + + def test_repr(self, codex_tool: CodexTool) -> None: + repr_str = repr(codex_tool) + assert "CodexTool" in repr_str + assert AIToolType.CODEX.value in repr_str diff --git a/tests/unit/test_ai_tools_detector.py b/tests/unit/test_ai_tools_detector.py index eba492b..cf367e7 100644 --- a/tests/unit/test_ai_tools_detector.py +++ b/tests/unit/test_ai_tools_detector.py @@ -76,6 +76,7 @@ def mock_all_tools_installed(monkeypatch, temp_dir): (home_dir / ".claude" / "rules").mkdir(parents=True) monkeypatch.setattr("devsync.utils.paths.get_home_directory", lambda: home_dir) + monkeypatch.setattr("devsync.ai_tools.codex.shutil.which", lambda cmd: "/usr/local/bin/codex") class TestAIToolDetector: @@ -83,7 +84,7 @@ class TestAIToolDetector: def test_init_creates_all_tools(self, detector): """Test that detector initializes with all supported tools.""" - assert len(detector.tools) == 7 + assert len(detector.tools) == 8 assert AIToolType.CURSOR in detector.tools assert AIToolType.COPILOT in detector.tools assert AIToolType.WINSURF in detector.tools @@ -91,6 +92,7 @@ def test_init_creates_all_tools(self, detector): assert AIToolType.KIRO in detector.tools assert AIToolType.CLINE in detector.tools assert AIToolType.ROO in detector.tools + assert AIToolType.CODEX in detector.tools def test_detect_installed_tools_none(self, temp_dir, monkeypatch): """Test detect_installed_tools when no tools are installed.""" @@ -108,7 +110,7 @@ def test_detect_installed_tools_all(self, mock_all_tools_installed): # Create fresh detector with mocked paths detector = AIToolDetector() installed = detector.detect_installed_tools() - assert len(installed) == 7 + assert len(installed) == 8 def test_get_tool_by_name_valid(self, detector): """Test get_tool_by_name with valid tool name.""" @@ -197,7 +199,7 @@ def test_is_any_tool_installed_false(self, temp_dir, monkeypatch): def test_get_tool_names(self, detector): """Test get_tool_names returns all tool names.""" names = detector.get_tool_names() - assert len(names) == 7 + assert len(names) == 8 assert "cursor" in names assert "copilot" in names assert "winsurf" in names @@ -205,6 +207,7 @@ def test_get_tool_names(self, detector): assert "kiro" in names assert "cline" in names assert "roo" in names + assert "codex" in names def test_validate_tool_name_valid(self, detector): """Test validate_tool_name with valid name.""" @@ -219,7 +222,7 @@ def test_get_detection_summary(self, mock_all_tools_installed): """Test get_detection_summary.""" detector = AIToolDetector() summary = detector.get_detection_summary() - assert len(summary) == 7 + assert len(summary) == 8 assert all(isinstance(v, bool) for v in summary.values()) def test_format_detection_summary(self, mock_all_tools_installed):