From 105f7c35df858fa08f33af5e163b58eab4c9751c Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 9 Apr 2026 11:33:49 +0800 Subject: [PATCH 1/4] feat: add install, uninstall, and update commands to CLI Signed-off-by: Frost Ming --- src/bub/builtin/cli.py | 92 ++++++++++++++++++++++++++++++++++++ src/bub/builtin/hook_impl.py | 3 ++ 2 files changed, 95 insertions(+) diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 4c24cc68..1b162cd4 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -4,6 +4,11 @@ from __future__ import annotations import asyncio +import os +import subprocess +import sys +from functools import lru_cache +from pathlib import Path import typer @@ -81,3 +86,90 @@ def chat( raise typer.Exit(1) channel.set_metadata(chat_id=chat_id, session_id=session_id) # type: ignore[attr-defined] asyncio.run(manager.listen_and_run()) + + +@lru_cache(maxsize=1) +def _find_uv() -> str: + import shutil + import sysconfig + + bin_path = sysconfig.get_path("scripts") + uv_path = shutil.which("uv", path=os.pathsep.join([bin_path, os.getenv("PATH", "")])) + if uv_path is None: + raise FileNotFoundError("uv executable not found in PATH or scripts directory.") + return uv_path + + +def _is_in_venv() -> bool: + return sys.prefix != getattr(sys, "base_prefix", sys.prefix) + + +def _uv(*args: str) -> subprocess.CompletedProcess: + uv_executable = _find_uv() + if not _is_in_venv(): + typer.secho("Please install Bub in a virtual environment to use this command.", err=True, fg="red") + raise typer.Exit(1) + env = {**os.environ, "VIRTUAL_ENV": sys.prefix} + try: + return subprocess.run([uv_executable, *args], env=env, check=True) + except subprocess.CalledProcessError as e: + typer.secho(f"Command 'uv {' '.join(args)}' failed with exit code {e.returncode}.", err=True, fg="red") + raise typer.Exit(e.returncode) from e + + +BUB_CONTRIB_REPO = "https://github.com/bubbuild/bub-contrib.git" + + +def _build_requirement(spec: str) -> str: + if spec.startswith(("git@", "https://")): + # Git URL + return f"git+{spec}" + elif "/" in spec: + # owner/repo format + repo, *rest = spec.partition("@") + ref = "".join(rest) + return f"git+https://github.com/{repo}.git{ref}" + else: + # Assume it's a package name in bub-contrib + name, *rest = spec.partition("@") + ref = "".join(rest) + return f"git+{BUB_CONTRIB_REPO}{ref}#subdirectory=packages/{name}" + + +def _ensure_project() -> None: + if (Path.cwd() / "pyproject.toml").is_file(): + return + _uv("init", "--bare", "--name", "bub-project", "--app") + + +def install( + spec: str = typer.Argument( + ..., help="Package specification to install, can be a git URL, owner/repo, or package name in bub-contrib." + ), +) -> None: + """Install a plugin into Bub's environment.""" + _ensure_project() + req = _build_requirement(spec) + _uv("add", "--active", req) + + +def uninstall( + package: str = typer.Argument(..., help="Package name to uninstall (must match the name in pyproject.toml)"), +) -> None: + """Uninstall a plugin from Bub's environment.""" + _ensure_project() + _uv("remove", "--active", "--no-sync", package) + _uv("sync", "--active", "--frozen", "--inexact") + + +def update( + package: str | None = typer.Argument( + None, help="Optional package name to update (must match the name in pyproject.toml)" + ), +) -> None: + """Update selected package or all packages in Bub's environment.""" + _ensure_project() + if package is None: + _uv("sync", "--active", "--upgrade", "--inexact") + else: + _uv("sync", "--active", "--inexact", "--upgrade-package", package) diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 187886bb..a80acf6e 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -118,6 +118,9 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.add_typer(cli.login_app) app.command("hooks", hidden=True)(cli.list_hooks) app.command("gateway")(cli.gateway) + app.command("install")(cli.install) + app.command("uninstall")(cli.uninstall) + app.command("update")(cli.update) def _read_agents_file(self, state: State) -> str: workspace = state.get("_runtime_workspace", str(Path.cwd())) From 42bb58b42889eba16de37a67b0fb99a0292c9b87 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 10 Apr 2026 14:56:30 +0800 Subject: [PATCH 2/4] fix: temporarily disable uninstall command due to functionality issues Signed-off-by: Frost Ming --- src/bub/builtin/hook_impl.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index a80acf6e..d1388129 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -119,7 +119,8 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("hooks", hidden=True)(cli.list_hooks) app.command("gateway")(cli.gateway) app.command("install")(cli.install) - app.command("uninstall")(cli.uninstall) + # TODO: uninstall command can't work properly + # app.command("uninstall")(cli.uninstall) app.command("update")(cli.update) def _read_agents_file(self, state: State) -> str: From 26358a41ac505f8b8d73a52daedbd42c6f3132a4 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 10 Apr 2026 15:08:55 +0800 Subject: [PATCH 3/4] feat: enhance project management in CLI with project path option and improved install/uninstall/update commands Signed-off-by: Frost Ming --- src/bub/builtin/cli.py | 69 +++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 1b162cd4..d84ec536 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -100,18 +100,36 @@ def _find_uv() -> str: return uv_path +@lru_cache(maxsize=1) +def _default_project() -> Path: + from .settings import load_settings + + settings = load_settings() + project = settings.home / "bub-project" + project.mkdir(exist_ok=True, parents=True) + return project + + def _is_in_venv() -> bool: return sys.prefix != getattr(sys, "base_prefix", sys.prefix) -def _uv(*args: str) -> subprocess.CompletedProcess: +project_opt = typer.Option( + _default_project(), + "--project", + help="Path to the project directory (default: ~/.bub/bub-project)", + envvar="BUB_PROJECT", +) + + +def _uv(*args: str, cwd: Path) -> subprocess.CompletedProcess: uv_executable = _find_uv() if not _is_in_venv(): typer.secho("Please install Bub in a virtual environment to use this command.", err=True, fg="red") raise typer.Exit(1) env = {**os.environ, "VIRTUAL_ENV": sys.prefix} try: - return subprocess.run([uv_executable, *args], env=env, check=True) + return subprocess.run([uv_executable, *args], env=env, check=True, cwd=cwd) except subprocess.CalledProcessError as e: typer.secho(f"Command 'uv {' '.join(args)}' failed with exit code {e.returncode}.", err=True, fg="red") raise typer.Exit(e.returncode) from e @@ -136,40 +154,49 @@ def _build_requirement(spec: str) -> str: return f"git+{BUB_CONTRIB_REPO}{ref}#subdirectory=packages/{name}" -def _ensure_project() -> None: - if (Path.cwd() / "pyproject.toml").is_file(): +def _ensure_project(project: Path) -> None: + if (project / "pyproject.toml").is_file(): return - _uv("init", "--bare", "--name", "bub-project", "--app") + _uv("init", "--bare", "--name", "bub-project", "--app", cwd=project) def install( - spec: str = typer.Argument( - ..., help="Package specification to install, can be a git URL, owner/repo, or package name in bub-contrib." + specs: list[str] = typer.Argument( + default_factory=list, + help="Package specification to install, can be a git URL, owner/repo, or package name in bub-contrib.", ), + project: Path = project_opt, ) -> None: - """Install a plugin into Bub's environment.""" - _ensure_project() - req = _build_requirement(spec) - _uv("add", "--active", req) + """Install a plugin into Bub's environment, or sync the environment if no specifications are provided.""" + _ensure_project(project) + if not specs: + _uv("sync", "--active", "--inexact", cwd=project) + else: + _uv("add", "--active", *map(_build_requirement, specs), cwd=project) def uninstall( - package: str = typer.Argument(..., help="Package name to uninstall (must match the name in pyproject.toml)"), + packages: list[str] = typer.Argument(..., help="Package name to uninstall (must match the name in pyproject.toml)"), + project: Path = project_opt, ) -> None: """Uninstall a plugin from Bub's environment.""" - _ensure_project() - _uv("remove", "--active", "--no-sync", package) - _uv("sync", "--active", "--frozen", "--inexact") + _ensure_project(project) + _uv("remove", "--active", "--no-sync", *packages, cwd=project) + _uv("sync", "--active", "--frozen", "--inexact", cwd=project) def update( - package: str | None = typer.Argument( - None, help="Optional package name to update (must match the name in pyproject.toml)" + packages: list[str] = typer.Argument( + default_factory=list, help="Optional package name to update (must match the name in pyproject.toml)" ), + project: Path = project_opt, ) -> None: """Update selected package or all packages in Bub's environment.""" - _ensure_project() - if package is None: - _uv("sync", "--active", "--upgrade", "--inexact") + _ensure_project(project) + if not packages: + _uv("sync", "--active", "--upgrade", "--inexact", cwd=project) else: - _uv("sync", "--active", "--inexact", "--upgrade-package", package) + package_args: list[str] = [] + for pkg in packages: + package_args.extend(["--upgrade-package", pkg]) + _uv("sync", "--active", "--inexact", *package_args, cwd=project) From 1ddc8bbebd2532308cca02e90fd2f5a968247934 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 10 Apr 2026 15:13:07 +0800 Subject: [PATCH 4/4] fix: update project option initialization in CLI to use default_factory Signed-off-by: Frost Ming --- src/bub/builtin/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index d84ec536..f8c474a7 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -115,8 +115,7 @@ def _is_in_venv() -> bool: project_opt = typer.Option( - _default_project(), - "--project", + default_factory=_default_project, help="Path to the project directory (default: ~/.bub/bub-project)", envvar="BUB_PROJECT", )