Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<!-- devsync:start:name -->`)
- 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
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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/`
Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

</div>

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion devsync/ai_tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down
21 changes: 21 additions & 0 deletions devsync/ai_tools/capability_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
219 changes: 219 additions & 0 deletions devsync/ai_tools/codex.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- devsync:start:{name} -->"
END_MARKER = "<!-- devsync:end:{name} -->"
SECTION_PATTERN = r"<!-- devsync:start:{name} -->\n.*?\n<!-- devsync:end:{name} -->"


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:

<!-- devsync:start:instruction-name -->
... instruction content ...
<!-- devsync:end:instruction-name -->
"""

@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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route Codex instruction changes through section APIs

This tool now maps every instruction to the same file (AGENTS.md), but the main install/uninstall paths still treat installed_path as one-file-per-instruction (install_new._perform_installation writes the full file directly, and cli/uninstall.py unlinks it). In practice, installing multiple Codex instructions causes later installs to conflict/overwrite the whole file, and uninstalling one instruction can delete all Codex/manual content from AGENTS.md; this should use CodexTool.install_instruction/uninstall_instruction section logic instead of raw file writes/deletes.

Useful? React with 👍 / 👎.


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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape Codex overwrite replacement content

re.sub(pattern, section, content, flags=re.DOTALL) treats backslashes in section as backreferences, so overwrite fails or corrupts output when instruction content includes sequences like \1 (for example regex docs or Windows-style text), raising re.error: invalid group reference in real usage. Use a literal replacement strategy (e.g., function replacement) so instruction text is written verbatim.

Useful? React with 👍 / 👎.

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
3 changes: 3 additions & 0 deletions devsync/ai_tools/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -87,6 +89,7 @@ def get_primary_tool(self) -> Optional[AITool]:
AIToolType.KIRO,
AIToolType.CLINE,
AIToolType.ROO,
AIToolType.CODEX,
]

for tool_type in priority:
Expand Down
1 change: 1 addition & 0 deletions devsync/core/component_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
3 changes: 2 additions & 1 deletion devsync/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class AIToolType(Enum):
KIRO = "kiro"
CLINE = "cline"
ROO = "roo"
CODEX = "codex"


class ConflictResolution(Enum):
Expand Down Expand Up @@ -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}")

Expand Down
Loading
Loading