feat: xiaohe version——显示 bin 跑的是研发检出还是安装快照(含 git describe);uninstall --all 三重确认
This commit is contained in:
@@ -72,7 +72,8 @@ def _pip_uninstall() -> list[str]:
|
|||||||
@click.command("uninstall")
|
@click.command("uninstall")
|
||||||
@click.option("--all", "remove_all", is_flag=True,
|
@click.option("--all", "remove_all", is_flag=True,
|
||||||
help="Also remove ~/.xiaohe (runtime, settings, API keys)")
|
help="Also remove ~/.xiaohe (runtime, settings, API keys)")
|
||||||
@click.option("--yes", is_flag=True, help="Do not ask for confirmation")
|
@click.option("--yes", is_flag=True,
|
||||||
|
help="Skip the first confirmation (--all still requires the full chain)")
|
||||||
def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
||||||
"""Remove the xiaohe CLI layer; keeps ~/.xiaohe and the workspace."""
|
"""Remove the xiaohe CLI layer; keeps ~/.xiaohe and the workspace."""
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
@@ -97,11 +98,23 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
|||||||
if not yes and not click.confirm("Proceed with uninstall?", default=False, err=True):
|
if not yes and not click.confirm("Proceed with uninstall?", default=False, err=True):
|
||||||
console.print("Cancelled.")
|
console.print("Cancelled.")
|
||||||
return
|
return
|
||||||
if remove_all and not yes and not click.confirm(
|
if remove_all:
|
||||||
"Really delete ~/.xiaohe including your API keys?", default=False, err=True
|
# Deleting ~/.xiaohe destroys settings and every saved API key —
|
||||||
):
|
# always require the full confirmation chain, even with --yes.
|
||||||
console.print("Cancelled.")
|
if not click.confirm(
|
||||||
return
|
"This also deletes ~/.xiaohe — runtime, settings and ALL saved API keys. "
|
||||||
|
"Continue?",
|
||||||
|
default=False,
|
||||||
|
err=True,
|
||||||
|
):
|
||||||
|
console.print("Cancelled.")
|
||||||
|
return
|
||||||
|
phrase = click.prompt(
|
||||||
|
'Final confirmation — type "delete-all" to proceed', err=True
|
||||||
|
)
|
||||||
|
if phrase.strip() != "delete-all":
|
||||||
|
console.print("Cancelled (phrase did not match).")
|
||||||
|
return
|
||||||
|
|
||||||
removed: list[Path] = []
|
removed: list[Path] = []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""``xiaohe version`` — show whether the CLI runs a dev checkout or an installed runtime."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def _git_describe(tree: Path) -> str:
|
||||||
|
try:
|
||||||
|
describe = subprocess.run(
|
||||||
|
["git", "-C", str(tree), "describe", "--tags", "--dirty", "--always"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "-C", str(tree), "branch", "--show-current"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return "unknown"
|
||||||
|
if describe.returncode != 0:
|
||||||
|
return "unknown"
|
||||||
|
text = describe.stdout.strip()
|
||||||
|
name = branch.stdout.strip()
|
||||||
|
return f"{text} (branch {name})" if name else text
|
||||||
|
|
||||||
|
|
||||||
|
def describe_tree(tree: Path) -> tuple[str, str]:
|
||||||
|
"""Classify a package source tree: ('installed'|'development'|'unknown', detail)."""
|
||||||
|
parts = tree.parts
|
||||||
|
if ".xiaohe" in parts and "runtime" in parts:
|
||||||
|
try:
|
||||||
|
idx = parts.index("runtime")
|
||||||
|
return "installed", parts[idx + 1]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return "installed", "unknown"
|
||||||
|
if (tree / ".git").exists():
|
||||||
|
return "development", _git_describe(tree)
|
||||||
|
return "unknown", ""
|
||||||
|
|
||||||
|
|
||||||
|
def _pkg_tree(module_file: str) -> Path:
|
||||||
|
# <repo>/myagents/__init__.py -> <repo>
|
||||||
|
return Path(module_file).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _report_pkg(label: str, module_file: str) -> str:
|
||||||
|
tree = _pkg_tree(module_file)
|
||||||
|
mode, detail = describe_tree(tree)
|
||||||
|
line = {
|
||||||
|
"installed": f"installed runtime [cyan]{detail}[/cyan]",
|
||||||
|
"development": f"[green]development checkout[/green] — {detail}",
|
||||||
|
}.get(mode, "unknown")
|
||||||
|
console.print(f" {label}: {line}")
|
||||||
|
console.print(f" [dim]{tree}[/dim]")
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("version")
|
||||||
|
def version_cmd() -> None:
|
||||||
|
"""Show which tree the CLI runs from: dev checkout or installed runtime."""
|
||||||
|
import myagents
|
||||||
|
|
||||||
|
bin_path = shutil.which("xiaohe") or "?"
|
||||||
|
console.print(f"[bold]xiaohe[/bold] (bin: {bin_path})")
|
||||||
|
mode = _report_pkg("myagents", myagents.__file__)
|
||||||
|
try:
|
||||||
|
import mytoolkit
|
||||||
|
|
||||||
|
_report_pkg("mytoolkit", mytoolkit.__file__)
|
||||||
|
except ImportError:
|
||||||
|
console.print(" mytoolkit: [yellow]not importable[/yellow]")
|
||||||
|
|
||||||
|
current = Path.home() / ".xiaohe" / "runtime" / "current"
|
||||||
|
if current.is_symlink():
|
||||||
|
console.print(f"runtime snapshot: [cyan]{current.resolve().name}[/cyan] [dim]({current.parent})[/dim]")
|
||||||
|
|
||||||
|
if mode == "installed":
|
||||||
|
console.print(
|
||||||
|
"\n[dim]Dev machine? Point the bins at your repo instead:[/dim]\n"
|
||||||
|
" pip install -e <repo>/contrib/mytoolkit -e <repo>/contrib/myagents"
|
||||||
|
)
|
||||||
@@ -26,16 +26,18 @@ def default_agent() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def build_xiaohe_cli():
|
def build_xiaohe_cli():
|
||||||
"""The ``xiaohe`` command group: agent forwarding + init/sync/upgrade/uninstall."""
|
"""The ``xiaohe`` command group: agent forwarding + init/sync/upgrade/uninstall/version."""
|
||||||
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.uninstall import uninstall_cmd
|
||||||
from myagents.commands.upgrade import upgrade_cmd
|
from myagents.commands.upgrade import upgrade_cmd
|
||||||
|
from myagents.commands.version import version_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)
|
xiaohe_cli.add_command(uninstall_cmd)
|
||||||
|
xiaohe_cli.add_command(version_cmd)
|
||||||
return xiaohe_cli
|
return xiaohe_cli
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -53,11 +53,32 @@ class TestUninstall:
|
|||||||
|
|
||||||
def test_all_removes_xiaohe_dir(self, fake_home: Path) -> None:
|
def test_all_removes_xiaohe_dir(self, fake_home: Path) -> None:
|
||||||
_populate(fake_home)
|
_populate(fake_home)
|
||||||
result = CliRunner().invoke(un_mod.uninstall_cmd, ["--all", "--yes"])
|
result = CliRunner().invoke(
|
||||||
|
un_mod.uninstall_cmd, ["--all", "--yes"], input="y\ndelete-all\n"
|
||||||
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert not (fake_home / ".xiaohe").exists()
|
assert not (fake_home / ".xiaohe").exists()
|
||||||
assert (fake_home / "workspace").is_dir() # workspace never touched
|
assert (fake_home / "workspace").is_dir() # workspace never touched
|
||||||
|
|
||||||
|
def test_all_requires_full_chain_even_with_yes(self, fake_home: Path) -> None:
|
||||||
|
_populate(fake_home)
|
||||||
|
# --yes skips only the first gate; declining the second cancels.
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
un_mod.uninstall_cmd, ["--all", "--yes"], input="n\n"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cancelled" in result.output
|
||||||
|
assert (fake_home / ".xiaohe").exists()
|
||||||
|
|
||||||
|
def test_all_aborts_on_wrong_phrase(self, fake_home: Path) -> None:
|
||||||
|
_populate(fake_home)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
un_mod.uninstall_cmd, ["--all", "--yes"], input="y\nnope\n"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cancelled" in result.output
|
||||||
|
assert (fake_home / ".xiaohe").exists()
|
||||||
|
|
||||||
def test_cancelled_keeps_everything(self, fake_home: Path) -> None:
|
def test_cancelled_keeps_everything(self, fake_home: Path) -> None:
|
||||||
_populate(fake_home)
|
_populate(fake_home)
|
||||||
result = CliRunner().invoke(un_mod.uninstall_cmd, [], input="n\n")
|
result = CliRunner().invoke(un_mod.uninstall_cmd, [], input="n\n")
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Tests for myagents.commands.version."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from myagents.commands import version as ver_mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescribeTree:
|
||||||
|
def test_installed_runtime_tree(self) -> None:
|
||||||
|
tree = Path("/home/u/.xiaohe/runtime/v1.2.3/contrib/myagents")
|
||||||
|
mode, detail = ver_mod.describe_tree(tree)
|
||||||
|
assert mode == "installed"
|
||||||
|
assert detail == "v1.2.3"
|
||||||
|
|
||||||
|
def test_development_checkout(self, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
mode, _ = ver_mod.describe_tree(repo)
|
||||||
|
assert mode == "development"
|
||||||
|
|
||||||
|
def test_unknown_tree(self, tmp_path: Path) -> None:
|
||||||
|
mode, _ = ver_mod.describe_tree(tmp_path)
|
||||||
|
assert mode == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitDescribe:
|
||||||
|
def test_describes_real_repo(self, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
||||||
|
"commit", "-q", "--allow-empty", "-m", "init"],
|
||||||
|
cwd=repo, check=True,
|
||||||
|
)
|
||||||
|
result = ver_mod._git_describe(repo)
|
||||||
|
assert "branch" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionCommand:
|
||||||
|
def test_runs_and_reports(self) -> None:
|
||||||
|
result = CliRunner().invoke(ver_mod.version_cmd)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "myagents" in result.output
|
||||||
Reference in New Issue
Block a user