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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user