feat: 添加 mycodex 入口,支持 OpenAI Codex CLI
- 在 launcher 中注册 codex 后端,兼容 ~/.codex/sessions 日期嵌套目录 - 解析 session_meta.payload.cwd 过滤当前目录会话 - 添加 myagents codex 子命令与 mycodex 独立入口、补全及 Makefile 链接 - 更新 pyproject.toml 控制台脚本与 README 文档 - 补充 codex 相关单元测试,45 tests passed
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
ROOT_DIR := $(shell pwd)
|
ROOT_DIR := $(shell pwd)
|
||||||
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
|
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
|
||||||
USER_BIN_DIR := $(HOME)/.local/bin
|
USER_BIN_DIR := $(HOME)/.local/bin
|
||||||
COMMANDS := myagents myclaude mykimi
|
COMMANDS := myagents myclaude mykimi mycodex
|
||||||
|
|
||||||
.PHONY: help install uninstall _symlink-commands
|
.PHONY: help install uninstall _symlink-commands
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# MyAgents
|
# MyAgents
|
||||||
|
|
||||||
一个统一的 AI coding agent 启动器,封装 Claude Code、Kimi Code CLI 等工具。
|
一个统一的 AI coding agent 启动器,封装 Claude Code、Kimi Code CLI、OpenAI Codex 等工具。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 🤖 **多 Agent 统一入口**:`myagents claude` / `myagents kimi`
|
- 🤖 **多 Agent 统一入口**:`myagents claude` / `myagents kimi` / `myagents codex`
|
||||||
- 🚀 **独立快捷命令**:`myclaude`、`mykimi`
|
- 🚀 **独立快捷命令**:`myclaude`、`mykimi`、`mycodex`
|
||||||
- 📂 **自动 Workspace 解析**:默认 `~/workspace`,可通过环境变量覆盖
|
- 📂 **自动 Workspace 解析**:默认 `~/workspace`,可通过环境变量覆盖
|
||||||
- 📝 **Session 管理**:`--list` 列出可恢复会话
|
- 📝 **Session 管理**:`--list` 列出可恢复会话
|
||||||
- ➡️ **原生参数透传**:任意底层 agent 支持的参数直接透传
|
- ➡️ **原生参数透传**:任意底层 agent 支持的参数直接透传
|
||||||
@@ -48,7 +48,7 @@ make install
|
|||||||
|
|
||||||
`make install` 会:
|
`make install` 会:
|
||||||
1. 同步/创建虚拟环境并做可编辑安装
|
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`
|
3. 自动安装 shell 补全到 `~/.local/share/zsh/site-functions` 和 `~/.local/share/bash-completion/completions`
|
||||||
|
|
||||||
请确保 `~/.local/bin` 在 `PATH` 中,并在 `~/.zshrc` 里加上:
|
请确保 `~/.local/bin` 在 `PATH` 中,并在 `~/.zshrc` 里加上:
|
||||||
@@ -81,6 +81,8 @@ myagents claude -l # 列出可恢复 sessions
|
|||||||
myagents claude -r <session-id> # 恢复指定 session
|
myagents claude -r <session-id> # 恢复指定 session
|
||||||
myagents kimi # 启动 Kimi Code CLI
|
myagents kimi # 启动 Kimi Code CLI
|
||||||
myagents kimi -S <session-id> # 恢复 Kimi session
|
myagents kimi -S <session-id> # 恢复 Kimi session
|
||||||
|
myagents codex # 启动 OpenAI Codex CLI
|
||||||
|
myagents codex resume <id> # 恢复 Codex session
|
||||||
myagents update # 重装 myagents 并同步 workspace 链接
|
myagents update # 重装 myagents 并同步 workspace 链接
|
||||||
myagents upgrade # update 别名
|
myagents upgrade # update 别名
|
||||||
myagents completion # 安装/查看/卸载 shell 补全
|
myagents completion # 安装/查看/卸载 shell 补全
|
||||||
@@ -90,9 +92,11 @@ myclaude
|
|||||||
myclaude --cwd . -r <session-id>
|
myclaude --cwd . -r <session-id>
|
||||||
mykimi
|
mykimi
|
||||||
mykimi -S <session-id>
|
mykimi -S <session-id>
|
||||||
|
mycodex
|
||||||
|
mycodex resume <session-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session`、`-c`(continue)、`-y`(yolo)等,直接加在命令后即可。
|
各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session`、`-c`(continue)、`-y`(yolo)等,Codex 支持 `resume`、`continue`、`exec` 等子命令,直接加在命令后即可。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -11,12 +11,13 @@ from myagents.launcher import build_cli
|
|||||||
|
|
||||||
def _progs():
|
def _progs():
|
||||||
"""Return (prog_name, cli_factory) pairs that need shell completions."""
|
"""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 [
|
return [
|
||||||
("myagents", lambda: cli),
|
("myagents", lambda: cli),
|
||||||
("myclaude", lambda: claude_cli),
|
("myclaude", lambda: claude_cli),
|
||||||
("mykimi", lambda: kimi_cli),
|
("mykimi", lambda: kimi_cli),
|
||||||
|
("mycodex", lambda: codex_cli),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ def _package_version() -> str:
|
|||||||
def cli(ctx: click.Context) -> None:
|
def cli(ctx: click.Context) -> None:
|
||||||
"""Myagents: unified launcher for AI coding agents.
|
"""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:
|
if ctx.invoked_subcommand is None:
|
||||||
click.echo(ctx.get_help())
|
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("claude"), name="claude")
|
||||||
cli.add_command(build_cli("kimi"), name="kimi")
|
cli.add_command(build_cli("kimi"), name="kimi")
|
||||||
|
cli.add_command(build_cli("codex"), name="codex")
|
||||||
cli.add_command(update_cmd)
|
cli.add_command(update_cmd)
|
||||||
cli.add_command(upgrade_cmd, name="upgrade")
|
cli.add_command(upgrade_cmd, name="upgrade")
|
||||||
cli.add_command(build_completion_group(_progs))
|
cli.add_command(build_completion_group(_progs))
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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())
|
ensure_completions_installed(_progs())
|
||||||
cli()
|
cli()
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ def _print_script(prog_name: str, shell: str) -> None:
|
|||||||
from myagents.entrypoints import kimi_cli
|
from myagents.entrypoints import kimi_cli
|
||||||
|
|
||||||
cli_obj = kimi_cli
|
cli_obj = kimi_cli
|
||||||
|
elif prog_name == "mycodex":
|
||||||
|
from myagents.entrypoints import codex_cli
|
||||||
|
|
||||||
|
cli_obj = codex_cli
|
||||||
else:
|
else:
|
||||||
raise click.ClickException(f"Unknown command: {prog_name}")
|
raise click.ClickException(f"Unknown command: {prog_name}")
|
||||||
|
|
||||||
@@ -56,7 +60,7 @@ def build_completion_group(progs_fn: ProgListFn) -> click.Group:
|
|||||||
|
|
||||||
@completion.command("install")
|
@completion.command("install")
|
||||||
def install_cmd() -> None:
|
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())
|
installed = install_completions(progs_fn())
|
||||||
if not installed:
|
if not installed:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Sequence
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from click.shell_completion import get_completion_class
|
from click.shell_completion import get_completion_class
|
||||||
|
|
||||||
|
|
||||||
ProgList = Sequence[tuple[str, Callable[[], click.Command]]]
|
ProgList = Sequence[tuple[str, Callable[[], click.Command]]]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -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
|
from myagents.launcher import build_cli
|
||||||
|
|
||||||
claude_cli = build_cli("claude", prog_name="myclaude")
|
claude_cli = build_cli("claude", prog_name="myclaude")
|
||||||
kimi_cli = build_cli("kimi", prog_name="mykimi")
|
kimi_cli = build_cli("kimi", prog_name="mykimi")
|
||||||
|
codex_cli = build_cli("codex", prog_name="mycodex")
|
||||||
|
|
||||||
|
|
||||||
def _progs():
|
def _progs():
|
||||||
@@ -13,12 +14,15 @@ def _progs():
|
|||||||
("myagents", lambda: cli),
|
("myagents", lambda: cli),
|
||||||
("myclaude", lambda: claude_cli),
|
("myclaude", lambda: claude_cli),
|
||||||
("mykimi", lambda: kimi_cli),
|
("mykimi", lambda: kimi_cli),
|
||||||
|
("mycodex", lambda: codex_cli),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def claude_main() -> None:
|
def claude_main() -> None:
|
||||||
"""Run ``myclaude``."""
|
"""Run ``myclaude``."""
|
||||||
from myagents.commands.completion_install import ensure_completions_installed
|
from myagents.commands.completion_install import (
|
||||||
|
ensure_completions_installed,
|
||||||
|
)
|
||||||
|
|
||||||
ensure_completions_installed(_progs())
|
ensure_completions_installed(_progs())
|
||||||
claude_cli()
|
claude_cli()
|
||||||
@@ -26,7 +30,19 @@ def claude_main() -> None:
|
|||||||
|
|
||||||
def kimi_main() -> None:
|
def kimi_main() -> None:
|
||||||
"""Run ``mykimi``."""
|
"""Run ``mykimi``."""
|
||||||
from myagents.commands.completion_install import ensure_completions_installed
|
from myagents.commands.completion_install import (
|
||||||
|
ensure_completions_installed,
|
||||||
|
)
|
||||||
|
|
||||||
ensure_completions_installed(_progs())
|
ensure_completions_installed(_progs())
|
||||||
kimi_cli()
|
kimi_cli()
|
||||||
|
|
||||||
|
|
||||||
|
def codex_main() -> None:
|
||||||
|
"""Run ``mycodex``."""
|
||||||
|
from myagents.commands.completion_install import (
|
||||||
|
ensure_completions_installed,
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure_completions_installed(_progs())
|
||||||
|
codex_cli()
|
||||||
|
|||||||
+94
-3
@@ -42,6 +42,17 @@ _BACKENDS: dict[str, dict] = {
|
|||||||
"[cyan]KIMI_BIN[/cyan]."
|
"[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)
|
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:
|
def _sessions_dir(backend: str, chat_cwd: Path) -> Path:
|
||||||
"""Directory where the backend stores sessions for chat_cwd."""
|
"""Directory where the backend stores sessions for chat_cwd."""
|
||||||
config = _BACKENDS[backend]
|
config = _BACKENDS[backend]
|
||||||
@@ -76,6 +93,9 @@ def _sessions_dir(backend: str, chat_cwd: Path) -> Path:
|
|||||||
if backend == "kimi":
|
if backend == "kimi":
|
||||||
# Kimi hashes the cwd with md5.
|
# Kimi hashes the cwd with md5.
|
||||||
munged = hashlib.md5(cwd_str.encode("utf-8")).hexdigest() # noqa: S324
|
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:
|
else:
|
||||||
munged = re.sub(r"[^A-Za-z0-9]", "-", cwd_str)
|
munged = re.sub(r"[^A-Za-z0-9]", "-", cwd_str)
|
||||||
return config["sessions_root"]() / munged
|
return config["sessions_root"]() / munged
|
||||||
@@ -125,11 +145,71 @@ def _first_prompt_kimi(session_file: Path) -> str:
|
|||||||
return ""
|
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]:
|
def _session_files(backend: str, chat_cwd: Path) -> list[Path]:
|
||||||
"""Return session files/directories sorted by newest first."""
|
"""Return session files/directories sorted by newest first."""
|
||||||
config = _BACKENDS[backend]
|
config = _BACKENDS[backend]
|
||||||
sessions_dir = _sessions_dir(backend, chat_cwd)
|
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":
|
if backend == "claude":
|
||||||
files = sorted(
|
files = sorted(
|
||||||
sessions_dir.glob(config["session_pattern"]),
|
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:
|
def _first_prompt(backend: str, session_file: Path) -> str:
|
||||||
if backend == "claude":
|
if backend == "claude":
|
||||||
return _first_prompt_claude(session_file)
|
return _first_prompt_claude(session_file)
|
||||||
|
if backend == "codex":
|
||||||
|
return _first_prompt_codex(session_file)
|
||||||
return _first_prompt_kimi(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."""
|
"""Return the backend's resume flag syntax for use in help text."""
|
||||||
if backend == "kimi":
|
if backend == "kimi":
|
||||||
return "--session, -S <id>"
|
return "--session, -S <id>"
|
||||||
|
if backend == "codex":
|
||||||
|
return "resume <id>"
|
||||||
return "--resume, -r <id>"
|
return "--resume, -r <id>"
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +258,12 @@ def _list_sessions(backend: str, chat_cwd: Path) -> None:
|
|||||||
for session_file in files:
|
for session_file in files:
|
||||||
mtime = datetime.fromtimestamp(session_file.stat().st_mtime)
|
mtime = datetime.fromtimestamp(session_file.stat().st_mtime)
|
||||||
snippet = _first_prompt(backend, session_file) or "[dim](empty)[/dim]"
|
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(
|
console.print(
|
||||||
f" [cyan]{session_id}[/cyan] "
|
f" [cyan]{session_id}[/cyan] "
|
||||||
f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}"
|
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:
|
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
|
``prog_name`` is used in --version output. When omitted it defaults to
|
||||||
``myagents <backend>`` (suitable for use as a subcommand of ``myagents``).
|
``myagents <backend>`` (suitable for use as a subcommand of ``myagents``).
|
||||||
"""
|
"""
|
||||||
backend_title = backend.capitalize()
|
backend_title = backend.capitalize()
|
||||||
|
if backend == "codex":
|
||||||
|
backend_title = "OpenAI Codex"
|
||||||
|
|
||||||
@click.group(
|
@click.group(
|
||||||
cls=LaunchGroup,
|
cls=LaunchGroup,
|
||||||
@@ -262,7 +353,7 @@ def build_cli(backend: str, prog_name: str | None = None) -> click.Group:
|
|||||||
_launch(backend, chat_cwd, extra)
|
_launch(backend, chat_cwd, extra)
|
||||||
|
|
||||||
cli.help = (
|
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}."
|
f"Unknown arguments ({_resume_syntax(backend)}, --continue, …) pass through to {backend}."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
myagents = "myagents.cli:cli"
|
myagents = "myagents.cli:cli"
|
||||||
myclaude = "myagents.entrypoints:claude_main"
|
myclaude = "myagents.entrypoints:claude_main"
|
||||||
mykimi = "myagents.entrypoints:kimi_main"
|
mykimi = "myagents.entrypoints:kimi_main"
|
||||||
|
mycodex = "myagents.entrypoints:codex_main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8.0"]
|
dev = ["pytest>=8.0"]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
_COMMANDS = ("myagents", "myclaude", "mykimi")
|
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex")
|
||||||
|
|
||||||
|
|
||||||
def _remove_link(link: str, want: str) -> bool:
|
def _remove_link(link: str, want: str) -> bool:
|
||||||
|
|||||||
+137
-1
@@ -1,10 +1,12 @@
|
|||||||
"""Tests for myagents CLI."""
|
"""Tests for myagents CLI."""
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
import myagents.launcher
|
||||||
from myagents.cli import cli
|
from myagents.cli import cli
|
||||||
|
|
||||||
|
|
||||||
@@ -12,12 +14,13 @@ class TestMyagentsHelp:
|
|||||||
"""Tests for top-level myagents command."""
|
"""Tests for top-level myagents command."""
|
||||||
|
|
||||||
def test_help_shows_agent_subcommands(self) -> None:
|
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()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["--help"])
|
result = runner.invoke(cli, ["--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "claude" in result.output
|
assert "claude" in result.output
|
||||||
assert "kimi" in result.output
|
assert "kimi" in result.output
|
||||||
|
assert "codex" in result.output
|
||||||
assert "update" in result.output
|
assert "update" in result.output
|
||||||
assert "upgrade" in result.output
|
assert "upgrade" in result.output
|
||||||
|
|
||||||
@@ -226,3 +229,136 @@ class TestUpdateSubcommand:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["upgrade", "--help"])
|
result = runner.invoke(cli, ["upgrade", "--help"])
|
||||||
assert result.exit_code == 0
|
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
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Tests for standalone myclaude / mykimi entrypoints."""
|
"""Tests for standalone myclaude / mykimi / mycodex entrypoints."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from click.testing import CliRunner
|
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:
|
class TestMyclaudeEntrypoint:
|
||||||
@@ -73,3 +73,36 @@ class TestMykimiEntrypoint:
|
|||||||
result = runner.invoke(kimi_cli, ["--cwd", str(tmp_path), "--resume"])
|
result = runner.invoke(kimi_cli, ["--cwd", str(tmp_path), "--resume"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "--resume" in mock_run.call_args[0][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]
|
||||||
|
|||||||
Reference in New Issue
Block a user