diff --git a/myagents/commands/uninstall.py b/myagents/commands/uninstall.py new file mode 100644 index 0000000..62b3c56 --- /dev/null +++ b/myagents/commands/uninstall.py @@ -0,0 +1,138 @@ +"""``xiaohe uninstall`` — remove the CLI layer, keep runtime/config/workspace. + +Default: removes the entry points (xiaohe/myclaude/.../mytoolkit), metabot +CLI, shell completions, and the desktop launcher. Keeps ~/.xiaohe (runtime, +settings, keys) and the workspace. ``--all`` also deletes ~/.xiaohe; the +workspace is never touched. +""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import click +from rich.console import Console + +stderr_console = Console(stderr=True) +console = Console() + +BIN_NAMES = ( + "xiaohe", "myagents", "myclaude", "mykimi", "mycodex", "myhermes", + "mytoolkit", "metabot", "mb", "mm", "doubao-tts", +) +METABOT_COMPLETIONS = ( + "mb", "mm", "metabot", "doubao-tts", + "_mb", "_mm", "_metabot", "_doubao-tts", +) +PY_PACKAGES = ("mytoolkit", "myagents") + + +def _launcher_paths(home: Path) -> list[Path]: + apps = home / ".local" / "share" / "applications" + icon = ( + home / ".local" / "share" / "icons" / "hicolor" / "512x512" + / "apps" / "xiaohe-agent.png" + ) + return [ + home / "Desktop" / "Xiaohe Agent.app", + home / "Desktop" / "XiaoheAgent.app", + home / "Desktop" / "XiaoheAgent.command", + apps / "Xiaohe Agent.desktop", + apps / "XiaoheAgent.desktop", + icon, + ] + + +def _remove_path(path: Path, removed: list[Path]) -> None: + if path.is_symlink() or path.is_file(): + path.unlink() + removed.append(path) + elif path.is_dir(): + shutil.rmtree(path) + removed.append(path) + + +def _pip_uninstall() -> list[str]: + """Uninstall the pip packages (removes their pip-owned entry points).""" + if os.environ.get("VIRTUAL_ENV") and shutil.which("uv"): + base = ["uv", "pip", "uninstall"] + else: + base = [sys.executable, "-m", "pip", "uninstall", "-y"] + warnings: list[str] = [] + for pkg in PY_PACKAGES: + result = subprocess.run([*base, pkg], capture_output=True, text=True) + if result.returncode != 0: + tail = (result.stderr or "").strip()[:120] + warnings.append(f"pip uninstall {pkg} failed: {tail}") + return warnings + + +@click.command("uninstall") +@click.option("--all", "remove_all", is_flag=True, + help="Also remove ~/.xiaohe (runtime, settings, API keys)") +@click.option("--yes", is_flag=True, help="Do not ask for confirmation") +def uninstall_cmd(remove_all: bool, yes: bool) -> None: + """Remove the xiaohe CLI layer; keeps ~/.xiaohe and the workspace.""" + home = Path.home() + from myagents.commands.completion_install import uninstall_completions + from myagents.entrypoints import _progs + 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(" - shell completions for the above") + console.print(" - desktop launcher (Xiaohe Agent.app / Xiaohe Agent.desktop)") + console.print(" - pip packages: mytoolkit, myagents") + if remove_all: + console.print(" - [red]~/.xiaohe (runtime, settings.json, config.json keys)[/red]") + console.print("[bold]Will keep:[/bold]") + if not remove_all: + console.print(" - ~/.xiaohe (runtime, settings, keys)") + console.print(f" - workspace: {get_workspace_root(create=False)}") + console.print(" - rc-file edits (PATH line, ANTHROPIC_* exports) and the claude CLI") + + if not yes and not click.confirm("Proceed with uninstall?", default=False, err=True): + console.print("Cancelled.") + return + if remove_all and not yes and not click.confirm( + "Really delete ~/.xiaohe including your API keys?", default=False, err=True + ): + console.print("Cancelled.") + return + + removed: list[Path] = [] + + # Shell completions first — needs myagents still importable. + removed += uninstall_completions(_progs()) + + # Entry-point scripts in ~/.local/bin (defensive: pip removes its own). + local_bin = home / ".local" / "bin" + for name in BIN_NAMES: + _remove_path(local_bin / name, removed) + for name in METABOT_COMPLETIONS: + _remove_path(local_bin / "completions" / name, removed) + comp_dir = local_bin / "completions" + if comp_dir.is_dir() and not any(comp_dir.iterdir()): + comp_dir.rmdir() + + for path in _launcher_paths(home): + _remove_path(path, removed) + + warnings = _pip_uninstall() + + if remove_all: + _remove_path(home / ".xiaohe", removed) + + for warning in warnings: + stderr_console.print(f"[yellow]warning: {warning}[/yellow]") + console.print( + f"[green]Removed {len(removed)} items.[/green] " + "Open a new terminal to pick up the change." + ) + if remove_all: + console.print("Kept: your workspace and rc-file edits.") + else: + console.print("Kept: ~/.xiaohe, your workspace, rc-file edits.") diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py index b693b73..70c4bdd 100644 --- a/myagents/entrypoints.py +++ b/myagents/entrypoints.py @@ -26,14 +26,16 @@ def default_agent() -> str: def build_xiaohe_cli(): - """The ``xiaohe`` command group: agent forwarding + init/sync/upgrade.""" + """The ``xiaohe`` command group: agent forwarding + init/sync/upgrade/uninstall.""" from myagents.commands.sync_workspace import init_cmd, sync_cmd + from myagents.commands.uninstall import uninstall_cmd from myagents.commands.upgrade import upgrade_cmd xiaohe_cli = build_cli(default_agent(), prog_name="xiaohe", offer_install=True) xiaohe_cli.add_command(init_cmd) xiaohe_cli.add_command(sync_cmd) xiaohe_cli.add_command(upgrade_cmd) + xiaohe_cli.add_command(uninstall_cmd) return xiaohe_cli diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py new file mode 100644 index 0000000..a856cf9 --- /dev/null +++ b/tests/test_uninstall.py @@ -0,0 +1,66 @@ +"""Tests for myagents.commands.uninstall.""" + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from myagents.commands import uninstall as un_mod + + +@pytest.fixture() +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + monkeypatch.setattr(un_mod, "_pip_uninstall", lambda: []) + monkeypatch.setattr( + "myagents.commands.completion_install.uninstall_completions", + lambda progs, **kw: [], + ) + return home + + +def _populate(home: Path) -> None: + local_bin = home / ".local" / "bin" + (local_bin / "completions").mkdir(parents=True) + for name in ("xiaohe", "myclaude", "mytoolkit", "metabot", "mb"): + (local_bin / name).write_text("#!/bin/sh\n") + (local_bin / "completions" / "metabot").write_text("comp") + (local_bin / "completions" / "_metabot").write_text("comp") + (home / ".xiaohe" / "agent").mkdir(parents=True) + (home / ".xiaohe" / "agent" / "config.json").write_text("{}") + (home / "workspace").mkdir() + app = home / "Desktop" / "Xiaohe Agent.app" / "Contents" + app.mkdir(parents=True) + + +class TestUninstall: + def test_removes_cli_layer_keeps_xiaohe_and_workspace( + self, fake_home: Path + ) -> None: + _populate(fake_home) + result = CliRunner().invoke(un_mod.uninstall_cmd, ["--yes"]) + assert result.exit_code == 0 + local_bin = fake_home / ".local" / "bin" + assert not (local_bin / "xiaohe").exists() + assert not (local_bin / "myclaude").exists() + assert not (local_bin / "metabot").exists() + assert not (local_bin / "completions").exists() # empty dir cleaned + assert not (fake_home / "Desktop" / "Xiaohe Agent.app").exists() + assert (fake_home / ".xiaohe" / "agent" / "config.json").is_file() + assert (fake_home / "workspace").is_dir() + + def test_all_removes_xiaohe_dir(self, fake_home: Path) -> None: + _populate(fake_home) + result = CliRunner().invoke(un_mod.uninstall_cmd, ["--all", "--yes"]) + assert result.exit_code == 0 + assert not (fake_home / ".xiaohe").exists() + assert (fake_home / "workspace").is_dir() # workspace never touched + + def test_cancelled_keeps_everything(self, fake_home: Path) -> None: + _populate(fake_home) + result = CliRunner().invoke(un_mod.uninstall_cmd, [], input="n\n") + assert result.exit_code == 0 + assert "Cancelled" in result.output + assert (fake_home / ".local" / "bin" / "xiaohe").exists()