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) 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 mycodex myhermes COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor
.PHONY: help install uninstall _symlink-commands .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 ## License
+5 -2
View File
@@ -14,6 +14,7 @@ def _progs():
from myagents.entrypoints import ( from myagents.entrypoints import (
claude_cli, claude_cli,
codex_cli, codex_cli,
cursor_cli,
hermes_cli, hermes_cli,
kimi_cli, kimi_cli,
) )
@@ -24,6 +25,7 @@ def _progs():
("mykimi", lambda: kimi_cli), ("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli), ("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli), ("myhermes", lambda: hermes_cli),
("mycursor", lambda: cursor_cli),
] ]
@@ -40,8 +42,8 @@ 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``, ``myagents kimi``, ``myagents codex`` or Use ``myagents claude``, ``myagents kimi``, ``myagents codex``,
``myagents hermes`` to start an agent in workspace/. ``myagents hermes`` or ``myagents cursor`` 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())
@@ -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("kimi"), name="kimi")
cli.add_command(build_cli("codex"), name="codex") cli.add_command(build_cli("codex"), name="codex")
cli.add_command(build_cli("hermes"), name="hermes") cli.add_command(build_cli("hermes"), name="hermes")
cli.add_command(build_cli("cursor"), name="cursor")
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))
+4
View File
@@ -43,6 +43,10 @@ def _print_script(prog_name: str, shell: str) -> None:
from myagents.entrypoints import hermes_cli from myagents.entrypoints import hermes_cli
cli_obj = hermes_cli cli_obj = hermes_cli
elif prog_name == "mycursor":
from myagents.entrypoints import cursor_cli
cli_obj = cursor_cli
else: else:
raise click.ClickException(f"Unknown command: {prog_name}") 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 from __future__ import annotations
@@ -18,13 +18,6 @@ def _package_version() -> str:
return "0.0.0" 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") @click.command("info")
def info_cmd() -> None: def info_cmd() -> None:
"""Show status: version, agent, and provider.""" """Show status: version, agent, and provider."""
@@ -36,10 +29,10 @@ def info_cmd() -> None:
table.add_column("key", style="dim") table.add_column("key", style="dim")
table.add_column("value") table.add_column("value")
table.add_row("Version", _describe_install()) table.add_row("Version", f"myagents {_package_version()}")
default_agent = _resolve_agent() 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()) table.add_row("Provider", describe_active_backend())
@@ -47,6 +40,6 @@ def info_cmd() -> None:
console.print() console.print()
provider_list_table() provider_list_table()
console.print() 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 agent <name>")
console.print(" xiaohe switch provider <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 from __future__ import annotations
import click import click
from rich.console import Console from rich.console import Console
from myagents.entrypoints import _KNOWN_BACKENDS from myagents.launcher import _BACKENDS
from myagents.settings import get_setting, set_setting from myagents.settings import get_setting, set_setting
console = Console() console = Console()
_KNOWN_BACKENDS = tuple(_BACKENDS)
def agent_list_table() -> None: def agent_list_table() -> None:
"""Print the agent list (reusable from info command).""" """Print the agent list (reusable from info command)."""
+10 -4
View File
@@ -18,7 +18,7 @@ console = Console()
BIN_NAMES = ( BIN_NAMES = (
"xiaohe", "myagents", "myclaude", "mykimi", "mycodex", "myhermes", "xiaohe", "myagents", "myclaude", "mykimi", "mycodex", "myhermes",
"mytoolkit", "metabot", "mb", "mm", "doubao-tts", "mycursor", "mytoolkit", "metabot", "mb", "mm", "doubao-tts",
) )
METABOT_COMPLETIONS = ( METABOT_COMPLETIONS = (
"mb", "mm", "metabot", "doubao-tts", "mb", "mm", "metabot", "doubao-tts",
@@ -31,7 +31,7 @@ def _launcher_paths(home: Path) -> list[Path]:
apps = home / ".local" / "share" / "applications" apps = home / ".local" / "share" / "applications"
icon = ( icon = (
home / ".local" / "share" / "icons" / "hicolor" / "512x512" home / ".local" / "share" / "icons" / "hicolor" / "512x512"
/ "apps" / "xiaohe-agent.png" / "apps" / "xiaohe-api.png"
) )
return [ return [
home / "Desktop" / "Xiaohe Agent.app", home / "Desktop" / "Xiaohe Agent.app",
@@ -40,6 +40,8 @@ def _launcher_paths(home: Path) -> list[Path]:
apps / "Xiaohe Agent.desktop", apps / "Xiaohe Agent.desktop",
apps / "XiaoheAgent.desktop", apps / "XiaoheAgent.desktop",
icon, 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 from myagents.project_root import get_workspace_root
console.print("[bold]Will remove:[/bold]") console.print("[bold]Will remove:[/bold]")
console.print(" - CLI entry points: xiaohe, myclaude, mykimi, mycodex, myhermes,") console.print(
console.print(" myagents, mytoolkit, metabot, mb, mm, doubao-tts") " - 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(" - shell completions for the above")
console.print(" - desktop launcher (Xiaohe Agent.app / Xiaohe Agent.desktop)") console.print(" - desktop launcher (Xiaohe Agent.app / Xiaohe Agent.desktop)")
console.print(" - pip packages: mytoolkit, myagents") console.print(" - pip packages: mytoolkit, myagents")
+3 -6
View File
@@ -1,7 +1,4 @@
"""``upgrade`` — forward to ``xiaohe upgrade`` (pip wheel). """``upgrade`` — forward to ``xiaohe upgrade`` (xiaohe-api wheel)."""
Old runtime-tarball install under ``~/.xiaohe/runtime`` is removed.
"""
from __future__ import annotations from __future__ import annotations
@@ -22,12 +19,12 @@ def upgrade_cmd(
user: str | None, user: str | None,
password: str | None, password: str | None,
) -> None: ) -> None:
"""Forward to ``xiaohe upgrade`` (wheel).""" """Forward to ``xiaohe upgrade`` (xiaohe-api wheel)."""
del user, password del user, password
xiaohe = shutil.which("xiaohe") xiaohe = shutil.which("xiaohe")
if not xiaohe: if not xiaohe:
raise click.ClickException( raise click.ClickException(
"Install xiaohe-agent, then run: xiaohe upgrade" "Install xiaohe-api, then run: xiaohe upgrade"
) )
cmd = [xiaohe, "upgrade"] cmd = [xiaohe, "upgrade"]
if version: 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 shutil
import subprocess import subprocess
@@ -36,16 +36,8 @@ def _git_describe(tree: Path) -> str:
def describe_tree(tree: Path) -> tuple[str, str]: def describe_tree(tree: Path) -> tuple[str, str]:
"""Classify a package source tree.""" """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(): 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 probe = tree
for _ in range(3): for _ in range(3):
if (probe / ".git").exists(): if (probe / ".git").exists():
@@ -64,29 +56,17 @@ def version_cmd() -> None:
"""Show which tree the CLI runs from.""" """Show which tree the CLI runs from."""
import myagents 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})") console.print(f"[bold]myagents[/bold] (bin: {bin_path})")
try: try:
console.print(f" xiaohe-agent: [cyan]{version('xiaohe-agent')}[/cyan]") console.print(f" version: [cyan]{version('myagents')}[/cyan]")
except PackageNotFoundError: except PackageNotFoundError:
pass pass
tree = _pkg_tree(myagents.__file__) tree = _pkg_tree(myagents.__file__)
mode, detail = describe_tree(tree) mode, detail = describe_tree(tree)
if mode == "development": if mode == "development":
console.print(f" myagents: [green]development[/green] — {detail}") console.print(f" source: [green]development[/green] — {detail}")
elif mode == "legacy-runtime":
console.print(
f" myagents: [yellow]legacy runtime {detail}[/yellow] "
"(use xiaohe upgrade / pip install)"
)
else: else:
console.print(f" myagents: installed [dim]{tree}[/dim]") console.print(f" source: 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]"
)
+6 -68
View File
@@ -1,8 +1,4 @@
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, xiaohe.""" """Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, mycursor."""
import re
import click
from myagents.launcher import build_cli 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") kimi_cli = build_cli("kimi", prog_name="mykimi")
codex_cli = build_cli("codex", prog_name="mycodex") codex_cli = build_cli("codex", prog_name="mycodex")
hermes_cli = build_cli("hermes", prog_name="myhermes") hermes_cli = build_cli("hermes", prog_name="myhermes")
cursor_cli = build_cli("cursor", prog_name="mycursor")
_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
def _progs(): def _progs():
@@ -75,7 +18,7 @@ def _progs():
("mykimi", lambda: kimi_cli), ("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli), ("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli), ("myhermes", lambda: hermes_cli),
("xiaohe", build_xiaohe_cli), ("mycursor", lambda: cursor_cli),
] ]
@@ -119,16 +62,11 @@ def hermes_main() -> None:
hermes_cli() hermes_cli()
def xiaohe_main() -> None: def cursor_main() -> None:
"""Legacy ``xiaohe`` entry (myagents-only installs). """Run ``mycursor``."""
Prefer the xiaohe-agent wheel entrypoint ``server.cli:main``.
"""
from myagents.commands.completion_install import ( from myagents.commands.completion_install import (
ensure_completions_installed, ensure_completions_installed,
) )
from myagents.commands.first_run import ensure_auth_configured
ensure_completions_installed(_progs()) ensure_completions_installed(_progs())
ensure_auth_configured() cursor_cli()
build_xiaohe_cli()()
+137 -7
View File
@@ -69,6 +69,20 @@ _BACKENDS: dict[str, dict] = {
"or set [cyan]HERMES_BIN[/cyan]." "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 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: def _tmux_session_name(backend: str, chat_cwd: Path) -> str:
"""Deterministic per-backend, per-directory tmux session name. """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]" f"[red]Install failed (exit {proc.returncode}).[/red]"
) )
return None return None
return os.environ.get(config["env_bin"]) or shutil.which(config["binary"]) return _resolve_backend_binary(config)
def _launch( def _launch(
@@ -217,7 +262,7 @@ def _launch(
) -> None: ) -> None:
"""Run backend CLI in chat_cwd, forwarding extra args. Never returns.""" """Run backend CLI in chat_cwd, forwarding extra args. Never returns."""
config = _BACKENDS[backend] 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: if not binary and offer_install:
binary = _offer_install(config) binary = _offer_install(config)
if not binary: if not binary:
@@ -228,6 +273,8 @@ def _launch(
extra = _translate_codex_extra(extra) extra = _translate_codex_extra(extra)
elif backend == "hermes": elif backend == "hermes":
extra = _translate_hermes_extra(extra) extra = _translate_hermes_extra(extra)
elif backend == "cursor":
extra = _translate_cursor_extra(extra)
cmd = [binary, *config["default_args"], *extra] cmd = [binary, *config["default_args"], *extra]
if use_tmux: 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.""" """Directory where the backend stores sessions for chat_cwd."""
config = _BACKENDS[backend] config = _BACKENDS[backend]
cwd_str = str(chat_cwd) cwd_str = str(chat_cwd)
if backend == "kimi": if backend in ("kimi", "cursor"):
# Kimi hashes the cwd with md5. # Kimi / Cursor Agent hash 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": elif backend == "codex":
# Codex stores all sessions under a single dated tree; cwd is in metadata. # 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 "" 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: def _codex_session_id(session_file: Path) -> str:
"""Extract the UUID session id from a codex rollout filename.""" """Extract the UUID session id from a codex rollout filename."""
match = _SESSION_ID_UUID_RE.search(session_file.stem) 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()] 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 # Kimi stores sessions as <cwd-md5>/<session-id>/context.jsonl
files: list[Path] = [] files: list[Path] = []
for session_dir in sessions_dir.glob(config["session_pattern"]): 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) return _first_prompt_claude(session_file)
if backend == "codex": if backend == "codex":
return _first_prompt_codex(session_file) return _first_prompt_codex(session_file)
if backend == "cursor":
return _first_prompt_cursor(session_file)
return _first_prompt_kimi(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>" return "-r, --resume <id> or resume <id>"
if backend == "hermes": if backend == "hermes":
return "-r, --resume <id> (bare -r opens the session picker)" return "-r, --resume <id> (bare -r opens the session picker)"
if backend == "cursor":
return "-r, --resume <id>"
return "--resume, -r <id>" return "--resume, -r <id>"
@@ -473,7 +600,7 @@ 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]"
if backend == "kimi": if backend in ("kimi", "cursor"):
session_id = session_file.parent.name session_id = session_file.parent.name
elif backend == "codex": elif backend == "codex":
session_id = _codex_session_id(session_file) 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" [cyan]{session_id}[/cyan] "
f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}" f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}"
) )
resume_prog = f"my{backend}"
console.print( 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]" "[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 ``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``).
With ``offer_install`` a missing backend binary triggers an interactive 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() backend_title = backend.capitalize()
if backend == "codex": if backend == "codex":
backend_title = "OpenAI Codex" backend_title = "OpenAI Codex"
elif backend == "cursor":
backend_title = "Cursor Agent"
@click.group( @click.group(
cls=LaunchGroup, cls=LaunchGroup,
+1 -1
View File
@@ -14,7 +14,7 @@ myclaude = "myagents.entrypoints:claude_main"
mykimi = "myagents.entrypoints:kimi_main" mykimi = "myagents.entrypoints:kimi_main"
mycodex = "myagents.entrypoints:codex_main" mycodex = "myagents.entrypoints:codex_main"
myhermes = "myagents.entrypoints:hermes_main" myhermes = "myagents.entrypoints:hermes_main"
xiaohe = "myagents.entrypoints:xiaohe_main" mycursor = "myagents.entrypoints:cursor_main"
[dependency-groups] [dependency-groups]
dev = ["pytest>=8.0"] dev = ["pytest>=8.0"]
+197 -1
View File
@@ -15,7 +15,7 @@ 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, kimi, codex and hermes subcommands.""" """--help should list claude, kimi, codex, hermes and cursor 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
@@ -23,6 +23,7 @@ class TestMyagentsHelp:
assert "kimi" in result.output assert "kimi" in result.output
assert "codex" in result.output assert "codex" in result.output
assert "hermes" in result.output assert "hermes" in result.output
assert "cursor" in result.output
assert "update" in result.output assert "update" in result.output
assert "upgrade" in result.output assert "upgrade" in result.output
@@ -49,6 +50,7 @@ class TestMyagentsHelp:
assert "kimi" in result.output assert "kimi" in result.output
assert "codex" in result.output assert "codex" in result.output
assert "hermes" in result.output assert "hermes" in result.output
assert "cursor" in result.output
def test_help_lists_backends(self) -> None: def test_help_lists_backends(self) -> None:
"""--help should list backends subcommand.""" """--help should list backends subcommand."""
@@ -598,3 +600,197 @@ class TestHermesListSessions:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert "sess-abc123" in result.output assert "sess-abc123" in result.output
assert "hello hermes" 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 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 build_xiaohe_cli, claude_cli, codex_cli, hermes_cli, kimi_cli from myagents.entrypoints import (
claude_cli,
codex_cli,
class TestXiaoheProvider: cursor_cli,
"""``xiaohe switch provider`` subcommand registration.""" hermes_cli,
kimi_cli,
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
class TestMyclaudeEntrypoint: class TestMyclaudeEntrypoint:
"""``myclaude`` standalone entrypoint.""" """``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: class TestTmuxOption:
"""``--tmux`` / ``-t`` wraps the backend in an attachable tmux session.""" """``--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): with patch.object(up_mod.shutil, "which", return_value=None):
result = CliRunner().invoke(up_mod.upgrade_cmd, []) result = CliRunner().invoke(up_mod.upgrade_cmd, [])
assert result.exit_code != 0 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: 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: def test_development_checkout(self, tmp_path: Path) -> None:
repo = tmp_path / "repo" repo = tmp_path / "repo"
repo.mkdir() repo.mkdir()