feat: xiaohe uninstall——默认只卸 CLI 层,保留 ~/.xiaohe 与 workspace;--all 连配置一起删
This commit is contained in:
@@ -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.")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user