Skip to content

Commit 1ea718b

Browse files
authored
feat: self-management commands (#155)
* feat: add install, uninstall, and update commands to CLI Signed-off-by: Frost Ming <me@frostming.com> * fix: temporarily disable uninstall command due to functionality issues Signed-off-by: Frost Ming <me@frostming.com> * feat: enhance project management in CLI with project path option and improved install/uninstall/update commands Signed-off-by: Frost Ming <me@frostming.com> * fix: update project option initialization in CLI to use default_factory Signed-off-by: Frost Ming <me@frostming.com>
1 parent cdd60cb commit 1ea718b

2 files changed

Lines changed: 122 additions & 0 deletions

File tree

src/bub/builtin/cli.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
from __future__ import annotations
55

66
import asyncio
7+
import os
8+
import subprocess
9+
import sys
10+
from functools import lru_cache
11+
from pathlib import Path
712

813
import typer
914

@@ -81,3 +86,116 @@ def chat(
8186
raise typer.Exit(1)
8287
channel.set_metadata(chat_id=chat_id, session_id=session_id) # type: ignore[attr-defined]
8388
asyncio.run(manager.listen_and_run())
89+
90+
91+
@lru_cache(maxsize=1)
92+
def _find_uv() -> str:
93+
import shutil
94+
import sysconfig
95+
96+
bin_path = sysconfig.get_path("scripts")
97+
uv_path = shutil.which("uv", path=os.pathsep.join([bin_path, os.getenv("PATH", "")]))
98+
if uv_path is None:
99+
raise FileNotFoundError("uv executable not found in PATH or scripts directory.")
100+
return uv_path
101+
102+
103+
@lru_cache(maxsize=1)
104+
def _default_project() -> Path:
105+
from .settings import load_settings
106+
107+
settings = load_settings()
108+
project = settings.home / "bub-project"
109+
project.mkdir(exist_ok=True, parents=True)
110+
return project
111+
112+
113+
def _is_in_venv() -> bool:
114+
return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
115+
116+
117+
project_opt = typer.Option(
118+
default_factory=_default_project,
119+
help="Path to the project directory (default: ~/.bub/bub-project)",
120+
envvar="BUB_PROJECT",
121+
)
122+
123+
124+
def _uv(*args: str, cwd: Path) -> subprocess.CompletedProcess:
125+
uv_executable = _find_uv()
126+
if not _is_in_venv():
127+
typer.secho("Please install Bub in a virtual environment to use this command.", err=True, fg="red")
128+
raise typer.Exit(1)
129+
env = {**os.environ, "VIRTUAL_ENV": sys.prefix}
130+
try:
131+
return subprocess.run([uv_executable, *args], env=env, check=True, cwd=cwd)
132+
except subprocess.CalledProcessError as e:
133+
typer.secho(f"Command 'uv {' '.join(args)}' failed with exit code {e.returncode}.", err=True, fg="red")
134+
raise typer.Exit(e.returncode) from e
135+
136+
137+
BUB_CONTRIB_REPO = "https://github.com/bubbuild/bub-contrib.git"
138+
139+
140+
def _build_requirement(spec: str) -> str:
141+
if spec.startswith(("git@", "https://")):
142+
# Git URL
143+
return f"git+{spec}"
144+
elif "/" in spec:
145+
# owner/repo format
146+
repo, *rest = spec.partition("@")
147+
ref = "".join(rest)
148+
return f"git+https://github.com/{repo}.git{ref}"
149+
else:
150+
# Assume it's a package name in bub-contrib
151+
name, *rest = spec.partition("@")
152+
ref = "".join(rest)
153+
return f"git+{BUB_CONTRIB_REPO}{ref}#subdirectory=packages/{name}"
154+
155+
156+
def _ensure_project(project: Path) -> None:
157+
if (project / "pyproject.toml").is_file():
158+
return
159+
_uv("init", "--bare", "--name", "bub-project", "--app", cwd=project)
160+
161+
162+
def install(
163+
specs: list[str] = typer.Argument(
164+
default_factory=list,
165+
help="Package specification to install, can be a git URL, owner/repo, or package name in bub-contrib.",
166+
),
167+
project: Path = project_opt,
168+
) -> None:
169+
"""Install a plugin into Bub's environment, or sync the environment if no specifications are provided."""
170+
_ensure_project(project)
171+
if not specs:
172+
_uv("sync", "--active", "--inexact", cwd=project)
173+
else:
174+
_uv("add", "--active", *map(_build_requirement, specs), cwd=project)
175+
176+
177+
def uninstall(
178+
packages: list[str] = typer.Argument(..., help="Package name to uninstall (must match the name in pyproject.toml)"),
179+
project: Path = project_opt,
180+
) -> None:
181+
"""Uninstall a plugin from Bub's environment."""
182+
_ensure_project(project)
183+
_uv("remove", "--active", "--no-sync", *packages, cwd=project)
184+
_uv("sync", "--active", "--frozen", "--inexact", cwd=project)
185+
186+
187+
def update(
188+
packages: list[str] = typer.Argument(
189+
default_factory=list, help="Optional package name to update (must match the name in pyproject.toml)"
190+
),
191+
project: Path = project_opt,
192+
) -> None:
193+
"""Update selected package or all packages in Bub's environment."""
194+
_ensure_project(project)
195+
if not packages:
196+
_uv("sync", "--active", "--upgrade", "--inexact", cwd=project)
197+
else:
198+
package_args: list[str] = []
199+
for pkg in packages:
200+
package_args.extend(["--upgrade-package", pkg])
201+
_uv("sync", "--active", "--inexact", *package_args, cwd=project)

src/bub/builtin/hook_impl.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ def register_cli_commands(self, app: typer.Typer) -> None:
118118
app.add_typer(cli.login_app)
119119
app.command("hooks", hidden=True)(cli.list_hooks)
120120
app.command("gateway")(cli.gateway)
121+
app.command("install")(cli.install)
122+
# TODO: uninstall command can't work properly
123+
# app.command("uninstall")(cli.uninstall)
124+
app.command("update")(cli.update)
121125

122126
def _read_agents_file(self, state: State) -> str:
123127
workspace = state.get("_runtime_workspace", str(Path.cwd()))

0 commit comments

Comments
 (0)