feat: add mycursor backend; drop myagents xiaohe entrypoint

Cursor Agent CLI wrapper (mycursor) with resume/session listing.
Product xiaohe CLI stays in xiaohe-api only — no dual entry.
This commit is contained in:
Zhengshou Lai
2026-07-29 21:54:30 +08:00
parent 6ec2a3ebe6
commit ffac0ba37d
16 changed files with 426 additions and 156 deletions
+1 -1
View File
@@ -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 mycodex myhermes
COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor
.PHONY: help install uninstall _symlink-commands
+1 -1
View File
@@ -135,7 +135,7 @@ python3 -m ruff check myagents tests scripts
## 完整部署指南
如果你要部署的是**小荷助理**完整环境(含 Claude Code、cc-switch、Metabot、飞书机器人等),请参见 [xiaohe-agent 部署文档](../../README.md)。
如果你要部署的是**小荷助理**完整环境(Session API、Desktop、Skills Hub 等),请参见 [xiaohe-agent 仓库 README](../../README.md)(产品包名 **xiaohe-api**
## License
+5 -2
View File
@@ -14,6 +14,7 @@ def _progs():
from myagents.entrypoints import (
claude_cli,
codex_cli,
cursor_cli,
hermes_cli,
kimi_cli,
)
@@ -24,6 +25,7 @@ def _progs():
("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
("mycursor", lambda: cursor_cli),
]
@@ -40,8 +42,8 @@ def _package_version() -> str:
def cli(ctx: click.Context) -> None:
"""Myagents: unified launcher for AI coding agents.
Use ``myagents claude``, ``myagents kimi``, ``myagents codex`` or
``myagents hermes`` to start an agent in workspace/.
Use ``myagents claude``, ``myagents kimi``, ``myagents codex``,
``myagents hermes`` or ``myagents cursor`` to start an agent in workspace/.
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@@ -61,6 +63,7 @@ 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(build_cli("hermes"), name="hermes")
cli.add_command(build_cli("cursor"), name="cursor")
cli.add_command(update_cmd)
cli.add_command(upgrade_cmd, name="upgrade")
cli.add_command(build_completion_group(_progs))
+4
View File
@@ -43,6 +43,10 @@ def _print_script(prog_name: str, shell: str) -> None:
from myagents.entrypoints import hermes_cli
cli_obj = hermes_cli
elif prog_name == "mycursor":
from myagents.entrypoints import cursor_cli
cli_obj = cursor_cli
else:
raise click.ClickException(f"Unknown command: {prog_name}")
+4 -11
View File
@@ -1,4 +1,4 @@
"""``info`` — show current version, agent, and provider (legacy myagents shim)."""
"""``info`` — show current version, agent, and provider."""
from __future__ import annotations
@@ -18,13 +18,6 @@ def _package_version() -> str:
return "0.0.0"
def _describe_install() -> str:
try:
return f"xiaohe-agent {version('xiaohe-agent')}"
except PackageNotFoundError:
return f"myagents {_package_version()}"
@click.command("info")
def info_cmd() -> None:
"""Show status: version, agent, and provider."""
@@ -36,10 +29,10 @@ def info_cmd() -> None:
table.add_column("key", style="dim")
table.add_column("value")
table.add_row("Version", _describe_install())
table.add_row("Version", f"myagents {_package_version()}")
default_agent = _resolve_agent()
table.add_row("Agent", f"{default_agent} (xiaohe → my{default_agent})")
table.add_row("Agent", f"my{default_agent}")
table.add_row("Provider", describe_active_backend())
@@ -47,6 +40,6 @@ def info_cmd() -> None:
console.print()
provider_list_table()
console.print()
console.print("[dim]Switch with:[/dim]")
console.print("[dim]Product CLI (xiaohe-api):[/dim]")
console.print(" xiaohe switch agent <name>")
console.print(" xiaohe switch provider <name>")
+4 -2
View File
@@ -1,15 +1,17 @@
"""``switch`` — agent / provider (legacy shim; product CLI is server.cli)."""
"""``switch`` — agent / provider helpers (used by product ``xiaohe`` CLI)."""
from __future__ import annotations
import click
from rich.console import Console
from myagents.entrypoints import _KNOWN_BACKENDS
from myagents.launcher import _BACKENDS
from myagents.settings import get_setting, set_setting
console = Console()
_KNOWN_BACKENDS = tuple(_BACKENDS)
def agent_list_table() -> None:
"""Print the agent list (reusable from info command)."""
+10 -4
View File
@@ -18,7 +18,7 @@ console = Console()
BIN_NAMES = (
"xiaohe", "myagents", "myclaude", "mykimi", "mycodex", "myhermes",
"mytoolkit", "metabot", "mb", "mm", "doubao-tts",
"mycursor", "mytoolkit", "metabot", "mb", "mm", "doubao-tts",
)
METABOT_COMPLETIONS = (
"mb", "mm", "metabot", "doubao-tts",
@@ -31,7 +31,7 @@ def _launcher_paths(home: Path) -> list[Path]:
apps = home / ".local" / "share" / "applications"
icon = (
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
/ "apps" / "xiaohe-agent.png"
/ "apps" / "xiaohe-api.png"
)
return [
home / "Desktop" / "Xiaohe Agent.app",
@@ -40,6 +40,8 @@ def _launcher_paths(home: Path) -> list[Path]:
apps / "Xiaohe Agent.desktop",
apps / "XiaoheAgent.desktop",
icon,
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
/ "apps" / "xiaohe.png",
]
@@ -86,8 +88,12 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
from myagents.project_root import get_workspace_root
console.print("[bold]Will remove:[/bold]")
console.print(" - CLI entry points: xiaohe, myclaude, mykimi, mycodex, myhermes,")
console.print(" myagents, mytoolkit, metabot, mb, mm, doubao-tts")
console.print(
" - CLI entry points: xiaohe, myclaude, mykimi, mycodex, myhermes,"
)
console.print(
" mycursor, myagents, mytoolkit, metabot, mb, mm, doubao-tts"
)
console.print(" - shell completions for the above")
console.print(" - desktop launcher (Xiaohe Agent.app / Xiaohe Agent.desktop)")
console.print(" - pip packages: mytoolkit, myagents")
+3 -6
View File
@@ -1,7 +1,4 @@
"""``upgrade`` — forward to ``xiaohe upgrade`` (pip wheel).
Old runtime-tarball install under ``~/.xiaohe/runtime`` is removed.
"""
"""``upgrade`` — forward to ``xiaohe upgrade`` (xiaohe-api wheel)."""
from __future__ import annotations
@@ -22,12 +19,12 @@ def upgrade_cmd(
user: str | None,
password: str | None,
) -> None:
"""Forward to ``xiaohe upgrade`` (wheel)."""
"""Forward to ``xiaohe upgrade`` (xiaohe-api wheel)."""
del user, password
xiaohe = shutil.which("xiaohe")
if not xiaohe:
raise click.ClickException(
"Install xiaohe-agent, then run: xiaohe upgrade"
"Install xiaohe-api, then run: xiaohe upgrade"
)
cmd = [xiaohe, "upgrade"]
if version:
+6 -26
View File
@@ -1,4 +1,4 @@
"""``version`` — show package install location (legacy myagents shim)."""
"""``version`` — show package install location."""
import shutil
import subprocess
@@ -36,16 +36,8 @@ def _git_describe(tree: Path) -> str:
def describe_tree(tree: Path) -> tuple[str, str]:
"""Classify a package source tree."""
parts = tree.parts
if ".xiaohe" in parts and "runtime" in parts:
# Legacy tarball layout (no longer installed by upgrade).
try:
idx = parts.index("runtime")
return "legacy-runtime", parts[idx + 1]
except (ValueError, IndexError):
return "legacy-runtime", "unknown"
if (tree / ".git").exists() or (tree.parent / ".git").exists():
# contrib/myagents → repo root may hold .git
# packages/myagents → monorepo root may hold .git
probe = tree
for _ in range(3):
if (probe / ".git").exists():
@@ -64,29 +56,17 @@ def version_cmd() -> None:
"""Show which tree the CLI runs from."""
import myagents
bin_path = shutil.which("xiaohe") or shutil.which("myagents") or "?"
bin_path = shutil.which("myagents") or "?"
console.print(f"[bold]myagents[/bold] (bin: {bin_path})")
try:
console.print(f" xiaohe-agent: [cyan]{version('xiaohe-agent')}[/cyan]")
console.print(f" version: [cyan]{version('myagents')}[/cyan]")
except PackageNotFoundError:
pass
tree = _pkg_tree(myagents.__file__)
mode, detail = describe_tree(tree)
if mode == "development":
console.print(f" myagents: [green]development[/green] — {detail}")
elif mode == "legacy-runtime":
console.print(
f" myagents: [yellow]legacy runtime {detail}[/yellow] "
"(use xiaohe upgrade / pip install)"
)
console.print(f" source: [green]development[/green] — {detail}")
else:
console.print(f" myagents: installed [dim]{tree}[/dim]")
legacy = Path.home() / ".xiaohe" / "runtime" / "current"
if legacy.is_symlink():
console.print(
f" [dim]leftover ~/.xiaohe/runtime → {legacy.resolve().name} "
"(safe to remove after wheel install)[/dim]"
)
console.print(f" source: installed [dim]{tree}[/dim]")
+6 -68
View File
@@ -1,8 +1,4 @@
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, xiaohe."""
import re
import click
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, mycursor."""
from myagents.launcher import build_cli
@@ -10,60 +6,7 @@ claude_cli = build_cli("claude", prog_name="myclaude")
kimi_cli = build_cli("kimi", prog_name="mykimi")
codex_cli = build_cli("codex", prog_name="mycodex")
hermes_cli = build_cli("hermes", prog_name="myhermes")
_KNOWN_BACKENDS = ("claude", "kimi", "codex", "hermes")
def default_agent() -> str:
"""Default backend for the ``xiaohe`` alias.
Reads ``default_agent`` from xiaohe settings (accepts both "claude" and
"myclaude" spellings); falls back to claude.
"""
from myagents.settings import get_setting
agent = str(get_setting("default_agent", "") or "")
agent = re.sub(r"^my", "", agent.strip().lower())
return agent if agent in _KNOWN_BACKENDS else "claude"
def build_xiaohe_cli():
"""Legacy ``xiaohe`` group for myagents-only installs.
The product entrypoint is ``server.cli:main`` (xiaohe-agent wheel).
"""
from myagents.commands.info import info_cmd
from myagents.commands.provider import provider_cmd
from myagents.commands.switch import switch_cmd
from myagents.commands.uninstall import uninstall_cmd
from myagents.commands.upgrade import upgrade_cmd
from myagents.commands.version import version_cmd
@click.command("init")
def init_cmd() -> None:
"""Removed — use ``xiaohe workspace init``."""
raise click.ClickException("Use: xiaohe workspace init")
@click.command("sync")
def sync_cmd() -> None:
"""Removed — workspace is git-managed; product skills use ``make sync``."""
raise click.ClickException(
"Workspace content is git-managed. "
"Product skills: make sync (in xiaohe-agent)."
)
if "provider" not in switch_cmd.commands:
switch_cmd.add_command(provider_cmd)
xiaohe_cli = build_cli(default_agent(), prog_name="xiaohe", offer_install=True)
xiaohe_cli.add_command(info_cmd)
xiaohe_cli.add_command(init_cmd)
xiaohe_cli.add_command(sync_cmd)
xiaohe_cli.add_command(upgrade_cmd)
xiaohe_cli.add_command(switch_cmd)
xiaohe_cli.add_command(uninstall_cmd)
xiaohe_cli.add_command(version_cmd)
return xiaohe_cli
cursor_cli = build_cli("cursor", prog_name="mycursor")
def _progs():
@@ -75,7 +18,7 @@ def _progs():
("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
("xiaohe", build_xiaohe_cli),
("mycursor", lambda: cursor_cli),
]
@@ -119,16 +62,11 @@ def hermes_main() -> None:
hermes_cli()
def xiaohe_main() -> None:
"""Legacy ``xiaohe`` entry (myagents-only installs).
Prefer the xiaohe-agent wheel entrypoint ``server.cli:main``.
"""
def cursor_main() -> None:
"""Run ``mycursor``."""
from myagents.commands.completion_install import (
ensure_completions_installed,
)
from myagents.commands.first_run import ensure_auth_configured
ensure_completions_installed(_progs())
ensure_auth_configured()
build_xiaohe_cli()()
cursor_cli()
+137 -7
View File
@@ -69,6 +69,20 @@ _BACKENDS: dict[str, dict] = {
"or set [cyan]HERMES_BIN[/cyan]."
),
},
"cursor": {
# Cursor Agent CLI ships as ``agent`` (also linked as ``cursor-agent``).
"binary": "agent",
"alt_binaries": ("cursor-agent",),
"env_bin": "CURSOR_BIN",
"sessions_root": lambda: Path.home() / ".cursor" / "chats",
"session_pattern": "*",
"default_args": ["--force"],
"not_found_msg": (
"[red]Cursor Agent CLI (agent) not found in PATH.[/red] Install "
"from [cyan]https://cursor.com/docs/cli[/cyan] or set "
"[cyan]CURSOR_BIN[/cyan]."
),
},
}
@@ -128,6 +142,37 @@ def _translate_hermes_extra(extra: list[str]) -> list[str]:
return extra
def _translate_cursor_extra(extra: list[str]) -> list[str]:
"""Map myagents-style resume shortcuts to Cursor Agent CLI flags.
Cursor Agent accepts ``--resume [chatId]`` and ``--continue``, but not
``-r`` / ``-c`` / ``--last``. Keep ``mycursor`` UX aligned with the others.
"""
if not extra:
return extra
head = extra[0]
tail = extra[1:]
if head == "-r":
if not tail or tail[0].startswith("-"):
return ["--resume", *tail]
return ["--resume", tail[0], *tail[1:]]
if head in ("-c", "--last"):
return ["--continue", *tail]
return extra
def _resolve_backend_binary(config: dict) -> str | None:
"""Resolve backend CLI path from env override, primary name, then alts."""
binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"])
if binary:
return binary
for alt in config.get("alt_binaries", ()):
found = shutil.which(alt)
if found:
return found
return None
def _tmux_session_name(backend: str, chat_cwd: Path) -> str:
"""Deterministic per-backend, per-directory tmux session name.
@@ -205,7 +250,7 @@ def _offer_install(config: dict) -> str | None:
f"[red]Install failed (exit {proc.returncode}).[/red]"
)
return None
return os.environ.get(config["env_bin"]) or shutil.which(config["binary"])
return _resolve_backend_binary(config)
def _launch(
@@ -217,7 +262,7 @@ def _launch(
) -> None:
"""Run backend CLI in chat_cwd, forwarding extra args. Never returns."""
config = _BACKENDS[backend]
binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"])
binary = _resolve_backend_binary(config)
if not binary and offer_install:
binary = _offer_install(config)
if not binary:
@@ -228,6 +273,8 @@ def _launch(
extra = _translate_codex_extra(extra)
elif backend == "hermes":
extra = _translate_hermes_extra(extra)
elif backend == "cursor":
extra = _translate_cursor_extra(extra)
cmd = [binary, *config["default_args"], *extra]
if use_tmux:
@@ -246,8 +293,8 @@ def _sessions_dir(backend: str, chat_cwd: Path) -> Path:
"""Directory where the backend stores sessions for chat_cwd."""
config = _BACKENDS[backend]
cwd_str = str(chat_cwd)
if backend == "kimi":
# Kimi hashes the cwd with md5.
if backend in ("kimi", "cursor"):
# Kimi / Cursor Agent hash 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.
@@ -341,6 +388,70 @@ def _first_prompt_codex(session_file: Path) -> str:
return ""
_USER_QUERY_RE = re.compile(
r"<user_query>\s*(.*?)\s*</user_query>", re.DOTALL | re.IGNORECASE
)
def _decode_cursor_meta_value(value: str) -> dict | None:
"""Decode Cursor store.db meta values (hex-encoded JSON or plain JSON)."""
candidates = [value]
if len(value) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in value):
try:
candidates.insert(0, bytes.fromhex(value).decode("utf-8"))
except (ValueError, UnicodeDecodeError):
pass
for raw in candidates:
try:
obj = json.loads(raw)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
return obj
return None
def _first_prompt_cursor(session_file: Path) -> str:
"""Best-effort snippet from a Cursor Agent ``store.db`` session."""
try:
conn = sqlite3.connect(f"file:{session_file}?mode=ro", uri=True)
except sqlite3.Error:
return ""
try:
for _key, value in conn.execute("SELECT key, value FROM meta"):
meta = _decode_cursor_meta_value(str(value))
if not meta:
continue
name = str(meta.get("name") or "").strip()
if name and name.lower() not in ("new agent", "new chat", "untitled"):
return " ".join(name.split())[:80]
for _blob_id, data in conn.execute("SELECT id, data FROM blobs"):
try:
if isinstance(data, memoryview):
data = data.tobytes()
if isinstance(data, bytes):
entry = json.loads(data.decode("utf-8", errors="ignore"))
else:
entry = json.loads(data)
except (TypeError, json.JSONDecodeError, UnicodeDecodeError):
continue
if entry.get("role") != "user":
continue
content = entry.get("content")
if not isinstance(content, str) or not content.strip():
continue
match = _USER_QUERY_RE.search(content)
text = match.group(1) if match else content
text = " ".join(text.split())
if text:
return text[:80]
except sqlite3.Error:
return ""
finally:
conn.close()
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)
@@ -374,6 +485,18 @@ def _session_files(backend: str, chat_cwd: Path) -> list[Path]:
)
return [p for p in files if p.is_file()]
if backend == "cursor":
# Cursor Agent: ~/.cursor/chats/<cwd-md5>/<chat-id>/store.db
files: list[Path] = []
for session_dir in sessions_dir.glob(config["session_pattern"]):
if not session_dir.is_dir():
continue
store = session_dir / "store.db"
if store.is_file():
files.append(store)
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return files
# Kimi stores sessions as <cwd-md5>/<session-id>/context.jsonl
files: list[Path] = []
for session_dir in sessions_dir.glob(config["session_pattern"]):
@@ -391,6 +514,8 @@ def _first_prompt(backend: str, session_file: Path) -> str:
return _first_prompt_claude(session_file)
if backend == "codex":
return _first_prompt_codex(session_file)
if backend == "cursor":
return _first_prompt_cursor(session_file)
return _first_prompt_kimi(session_file)
@@ -402,6 +527,8 @@ def _resume_syntax(backend: str) -> str:
return "-r, --resume <id> or resume <id>"
if backend == "hermes":
return "-r, --resume <id> (bare -r opens the session picker)"
if backend == "cursor":
return "-r, --resume <id>"
return "--resume, -r <id>"
@@ -473,7 +600,7 @@ 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]"
if backend == "kimi":
if backend in ("kimi", "cursor"):
session_id = session_file.parent.name
elif backend == "codex":
session_id = _codex_session_id(session_file)
@@ -483,8 +610,9 @@ def _list_sessions(backend: str, chat_cwd: Path) -> None:
f" [cyan]{session_id}[/cyan] "
f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}"
)
resume_prog = f"my{backend}"
console.print(
f"\n[dim]Resume with[/dim] [green]{backend} {_resume_syntax(backend)}[/green] "
f"\n[dim]Resume with[/dim] [green]{resume_prog} {_resume_syntax(backend)}[/green] "
"[dim](add --cwd if not workspace).[/dim]"
)
@@ -517,11 +645,13 @@ def build_cli(
``prog_name`` is used in --version output. When omitted it defaults to
``myagents <backend>`` (suitable for use as a subcommand of ``myagents``).
With ``offer_install`` a missing backend binary triggers an interactive
install prompt instead of a plain error (used by the ``xiaohe`` alias).
install prompt instead of a plain error.
"""
backend_title = backend.capitalize()
if backend == "codex":
backend_title = "OpenAI Codex"
elif backend == "cursor":
backend_title = "Cursor Agent"
@click.group(
cls=LaunchGroup,
+1 -1
View File
@@ -14,7 +14,7 @@ myclaude = "myagents.entrypoints:claude_main"
mykimi = "myagents.entrypoints:kimi_main"
mycodex = "myagents.entrypoints:codex_main"
myhermes = "myagents.entrypoints:hermes_main"
xiaohe = "myagents.entrypoints:xiaohe_main"
mycursor = "myagents.entrypoints:cursor_main"
[dependency-groups]
dev = ["pytest>=8.0"]
+197 -1
View File
@@ -15,7 +15,7 @@ class TestMyagentsHelp:
"""Tests for top-level myagents command."""
def test_help_shows_agent_subcommands(self) -> None:
"""--help should list claude, kimi, codex and hermes subcommands."""
"""--help should list claude, kimi, codex, hermes and cursor subcommands."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
@@ -23,6 +23,7 @@ class TestMyagentsHelp:
assert "kimi" in result.output
assert "codex" in result.output
assert "hermes" in result.output
assert "cursor" in result.output
assert "update" in result.output
assert "upgrade" in result.output
@@ -49,6 +50,7 @@ class TestMyagentsHelp:
assert "kimi" in result.output
assert "codex" in result.output
assert "hermes" in result.output
assert "cursor" in result.output
def test_help_lists_backends(self) -> None:
"""--help should list backends subcommand."""
@@ -598,3 +600,197 @@ class TestHermesListSessions:
assert result.exit_code == 0, result.output
assert "sess-abc123" in result.output
assert "hello hermes" in result.output
class TestCursorSubcommand:
"""Tests for ``myagents cursor``."""
def test_help_shows_options(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["cursor", "--help"])
assert result.exit_code == 0
assert "--cwd" in result.output
assert "--list" in result.output
def test_runs_agent(self) -> None:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/agent"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["cursor"])
assert result.exit_code == 0
mock_run.assert_called_once()
assert mock_run.call_args[0][0] == ["/usr/bin/agent", "--force"]
def test_falls_back_to_cursor_agent(self) -> None:
runner = CliRunner()
def _which(name: str) -> str | None:
return "/usr/bin/cursor-agent" if name == "cursor-agent" else None
with (
patch("myagents.launcher.shutil.which", side_effect=_which),
patch("myagents.launcher.subprocess.run") as mock_run,
patch.dict("os.environ", {}, clear=False) as env,
):
env.pop("CURSOR_BIN", None)
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["cursor"])
assert result.exit_code == 0
assert mock_run.call_args[0][0] == [
"/usr/bin/cursor-agent",
"--force",
]
def test_missing_binary_error(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value=None),
patch.dict("os.environ", {}, clear=False) as env,
):
env.pop("CURSOR_BIN", None)
result = runner.invoke(cli, ["cursor"])
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/agent"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["cursor", "--cwd", str(test_dir)])
assert result.exit_code == 0
assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve())
class TestCursorPassthrough:
"""Map myagents-style resume flags to Cursor Agent CLI flags."""
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/agent"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
cli, ["cursor", "--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_r_flag_maps_to_resume(self, tmp_path: Path) -> None:
cmd = self._invoke(["-r", "abc123"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--resume", "abc123"]
def test_bare_r_flag_maps_to_resume(self, tmp_path: Path) -> None:
cmd = self._invoke(["-r"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--resume"]
def test_resume_flag_passes_through(self, tmp_path: Path) -> None:
cmd = self._invoke(["--resume", "abc123"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--resume", "abc123"]
def test_c_flag_maps_to_continue(self, tmp_path: Path) -> None:
cmd = self._invoke(["-c"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
def test_continue_flag_passes_through(self, tmp_path: Path) -> None:
cmd = self._invoke(["--continue"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
def test_last_flag_maps_to_continue(self, tmp_path: Path) -> None:
cmd = self._invoke(["--last"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
class TestCursorListSessions:
"""``myagents cursor --list`` reads ~/.cursor/chats/<cwd-md5>/."""
def test_list_empty_directory(self, tmp_path: Path) -> None:
runner = CliRunner()
with patch(
"myagents.launcher._BACKENDS",
{
"cursor": {
**myagents.launcher._BACKENDS["cursor"],
"sessions_root": lambda: tmp_path / "chats",
}
},
):
result = runner.invoke(
cli, ["cursor", "--cwd", str(tmp_path), "--list"]
)
assert result.exit_code == 0
assert "no sessions" in result.output.lower()
def test_list_shows_session_id_and_name(self, tmp_path: Path) -> None:
import hashlib
import json
import sqlite3
cwd = tmp_path / "proj"
cwd.mkdir()
chats_root = tmp_path / "chats"
munged = hashlib.md5(str(cwd.resolve()).encode("utf-8")).hexdigest()
session_dir = chats_root / munged / "chat-abc123"
session_dir.mkdir(parents=True)
db_path = session_dir / "store.db"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)")
meta = {
"agentId": "chat-abc123",
"name": "Fix mycursor entrypoint",
"mode": "default",
"createdAt": 1,
}
conn.execute(
"INSERT INTO meta (key, value) VALUES ('0', ?)",
(json.dumps(meta).encode().hex(),),
)
conn.execute(
"INSERT INTO blobs (id, data) VALUES ('1', ?)",
(
json.dumps(
{
"role": "user",
"content": "<user_query>\nhello cursor\n</user_query>",
}
).encode(),
),
)
conn.commit()
conn.close()
runner = CliRunner()
with patch(
"myagents.launcher._BACKENDS",
{
"cursor": {
**myagents.launcher._BACKENDS["cursor"],
"sessions_root": lambda: chats_root,
}
},
):
result = runner.invoke(
cli, ["cursor", "--cwd", str(cwd), "--list"]
)
assert result.exit_code == 0, result.output
assert "chat-abc123" in result.output
assert "Fix mycursor entrypoint" in result.output
assert "mycursor" in result.output
+46 -19
View File
@@ -1,28 +1,17 @@
"""Tests for standalone myclaude / mykimi / mycodex / myhermes entrypoints."""
"""Tests for standalone myclaude / mykimi / mycodex / myhermes / mycursor entrypoints."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from myagents.entrypoints import build_xiaohe_cli, claude_cli, codex_cli, hermes_cli, kimi_cli
class TestXiaoheProvider:
"""``xiaohe switch provider`` subcommand registration."""
def test_help_lists_switch(self) -> None:
runner = CliRunner()
result = runner.invoke(build_xiaohe_cli(), ["--help"])
assert result.exit_code == 0
assert "switch" in result.output
def test_help_lists_info(self) -> None:
runner = CliRunner()
result = runner.invoke(build_xiaohe_cli(), ["--help"])
assert result.exit_code == 0
assert "info" in result.output
from myagents.entrypoints import (
claude_cli,
codex_cli,
cursor_cli,
hermes_cli,
kimi_cli,
)
class TestMyclaudeEntrypoint:
"""``myclaude`` standalone entrypoint."""
@@ -161,6 +150,44 @@ class TestMyhermesEntrypoint:
]
class TestMycursorEntrypoint:
"""``mycursor`` standalone entrypoint."""
def test_runs_agent(self) -> None:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/agent"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cursor_cli, [])
assert result.exit_code == 0
assert mock_run.call_args[0][0] == ["/usr/bin/agent", "--force"]
def test_version_shows_mycursor(self) -> None:
runner = CliRunner()
result = runner.invoke(cursor_cli, ["--version"])
assert result.exit_code == 0
assert "mycursor" in result.output
def test_passthrough(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/agent"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
cursor_cli, ["--cwd", str(tmp_path), "-r", "abc123"]
)
assert result.exit_code == 0
assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"]
class TestTmuxOption:
"""``--tmux`` / ``-t`` wraps the backend in an attachable tmux session."""
+1 -1
View File
@@ -23,4 +23,4 @@ class TestUpgradeCommand:
with patch.object(up_mod.shutil, "which", return_value=None):
result = CliRunner().invoke(up_mod.upgrade_cmd, [])
assert result.exit_code != 0
assert "xiaohe upgrade" in result.output
assert "Install xiaohe-api" in result.output or "xiaohe upgrade" in result.output
-6
View File
@@ -7,12 +7,6 @@ from myagents.commands import version as ver_mod
class TestDescribeTree:
def test_legacy_runtime_tree(self) -> None:
tree = Path("/home/u/.xiaohe/runtime/v1.2.3/contrib/myagents")
mode, detail = ver_mod.describe_tree(tree)
assert mode == "legacy-runtime"
assert detail == "v1.2.3"
def test_development_checkout(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()