fix(packaging): keep source clients in lockstep

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 78802c4c4e
commit 7384fbfffc
11 changed files with 175 additions and 45 deletions
+13
View File
@@ -136,6 +136,19 @@ GitHub Actions' free tier:
If your change genuinely needs to step outside this, please call it out
explicitly in the PR description so it can be discussed before merge.
## Release Packaging Contract
A stable install must never combine Python from one version with a TUI from another. Publish in
this order:
1. Set the package version and publish the matching GitHub release tag (`vX.Y.Z`).
2. Wait for `tui-release.yml` to upload every supported native TUI and checksum to that release.
3. Only then publish the same `X.Y.Z` package to PyPI.
The wheel contains the built WebUI. The native TUI stays a platform-specific release sidecar so
users download only the binary for their machine. Source checkouts use an editable Python install,
run `tui/` with Bun, and rebuild stale `webui/` assets locally.
## Questions?
If you have questions, ideas, or half-formed insights, you are warmly welcome here.
+22 -15
View File
@@ -77,7 +77,12 @@ nanobot is a self-hosted personal AI agent runtime. It can:
Pick **one** install method:
Prerequisites: Python 3.11 or newer. Git is only needed for a source install. Published packages already include the WebUI; a current-source install needs `bun` or `npm` to build it.
| Track | Install with | Update with | What runs |
|---|---|---|---|
| Stable | installer, `uv`, or pip | the same package tool | one released Python/WebUI/TUI version |
| Current source | editable Git checkout | `git pull --ff-only` + editable dependency sync | Python, WebUI, and TUI from that checkout |
Prerequisites: Python 3.11 or newer. Git and [Bun](https://bun.sh/) are only needed for a source install. Published packages include the WebUI and fetch a checksummed, version-matched TUI on first use.
If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path.
@@ -97,7 +102,7 @@ irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | i
The default command installs or upgrades `nanobot-ai` from PyPI. On a fresh local desktop, it then starts `nanobot webui` so you can configure the first provider and model in **Settings → Models**. SSH, headless, existing-config, and older-release paths keep the terminal setup wizard. The installer avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. It also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
To preview the plan without changing your environment, pass `--dry-run`.
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
@@ -107,16 +112,6 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
```
To install the current `main` branch instead, pass `--dev`:
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
```
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
```
If you prefer to inspect the script first, open [`scripts/install.sh`](./scripts/install.sh) or [`scripts/install.ps1`](./scripts/install.ps1).
**Install with `uv`**
@@ -135,15 +130,27 @@ If pip reports `externally-managed-environment` on macOS or Linux, use the one-c
**Install from source**
`bun` or `npm` must be available. From an activated virtual environment:
Clone the repository and install it in editable mode. Bun is required because the source
checkout runs the matching TUI directly instead of downloading an older release binary.
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install .
python -m venv .venv
```
On Windows, if pip reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install. Contributors who need an editable checkout should follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`webui/README.md`](./webui/README.md).
Activate it with `source .venv/bin/activate` on macOS/Linux or
`.venv\Scripts\Activate.ps1` in Windows PowerShell, then run:
```bash
python -m pip install -e .
```
After that, the normal commands are identical to a stable install. `nanobot agent` runs the TUI
from this checkout, and `nanobot webui` rebuilds stale frontend assets automatically. A later
`git pull --ff-only` updates the Python, TUI, and WebUI source together; rerun
`python -m pip install -e .` when Python dependencies change. Contributors should also read
[`CONTRIBUTING.md`](./CONTRIBUTING.md).
Verify the install:
+1 -1
View File
@@ -127,7 +127,7 @@ The default `--theme auto` mode probes the terminal's real foreground and backgr
`Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message to the composer. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback when a terminal cannot distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable. Use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. An editable source checkout requires Bun and always runs its own `tui/` source; it never silently falls back to a release binary.
Non-interactive input/output, `--logs`, and `--no-markdown` automatically retain the classic prompt so existing scripts and diagnostic workflows do not acquire terminal control sequences or silently ignore their options.
+24 -10
View File
@@ -12,7 +12,7 @@ These repository docs follow current `main`. The recommended installer uses the
- Access to one supported AI provider, company endpoint, or local model server.
- The credential, endpoint URL, and model ID required by that service. Local providers such as Ollama may not require a key.
Git is only needed for a source install. The published package already contains the WebUI. A current-source install needs `bun` or `npm` so its WebUI bundle can be built.
Git and [Bun](https://bun.sh/) are only needed for a source install. The published package already contains the WebUI and fetches a checksummed, version-matched TUI on first use.
## 1. Install nanobot
@@ -30,7 +30,7 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release unless you explicitly pass `--dev`. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below.
The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below.
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
@@ -155,18 +155,28 @@ If pip reports `externally-managed-environment`, use the recommended installer,
**Current source**
`bun` or `npm` must be available. Activate a virtual environment first, then run:
Clone the repository and install it in editable mode. Bun is required so the checkout can run
its matching native TUI instead of mixing current Python with an older release binary.
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install .
python -m venv .venv
```
Activate it with `source .venv/bin/activate` on macOS/Linux or
`.venv\Scripts\Activate.ps1` in Windows PowerShell, then run:
```bash
python -m pip install -e .
nanobot webui
```
On Windows, if `python -m pip install .` reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install.
The source path follows current `main` and can be newer than the published package. A non-editable install triggers the build hook that bundles the current WebUI. For editable Python or frontend development, follow [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../webui/README.md`](../webui/README.md).
The source path follows current `main` and can be newer than the published package. The editable
install keeps Python pointed at the checkout; `nanobot agent` runs `tui/` with Bun, and
`nanobot webui` automatically rebuilds `webui/` when its bundled assets are stale. All normal
commands remain the same as a stable install. For development details, follow
[`../CONTRIBUTING.md`](../CONTRIBUTING.md).
If the package is installed but the shell cannot find `nanobot`, use the runner that owns the installation. The recommended installer prints the exact command to reuse. Common forms are:
@@ -225,11 +235,15 @@ python -m pip install -U nanobot-ai
For a source checkout:
```bash
git pull
python -m pip install .
git pull --ff-only
python -m pip install -e .
```
Then check `nanobot --version`. Run `nanobot onboard --refresh` when you want to add newly introduced default fields while preserving existing settings.
Because the install is editable, normal source changes are visible immediately. Re-running the
install synchronizes any changed Python dependencies; the TUI and WebUI refresh their own
dependencies/assets when launched. Then check `nanobot --version`. Run
`nanobot onboard --refresh` when you want to add newly introduced default fields while preserving
existing settings.
## If the First Reply Fails
+19 -5
View File
@@ -120,15 +120,20 @@ def _resolve_tui_command() -> list[str]:
"on that platform; use the classic prompt until the upstream runtime supports it"
)
asset = f"nanobot-tui-{system}-{machine}{suffix}"
source_dir = _source_checkout_tui_dir()
if source_dir is not None:
bun = shutil.which("bun")
if not bun:
raise TuiUnavailableError(
"this source checkout requires Bun to run its matching TUI; "
"install Bun, then run `nanobot agent` again"
)
return _resolve_source_tui_command(source_dir, bun)
packaged = Path(__file__).resolve().parents[1] / "tui" / "bin" / asset
if packaged.is_file():
return [str(packaged)]
source_dir = Path(__file__).resolve().parents[2] / "tui"
bun = shutil.which("bun")
if bun and (source_dir / "package.json").is_file():
return _resolve_source_tui_command(source_dir, bun)
downloaded = _download_release_tui(asset)
if downloaded is not None:
return [str(downloaded)]
@@ -139,6 +144,15 @@ def _resolve_tui_command() -> list[str]:
)
def _source_checkout_tui_dir() -> Path | None:
"""Return this checkout's TUI source, never a neighboring unrelated directory."""
project_root = Path(__file__).resolve().parents[2]
source_dir = project_root / "tui"
if (project_root / "pyproject.toml").is_file() and (source_dir / "package.json").is_file():
return source_dir
return None
def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
dependency = source_dir / "node_modules" / "@opentui" / "core"
try:
+4 -4
View File
@@ -218,6 +218,10 @@ def webui(
)
webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
_prepare_webui_bundle_for_gateway(
runtime_config,
mode="skip" if dev else webui_bundle_mode,
)
config_arg = str(config_path)
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
@@ -236,10 +240,6 @@ def webui(
def ensure_shared_gateway(*, client_lease: GatewayClientLease) -> None:
"""Start or refresh the one managed gateway shared by local clients."""
_prepare_webui_bundle_for_gateway(
runtime_config,
mode="skip" if dev else webui_bundle_mode,
)
result = client_lease.ensure_on_demand_gateway(start_options)
restarted = False
restart_attempted = False
+6
View File
@@ -24,6 +24,7 @@ from nanobot.webui.build import (
BuildMode,
WebUIBuildError,
ensure_webui_bundle,
inspect_webui_bundle,
)
if TYPE_CHECKING:
@@ -191,6 +192,11 @@ def _prepare_webui_bundle_for_gateway(
return typer.confirm(message, default=True)
try:
# A source checkout is the development product. Every gateway entrypoint
# keeps its browser client in lockstep with Python; only Vite mode skips
# the production bundle intentionally.
if mode != "skip" and inspect_webui_bundle().source_available:
mode = "auto"
ensure_webui_bundle(
mode=mode,
confirm=_confirm if mode == "prompt" else None,
+4 -5
View File
@@ -8,7 +8,6 @@ param(
$ErrorActionPreference = "Stop"
$Package = "nanobot-ai"
$MainSource = "https://github.com/HKUDS/nanobot/archive/refs/heads/main.zip"
$InstallTarget = $Package
$InstallSource = "PyPI"
$script:NanobotRunner = $null
@@ -38,11 +37,12 @@ function Show-InstallFailureHint {
}
function Show-Usage {
Write-Host "Usage: install.ps1 [-Dev|--dev] [-DryRun|--dry-run]"
Write-Host "Usage: install.ps1 [-DryRun|--dry-run]"
Write-Host ""
Write-Host "By default this installs or upgrades nanobot-ai from PyPI."
Write-Host "Use --dev to install from the current main branch on GitHub."
Write-Host "Use --dry-run to print what would happen without installing or starting setup."
Write-Host ""
Write-Host "For current main, clone the repository and run 'python -m pip install -e .'."
}
function Test-Python {
@@ -245,8 +245,7 @@ foreach ($Arg in $RemainingArgs) {
}
if ($Dev) {
$InstallTarget = $MainSource
$InstallSource = "GitHub main"
Fail "--dev installed an untracked main snapshot and is no longer supported; clone the repository and run 'python -m pip install -e .' instead."
}
$Python = Find-Python
+4 -5
View File
@@ -2,7 +2,6 @@
set -eu
package="nanobot-ai"
main_source="https://github.com/HKUDS/nanobot/archive/refs/heads/main.zip"
install_target="$package"
install_source="PyPI"
dry_run="0"
@@ -32,11 +31,12 @@ install_failure_hint() {
usage() {
cat <<'EOF'
Usage: install.sh [--dev] [--dry-run]
Usage: install.sh [--dry-run]
By default this installs or upgrades nanobot-ai from PyPI.
Use --dev to install from the current main branch on GitHub.
Use --dry-run to print what would happen without installing or starting setup.
For current main, clone the repository and run `python -m pip install -e .`.
EOF
}
@@ -200,8 +200,7 @@ PY
while [ "$#" -gt 0 ]; do
case "$1" in
--dev)
install_target="$main_source"
install_source="GitHub main"
fail "--dev installed an untracked main snapshot and is no longer supported; clone the repository and run 'python -m pip install -e .' instead"
;;
--dry-run)
dry_run="1"
+40
View File
@@ -6,6 +6,7 @@ from types import SimpleNamespace
import pytest
import typer
import nanobot.cli.tui_launcher as tui_launcher
from nanobot.cli.agent import agent
from nanobot.cli.tui_launcher import (
TuiSessionError,
@@ -146,6 +147,45 @@ def test_windows_arm64_fails_instead_of_using_the_classic_prompt(
_resolve_tui_command()
def test_source_checkout_does_not_fall_back_to_a_release_tui_without_bun(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = tmp_path / "tui"
source_dir.mkdir()
monkeypatch.delenv("NANOBOT_TUI_BIN", raising=False)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._source_checkout_tui_dir",
lambda: source_dir,
)
monkeypatch.setattr("nanobot.cli.tui_launcher.shutil.which", lambda _name: None)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._download_release_tui",
lambda _asset: pytest.fail("a source checkout must not download a release TUI"),
)
with pytest.raises(TuiUnavailableError, match="source checkout requires Bun"):
_resolve_tui_command()
def test_source_checkout_requires_project_and_tui_markers(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
project_root = tmp_path / "project"
module_path = project_root / "nanobot" / "cli" / "tui_launcher.py"
source_dir = project_root / "tui"
module_path.parent.mkdir(parents=True)
source_dir.mkdir()
monkeypatch.setattr(tui_launcher, "__file__", str(module_path))
assert tui_launcher._source_checkout_tui_dir() is None
(source_dir / "package.json").write_text("{}", encoding="utf-8")
assert tui_launcher._source_checkout_tui_dir() is None
(project_root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
assert tui_launcher._source_checkout_tui_dir() == source_dir
def test_interactive_agent_uses_native_tui(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+38
View File
@@ -0,0 +1,38 @@
from types import SimpleNamespace
from nanobot.cli.webui_support import _prepare_webui_bundle_for_gateway
from nanobot.config.schema import Config
def test_source_checkout_rebuilds_the_webui_from_every_gateway_entrypoint(monkeypatch) -> None:
modes: list[str] = []
monkeypatch.setattr(
"nanobot.cli.webui_support.inspect_webui_bundle",
lambda: SimpleNamespace(source_available=True),
)
monkeypatch.setattr("nanobot.cli.webui_support._webui_channel_enabled", lambda _config: True)
monkeypatch.setattr(
"nanobot.cli.webui_support.ensure_webui_bundle",
lambda **kwargs: modes.append(kwargs["mode"]),
)
_prepare_webui_bundle_for_gateway(Config(), mode="warn")
assert modes == ["auto"]
def test_vite_mode_does_not_build_the_source_webui_bundle(monkeypatch) -> None:
modes: list[str] = []
monkeypatch.setattr(
"nanobot.cli.webui_support.inspect_webui_bundle",
lambda: SimpleNamespace(source_available=True),
)
monkeypatch.setattr("nanobot.cli.webui_support._webui_channel_enabled", lambda _config: True)
monkeypatch.setattr(
"nanobot.cli.webui_support.ensure_webui_bundle",
lambda **kwargs: modes.append(kwargs["mode"]),
)
_prepare_webui_bundle_for_gateway(Config(), mode="skip")
assert modes == ["skip"]