diff --git a/Makefile b/Makefile index d6259e1..5304745 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ ROOT_DIR := $(shell pwd) VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin USER_BIN_DIR := $(HOME)/.local/bin -COMMANDS := myagents myclaude mykimi mycodex myhermes +COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor .PHONY: help install uninstall _symlink-commands diff --git a/README.md b/README.md index 7f08d0a..d210add 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/myagents/cli.py b/myagents/cli.py index 9a74cb2..0674263 100644 --- a/myagents/cli.py +++ b/myagents/cli.py @@ -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)) diff --git a/myagents/commands/completion.py b/myagents/commands/completion.py index 7e100e3..3fdd4db 100644 --- a/myagents/commands/completion.py +++ b/myagents/commands/completion.py @@ -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}") diff --git a/myagents/commands/info.py b/myagents/commands/info.py index 31da222..8c4f2a6 100644 --- a/myagents/commands/info.py +++ b/myagents/commands/info.py @@ -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 ") console.print(" xiaohe switch provider ") diff --git a/myagents/commands/switch.py b/myagents/commands/switch.py index dd486f4..edeefae 100644 --- a/myagents/commands/switch.py +++ b/myagents/commands/switch.py @@ -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).""" diff --git a/myagents/commands/uninstall.py b/myagents/commands/uninstall.py index d16b0e9..4b567c5 100644 --- a/myagents/commands/uninstall.py +++ b/myagents/commands/uninstall.py @@ -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") diff --git a/myagents/commands/upgrade.py b/myagents/commands/upgrade.py index 9110172..3afa82f 100644 --- a/myagents/commands/upgrade.py +++ b/myagents/commands/upgrade.py @@ -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: diff --git a/myagents/commands/version.py b/myagents/commands/version.py index f93afc5..0609230 100644 --- a/myagents/commands/version.py +++ b/myagents/commands/version.py @@ -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]") diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py index 4c062b2..e463b8e 100644 --- a/myagents/entrypoints.py +++ b/myagents/entrypoints.py @@ -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() diff --git a/myagents/launcher.py b/myagents/launcher.py index d3b60fd..77a6436 100644 --- a/myagents/launcher.py +++ b/myagents/launcher.py @@ -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"\s*(.*?)\s*", 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///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 //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 or resume " if backend == "hermes": return "-r, --resume (bare -r opens the session picker)" + if backend == "cursor": + return "-r, --resume " return "--resume, -r " @@ -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 `` (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, diff --git a/pyproject.toml b/pyproject.toml index 9644098..b3a4afa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 3b08818..0c7b770 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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//.""" + + 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": "\nhello cursor\n", + } + ).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 diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index f5b228e..e544425 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -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.""" diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 75dee39..d0400f7 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -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 diff --git a/tests/test_version.py b/tests/test_version.py index f4e4590..6c82ef9 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -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()