From 10530479d0852dc98688e807153aecac614b1d75 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Tue, 7 Jul 2026 08:40:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20mycodex=20?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=8C=E6=94=AF=E6=8C=81=20OpenAI=20Codex?= =?UTF-8?q?=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 launcher 中注册 codex 后端,兼容 ~/.codex/sessions 日期嵌套目录 - 解析 session_meta.payload.cwd 过滤当前目录会话 - 添加 myagents codex 子命令与 mycodex 独立入口、补全及 Makefile 链接 - 更新 pyproject.toml 控制台脚本与 README 文档 - 补充 codex 相关单元测试,45 tests passed --- Makefile | 2 +- README.md | 14 ++- myagents/cli.py | 10 +- myagents/commands/completion.py | 6 +- myagents/commands/completion_install.py | 3 +- myagents/entrypoints.py | 22 +++- myagents/launcher.py | 97 ++++++++++++++++- pyproject.toml | 1 + scripts/rm_user_local_myagents.py | 4 +- tests/test_cli.py | 138 +++++++++++++++++++++++- tests/test_entrypoints.py | 37 ++++++- 11 files changed, 311 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index 2b5feec..0778b68 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ ROOT_DIR := $(shell pwd) VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin USER_BIN_DIR := $(HOME)/.local/bin -COMMANDS := myagents myclaude mykimi +COMMANDS := myagents myclaude mykimi mycodex .PHONY: help install uninstall _symlink-commands diff --git a/README.md b/README.md index 6189871..9e7f2ce 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # MyAgents -一个统一的 AI coding agent 启动器,封装 Claude Code、Kimi Code CLI 等工具。 +一个统一的 AI coding agent 启动器,封装 Claude Code、Kimi Code CLI、OpenAI Codex 等工具。 ## 功能 -- 🤖 **多 Agent 统一入口**:`myagents claude` / `myagents kimi` -- 🚀 **独立快捷命令**:`myclaude`、`mykimi` +- 🤖 **多 Agent 统一入口**:`myagents claude` / `myagents kimi` / `myagents codex` +- 🚀 **独立快捷命令**:`myclaude`、`mykimi`、`mycodex` - 📂 **自动 Workspace 解析**:默认 `~/workspace`,可通过环境变量覆盖 - 📝 **Session 管理**:`--list` 列出可恢复会话 - ➡️ **原生参数透传**:任意底层 agent 支持的参数直接透传 @@ -48,7 +48,7 @@ make install `make install` 会: 1. 同步/创建虚拟环境并做可编辑安装 -2. 在 `~/.local/bin/` 创建 `myagents`、`myclaude`、`mykimi` 符号链接 +2. 在 `~/.local/bin/` 创建 `myagents`、`myclaude`、`mykimi`、`mycodex` 符号链接 3. 自动安装 shell 补全到 `~/.local/share/zsh/site-functions` 和 `~/.local/share/bash-completion/completions` 请确保 `~/.local/bin` 在 `PATH` 中,并在 `~/.zshrc` 里加上: @@ -81,6 +81,8 @@ myagents claude -l # 列出可恢复 sessions myagents claude -r # 恢复指定 session myagents kimi # 启动 Kimi Code CLI myagents kimi -S # 恢复 Kimi session +myagents codex # 启动 OpenAI Codex CLI +myagents codex resume # 恢复 Codex session myagents update # 重装 myagents 并同步 workspace 链接 myagents upgrade # update 别名 myagents completion # 安装/查看/卸载 shell 补全 @@ -90,9 +92,11 @@ myclaude myclaude --cwd . -r mykimi mykimi -S +mycodex +mycodex resume ``` -各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session`、`-c`(continue)、`-y`(yolo)等,直接加在命令后即可。 +各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session`、`-c`(continue)、`-y`(yolo)等,Codex 支持 `resume`、`continue`、`exec` 等子命令,直接加在命令后即可。 ## 配置 diff --git a/myagents/cli.py b/myagents/cli.py index bb1beb8..edc6553 100644 --- a/myagents/cli.py +++ b/myagents/cli.py @@ -11,12 +11,13 @@ from myagents.launcher import build_cli def _progs(): """Return (prog_name, cli_factory) pairs that need shell completions.""" - from myagents.entrypoints import claude_cli, kimi_cli + from myagents.entrypoints import claude_cli, codex_cli, kimi_cli return [ ("myagents", lambda: cli), ("myclaude", lambda: claude_cli), ("mykimi", lambda: kimi_cli), + ("mycodex", lambda: codex_cli), ] @@ -33,7 +34,7 @@ def _package_version() -> str: def cli(ctx: click.Context) -> None: """Myagents: unified launcher for AI coding agents. - Use ``myagents claude`` or ``myagents kimi`` to start an agent in workspace/. + Use ``myagents claude``, ``myagents kimi`` or ``myagents codex`` to start an agent in workspace/. """ if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @@ -42,13 +43,16 @@ def cli(ctx: click.Context) -> None: cli.add_command(build_cli("claude"), name="claude") cli.add_command(build_cli("kimi"), name="kimi") +cli.add_command(build_cli("codex"), name="codex") cli.add_command(update_cmd) cli.add_command(upgrade_cmd, name="upgrade") cli.add_command(build_completion_group(_progs)) def main() -> None: - from myagents.commands.completion_install import ensure_completions_installed + from myagents.commands.completion_install import ( + ensure_completions_installed, + ) ensure_completions_installed(_progs()) cli() diff --git a/myagents/commands/completion.py b/myagents/commands/completion.py index 69b3d3a..02ca9d3 100644 --- a/myagents/commands/completion.py +++ b/myagents/commands/completion.py @@ -35,6 +35,10 @@ def _print_script(prog_name: str, shell: str) -> None: from myagents.entrypoints import kimi_cli cli_obj = kimi_cli + elif prog_name == "mycodex": + from myagents.entrypoints import codex_cli + + cli_obj = codex_cli else: raise click.ClickException(f"Unknown command: {prog_name}") @@ -56,7 +60,7 @@ def build_completion_group(progs_fn: ProgListFn) -> click.Group: @completion.command("install") def install_cmd() -> None: - """Write completion scripts for myagents, myclaude and mykimi.""" + """Write completion scripts for myagents, myclaude, mykimi and mycodex.""" installed = install_completions(progs_fn()) if not installed: raise click.ClickException( diff --git a/myagents/commands/completion_install.py b/myagents/commands/completion_install.py index 547ba38..2719698 100644 --- a/myagents/commands/completion_install.py +++ b/myagents/commands/completion_install.py @@ -9,14 +9,13 @@ from __future__ import annotations import os import shlex import subprocess +from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Callable, Sequence import click from click.shell_completion import get_completion_class - ProgList = Sequence[tuple[str, Callable[[], click.Command]]] diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py index 0c56218..f636d4c 100644 --- a/myagents/entrypoints.py +++ b/myagents/entrypoints.py @@ -1,9 +1,10 @@ -"""Standalone entrypoints for myclaude and mykimi command names.""" +"""Standalone entrypoints for myclaude, mykimi, and mycodex command names.""" from myagents.launcher import build_cli claude_cli = build_cli("claude", prog_name="myclaude") kimi_cli = build_cli("kimi", prog_name="mykimi") +codex_cli = build_cli("codex", prog_name="mycodex") def _progs(): @@ -13,12 +14,15 @@ def _progs(): ("myagents", lambda: cli), ("myclaude", lambda: claude_cli), ("mykimi", lambda: kimi_cli), + ("mycodex", lambda: codex_cli), ] def claude_main() -> None: """Run ``myclaude``.""" - from myagents.commands.completion_install import ensure_completions_installed + from myagents.commands.completion_install import ( + ensure_completions_installed, + ) ensure_completions_installed(_progs()) claude_cli() @@ -26,7 +30,19 @@ def claude_main() -> None: def kimi_main() -> None: """Run ``mykimi``.""" - from myagents.commands.completion_install import ensure_completions_installed + from myagents.commands.completion_install import ( + ensure_completions_installed, + ) ensure_completions_installed(_progs()) kimi_cli() + + +def codex_main() -> None: + """Run ``mycodex``.""" + from myagents.commands.completion_install import ( + ensure_completions_installed, + ) + + ensure_completions_installed(_progs()) + codex_cli() diff --git a/myagents/launcher.py b/myagents/launcher.py index 6132eae..04e6dc3 100644 --- a/myagents/launcher.py +++ b/myagents/launcher.py @@ -42,6 +42,17 @@ _BACKENDS: dict[str, dict] = { "[cyan]KIMI_BIN[/cyan]." ), }, + "codex": { + "binary": "codex", + "env_bin": "CODEX_BIN", + "sessions_root": lambda: Path.home() / ".codex" / "sessions", + "session_pattern": "**/*.jsonl", + "default_args": [], + "not_found_msg": ( + "[red]codex CLI not found in PATH.[/red] Install with " + "[cyan]npm install -g @openai/codex[/cyan] or set [cyan]CODEX_BIN[/cyan]." + ), + }, } @@ -69,6 +80,12 @@ def _launch(backend: str, chat_cwd: Path, extra: list[str]) -> None: raise SystemExit(proc.returncode) +_SESSION_ID_UUID_RE = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + def _sessions_dir(backend: str, chat_cwd: Path) -> Path: """Directory where the backend stores sessions for chat_cwd.""" config = _BACKENDS[backend] @@ -76,6 +93,9 @@ def _sessions_dir(backend: str, chat_cwd: Path) -> Path: if backend == "kimi": # Kimi hashes the cwd with md5. munged = hashlib.md5(cwd_str.encode("utf-8")).hexdigest() # noqa: S324 + elif backend == "codex": + # Codex stores all sessions under a single dated tree; cwd is in metadata. + return config["sessions_root"]() else: munged = re.sub(r"[^A-Za-z0-9]", "-", cwd_str) return config["sessions_root"]() / munged @@ -125,11 +145,71 @@ def _first_prompt_kimi(session_file: Path) -> str: return "" +def _codex_session_cwd(session_file: Path) -> str | None: + """Read cwd from a codex session's session_meta record.""" + try: + with session_file.open(encoding="utf-8", errors="ignore") as fh: + for line in fh: + if not line.strip(): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("type") == "session_meta": + return entry.get("payload", {}).get("cwd") + except OSError: + pass + return None + + +def _first_prompt_codex(session_file: Path) -> str: + """Best-effort snippet of the first human prompt in a codex rollout jsonl.""" + try: + with session_file.open(encoding="utf-8", errors="ignore") as fh: + for line in fh: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("type") != "user_message": + continue + payload = entry.get("payload", {}) + if payload.get("type") != "user_message": + continue + content = payload.get("message") + if isinstance(content, str) and content.strip(): + return " ".join(content.split())[:80] + except OSError: + pass + return "" + + +def _codex_session_id(session_file: Path) -> str: + """Extract the UUID session id from a codex rollout filename.""" + match = _SESSION_ID_UUID_RE.search(session_file.stem) + return match.group(0) if match else session_file.stem + + def _session_files(backend: str, chat_cwd: Path) -> list[Path]: """Return session files/directories sorted by newest first.""" config = _BACKENDS[backend] sessions_dir = _sessions_dir(backend, chat_cwd) + if backend == "codex": + files = [p for p in sessions_dir.glob(config["session_pattern"]) if p.is_file()] + cwd_str = str(chat_cwd) + + def _matches_cwd(p: Path) -> bool: + stored = _codex_session_cwd(p) + if stored is None: + return False + return stored == cwd_str or Path(stored).resolve() == chat_cwd + + files = [p for p in files if _matches_cwd(p)] + files.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return files + if backend == "claude": files = sorted( sessions_dir.glob(config["session_pattern"]), @@ -153,6 +233,8 @@ def _session_files(backend: str, chat_cwd: Path) -> list[Path]: def _first_prompt(backend: str, session_file: Path) -> str: if backend == "claude": return _first_prompt_claude(session_file) + if backend == "codex": + return _first_prompt_codex(session_file) return _first_prompt_kimi(session_file) @@ -160,6 +242,8 @@ def _resume_syntax(backend: str) -> str: """Return the backend's resume flag syntax for use in help text.""" if backend == "kimi": return "--session, -S " + if backend == "codex": + return "resume " return "--resume, -r " @@ -174,7 +258,12 @@ def _list_sessions(backend: str, chat_cwd: Path) -> None: for session_file in files: mtime = datetime.fromtimestamp(session_file.stat().st_mtime) snippet = _first_prompt(backend, session_file) or "[dim](empty)[/dim]" - session_id = session_file.parent.name if backend == "kimi" else session_file.stem + if backend == "kimi": + session_id = session_file.parent.name + elif backend == "codex": + session_id = _codex_session_id(session_file) + else: + session_id = session_file.stem console.print( f" [cyan]{session_id}[/cyan] " f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}" @@ -204,12 +293,14 @@ class LaunchGroup(click.Group): def build_cli(backend: str, prog_name: str | None = None) -> click.Group: - """Build a click CLI that wraps ``backend`` (claude or kimi). + """Build a click CLI that wraps ``backend`` (claude, kimi, or codex). ``prog_name`` is used in --version output. When omitted it defaults to ``myagents `` (suitable for use as a subcommand of ``myagents``). """ backend_title = backend.capitalize() + if backend == "codex": + backend_title = "OpenAI Codex" @click.group( cls=LaunchGroup, @@ -262,7 +353,7 @@ def build_cli(backend: str, prog_name: str | None = None) -> click.Group: _launch(backend, chat_cwd, extra) cli.help = ( - f"Launch {backend_title} Code in workspace/.\n\n" + f"Launch {backend_title} in workspace/.\n\n" f"Unknown arguments ({_resume_syntax(backend)}, --continue, …) pass through to {backend}." ) try: diff --git a/pyproject.toml b/pyproject.toml index 71b48db..23a6744 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ myagents = "myagents.cli:cli" myclaude = "myagents.entrypoints:claude_main" mykimi = "myagents.entrypoints:kimi_main" +mycodex = "myagents.entrypoints:codex_main" [dependency-groups] dev = ["pytest>=8.0"] diff --git a/scripts/rm_user_local_myagents.py b/scripts/rm_user_local_myagents.py index 38a3af5..78096bb 100644 --- a/scripts/rm_user_local_myagents.py +++ b/scripts/rm_user_local_myagents.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -"""Remove ~/.local/bin/{myagents,myclaude,mykimi} symlinks to this repo's venv.""" +"""Remove ~/.local/bin/{myagents,myclaude,mykimi,mycodex} symlinks to this repo's venv.""" from __future__ import annotations import os import sys -_COMMANDS = ("myagents", "myclaude", "mykimi") +_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex") def _remove_link(link: str, want: str) -> bool: diff --git a/tests/test_cli.py b/tests/test_cli.py index ee25c11..39dc2a7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,12 @@ """Tests for myagents CLI.""" +import json from pathlib import Path from unittest.mock import MagicMock, patch from click.testing import CliRunner +import myagents.launcher from myagents.cli import cli @@ -12,12 +14,13 @@ class TestMyagentsHelp: """Tests for top-level myagents command.""" def test_help_shows_agent_subcommands(self) -> None: - """--help should list claude and kimi subcommands.""" + """--help should list claude, kimi and codex subcommands.""" runner = CliRunner() result = runner.invoke(cli, ["--help"]) assert result.exit_code == 0 assert "claude" in result.output assert "kimi" in result.output + assert "codex" in result.output assert "update" in result.output assert "upgrade" in result.output @@ -226,3 +229,136 @@ class TestUpdateSubcommand: runner = CliRunner() result = runner.invoke(cli, ["upgrade", "--help"]) assert result.exit_code == 0 + + +class TestCodexSubcommand: + """Tests for ``myagents codex``.""" + + def test_help_shows_options(self) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["codex", "--help"]) + assert result.exit_code == 0 + assert "--cwd" in result.output + assert "--list" in result.output + + def test_runs_codex(self) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["codex"]) + assert result.exit_code == 0 + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["/usr/bin/codex"] + + def test_missing_binary_error(self) -> None: + runner = CliRunner() + with patch("myagents.launcher.shutil.which", return_value=None): + result = runner.invoke(cli, ["codex"]) + assert result.exit_code == 127 + assert "not found" in result.output.lower() + + def test_cwd_option_passed(self, tmp_path: Path) -> None: + runner = CliRunner() + test_dir = tmp_path / "test_cwd" + test_dir.mkdir() + + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["codex", "--cwd", str(test_dir)]) + assert result.exit_code == 0 + assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve()) + + +class TestCodexPassthrough: + """Codex resume subcommand forwards to codex instead of erroring.""" + + def _invoke(self, args: list[str], tmp_path: Path) -> list[str]: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke( + cli, ["codex", "--cwd", str(tmp_path), *args] + ) + assert result.exit_code == 0, result.output + mock_run.assert_called_once() + return mock_run.call_args[0][0] + + def test_resume_subcommand_passes_through(self, tmp_path: Path) -> None: + cmd = self._invoke(["resume", "abc123"], tmp_path) + assert cmd == ["/usr/bin/codex", "resume", "abc123"] + + def test_continue_subcommand_passes_through(self, tmp_path: Path) -> None: + cmd = self._invoke(["continue"], tmp_path) + assert cmd == ["/usr/bin/codex", "continue"] + + +class TestCodexListSessions: + """``myagents codex --list`` reports resumable sessions.""" + + def test_list_empty_directory(self, tmp_path: Path) -> None: + runner = CliRunner() + with patch("myagents.launcher.subprocess.run") as mock_run: + result = runner.invoke(cli, ["codex", "--cwd", str(tmp_path), "--list"]) + assert result.exit_code == 0 + assert "no sessions" in result.output.lower() + mock_run.assert_not_called() + + def test_list_shows_session_id_and_snippet(self, tmp_path: Path) -> None: + runner = CliRunner() + sessions_root = tmp_path / "sessions" + sessions_root.mkdir(parents=True) + session_file = ( + sessions_root + / "2026" + / "01" + / "02" + / "rollout-2026-01-02T12-00-00-019aeb67-0620-71c1-8cbb-756bc8845c6e.jsonl" + ) + session_file.parent.mkdir(parents=True) + session_file.write_text( + json.dumps( + { + "type": "session_meta", + "timestamp": "2026-01-02T12:00:00Z", + "payload": { + "id": "019aeb67-0620-71c1-8cbb-756bc8845c6e", + "cwd": str(tmp_path), + }, + } + ) + + "\n" + + json.dumps( + { + "type": "user_message", + "payload": { + "type": "user_message", + "message": "hello codex", + }, + } + ) + + "\n", + encoding="utf-8", + ) + + with patch( + "myagents.launcher._BACKENDS", + { + "codex": { + **myagents.launcher._BACKENDS["codex"], + "sessions_root": lambda: sessions_root, + } + }, + ): + result = runner.invoke(cli, ["codex", "--cwd", str(tmp_path), "--list"]) + assert result.exit_code == 0, result.output + assert "019aeb67-0620-71c1-8cbb-756bc8845c6e" in result.output + assert "hello codex" in result.output diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 5ec71cd..8b6a4ad 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -1,11 +1,11 @@ -"""Tests for standalone myclaude / mykimi entrypoints.""" +"""Tests for standalone myclaude / mykimi / mycodex entrypoints.""" from pathlib import Path from unittest.mock import MagicMock, patch from click.testing import CliRunner -from myagents.entrypoints import claude_cli, kimi_cli +from myagents.entrypoints import claude_cli, codex_cli, kimi_cli class TestMyclaudeEntrypoint: @@ -73,3 +73,36 @@ class TestMykimiEntrypoint: result = runner.invoke(kimi_cli, ["--cwd", str(tmp_path), "--resume"]) assert result.exit_code == 0 assert "--resume" in mock_run.call_args[0][0] + + +class TestMycodexEntrypoint: + """``mycodex`` standalone entrypoint.""" + + def test_runs_codex(self) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(codex_cli, []) + assert result.exit_code == 0 + assert mock_run.call_args[0][0] == ["/usr/bin/codex"] + + def test_version_shows_mycodex(self) -> None: + runner = CliRunner() + result = runner.invoke(codex_cli, ["--version"]) + assert result.exit_code == 0 + assert "mycodex" in result.output + + def test_passthrough(self, tmp_path: Path) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(codex_cli, ["--cwd", str(tmp_path), "resume", "abc123"]) + assert result.exit_code == 0 + assert "resume" in mock_run.call_args[0][0] + assert "abc123" in mock_run.call_args[0][0]