feat: xiaohe uninstall——默认只卸 CLI 层,保留 ~/.xiaohe 与 workspace;--all 连配置一起删

This commit is contained in:
Zhengshou Lai
2026-07-16 19:06:35 +08:00
parent 5d34b247ef
commit 37eae6f283
3 changed files with 207 additions and 1 deletions
+138
View File
@@ -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.")
+3 -1
View File
@@ -26,14 +26,16 @@ def default_agent() -> str:
def build_xiaohe_cli(): 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.sync_workspace import init_cmd, sync_cmd
from myagents.commands.uninstall import uninstall_cmd
from myagents.commands.upgrade import upgrade_cmd from myagents.commands.upgrade import upgrade_cmd
xiaohe_cli = build_cli(default_agent(), prog_name="xiaohe", offer_install=True) xiaohe_cli = build_cli(default_agent(), prog_name="xiaohe", offer_install=True)
xiaohe_cli.add_command(init_cmd) xiaohe_cli.add_command(init_cmd)
xiaohe_cli.add_command(sync_cmd) xiaohe_cli.add_command(sync_cmd)
xiaohe_cli.add_command(upgrade_cmd) xiaohe_cli.add_command(upgrade_cmd)
xiaohe_cli.add_command(uninstall_cmd)
return xiaohe_cli return xiaohe_cli
+66
View File
@@ -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()