diff --git a/myagents/commands/uninstall.py b/myagents/commands/uninstall.py index 62b3c56..a424ad8 100644 --- a/myagents/commands/uninstall.py +++ b/myagents/commands/uninstall.py @@ -72,7 +72,8 @@ def _pip_uninstall() -> list[str]: @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") +@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: """Remove the xiaohe CLI layer; keeps ~/.xiaohe and the workspace.""" 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): 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 + if remove_all: + # Deleting ~/.xiaohe destroys settings and every saved API key — + # always require the full confirmation chain, even with --yes. + if not click.confirm( + "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] = [] diff --git a/myagents/commands/version.py b/myagents/commands/version.py new file mode 100644 index 0000000..1ba369d --- /dev/null +++ b/myagents/commands/version.py @@ -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: + # /myagents/__init__.py -> + 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 /contrib/mytoolkit -e /contrib/myagents" + ) diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py index 70c4bdd..449a2b9 100644 --- a/myagents/entrypoints.py +++ b/myagents/entrypoints.py @@ -26,16 +26,18 @@ def default_agent() -> str: 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.uninstall import uninstall_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.add_command(init_cmd) xiaohe_cli.add_command(sync_cmd) xiaohe_cli.add_command(upgrade_cmd) xiaohe_cli.add_command(uninstall_cmd) + xiaohe_cli.add_command(version_cmd) return xiaohe_cli diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index a856cf9..826b30e 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -53,11 +53,32 @@ class TestUninstall: def test_all_removes_xiaohe_dir(self, fake_home: Path) -> None: _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 not (fake_home / ".xiaohe").exists() 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: _populate(fake_home) result = CliRunner().invoke(un_mod.uninstall_cmd, [], input="n\n") diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..21be535 --- /dev/null +++ b/tests/test_version.py @@ -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