Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ Tau is also available on [conda-forge](https://conda-forge.org), and can be inst
pixi global install tau-ai
```

Upgrade a normal installation with:

```bash
tau update
```

For local development:

```bash
Expand Down
17 changes: 14 additions & 3 deletions dev-notes/architecture/startup-update-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,33 @@ Tau now performs a small, best-effort update check in CLI startup paths that lau
- The result is cached under `~/.tau/cache/update-check.json` and refreshed at most once per day.
- Failures are quiet no-ops: network errors, malformed JSON, missing fields, and invalid versions do not stop startup.
- `TAU_NO_UPDATE_CHECK=1` disables the check, and the check is skipped automatically when `CI` is set.
- `tau update` upgrades `tau-ai` with the package manager that owns the active Tau environment.

## Where it belongs

This lives in `tau_coding`, not `tau_agent`, because update notification is CLI application behavior. The reusable agent harness remains independent of PyPI, Rich/Textual UI concerns, and Tau's home-directory layout.

## Output policy

- TUI startup passes the notice through the existing startup notification path.
- TUI startup renders the update notice as the first transcript item in fixed bright-yellow, bold styling, before release notes, provider errors, theme diagnostics, or session history.
- Print mode writes the notice to stderr for normal text output.
- Structured print output (`--output json`) suppresses the notice to avoid corrupting scripted output.
- Utility commands (`tau --version`, `tau sessions`, `tau export`, `tau providers`, `tau setup`) do not run the update check.
- Utility commands (`tau --version`, `tau update`, `tau sessions`, `tau export`, `tau providers`, `tau setup`) do not run the update check.

## Update command

`tau update` inspects the active environment before running anything:

- `uv-receipt.toml` means uv owns the tool. Tau fetches the latest stable PyPI version and runs `uv tool install tau-ai@<latest-version>`, explicitly replacing any version pin recorded when the tool was installed.
- `pipx_metadata.json` means pipx owns it, so Tau runs `pipx upgrade tau-ai`.
- The distribution's standard `INSTALLER` metadata identifies ordinary uv and pip installs. Tau runs either `uv pip install --python <current-python> --upgrade tau-ai` or `<current-python> -m pip install --upgrade tau-ai`, targeting the environment that is running Tau.

Tau does not fall through to another installer when the selected command fails. Direct-URL and editable installs are sent back to their original source; Conda/Pixi-managed and unrecognized environments get manual instructions rather than being modified with pip. Editable checkout installs can be refreshed with `uv tool install --editable --force .`.

## Testing

Run:

```bash
uv run pytest tests/test_update_check.py tests/test_cli.py tests/test_tui_app.py
uv run pytest tests/test_updater.py tests/test_update_check.py tests/test_cli.py tests/test_tui_app.py
```
34 changes: 25 additions & 9 deletions src/tau_coding/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
startup_release_notes_notice,
startup_update_notice,
)
from tau_coding.updater import update_tau
from tau_coding.version import current_version as _current_version


Expand Down Expand Up @@ -246,6 +247,12 @@ def main(
command = positional_args[0] if positional_args else None
initial_prompt = " ".join(positional_args) if positional_args else None

if prompt_option is None and command == "update":
if len(positional_args) != 1:
raise typer.BadParameter("Usage: tau update")
update_command()
raise typer.Exit()

if prompt_option is None and command == "sessions" and len(positional_args) == 1:
render_session_list(SessionManager().list_sessions())
raise typer.Exit()
Expand Down Expand Up @@ -349,14 +356,7 @@ async def run_openai_tui(
) -> None:
"""Run the Textual TUI with the default OpenAI-compatible provider."""
release_notes_notice = startup_release_notes_notice(_current_version())
startup_notices = [
notice
for notice in (
release_notes_notice.message if release_notes_notice is not None else None,
update_notice.message if update_notice is not None else None,
)
if notice is not None
]
startup_notices = (release_notes_notice.message,) if release_notes_notice is not None else ()
await run_tui_app(
model=model,
cwd=cwd,
Expand All @@ -365,7 +365,8 @@ async def run_openai_tui(
provider_name=provider_name,
auto_compact_token_threshold=auto_compact_token_threshold,
initial_prompt=initial_prompt,
startup_notices=tuple(startup_notices),
startup_update_notice=update_notice.message if update_notice is not None else None,
startup_notices=startup_notices,
extension_paths=extension_paths,
extensions_enabled=extensions_enabled,
project_extensions_enabled=project_extensions_enabled,
Expand All @@ -376,6 +377,21 @@ def _startup_update_notice() -> UpdateNotice | None:
return startup_update_notice(_current_version())


def update_command() -> None:
"""Upgrade Tau using the installer that manages the current environment."""
result = update_tau()
if not result.succeeded:
typer.echo("Could not safely update Tau:", err=True)
for failure in result.failures:
typer.echo(f"- {failure}", err=True)
raise typer.Exit(1)
if result.stdout:
typer.echo(result.stdout)
if result.stderr:
typer.echo(result.stderr, err=True)
typer.echo(f"Tau update completed with: {' '.join(result.command or ())}")


def render_session_list(records: list[CodingSessionRecord]) -> None:
"""Render indexed sessions for the CLI."""
if not records:
Expand Down
5 changes: 5 additions & 0 deletions src/tau_coding/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2850,6 +2850,7 @@ def __init__(
tui_settings: TuiSettings | None = None,
startup_message: str | None = None,
startup_notice: str | None = None,
startup_update_notice: str | None = None,
startup_notices: Sequence[str] = (),
initial_prompt: str | None = None,
) -> None:
Expand All @@ -2870,6 +2871,8 @@ def __init__(
self._bindings = BindingsMap(_app_bindings(self.tui_settings.keybindings))
self.session = session
self.state = TuiState(skills=session.skills)
if startup_update_notice is not None:
self.state.add_item("status", startup_update_notice, highlight="update")
for notice in self.startup_notices:
self.state.add_item("status", notice)
if self.tui_settings.theme != self.tui_settings.resolved_theme.name:
Expand Down Expand Up @@ -5986,6 +5989,7 @@ async def run_tui_app(
initial_prompt: str | None = None,
session_manager: SessionManager | None = None,
startup_notice: str | None = None,
startup_update_notice: str | None = None,
startup_notices: Sequence[str] = (),
extension_paths: tuple[Path, ...] = (),
extensions_enabled: bool = True,
Expand Down Expand Up @@ -6079,6 +6083,7 @@ async def run_tui_app(
session,
tui_settings=load_tui_settings(),
startup_message=startup_message,
startup_update_notice=startup_update_notice,
startup_notices=all_startup_notices,
initial_prompt=initial_prompt,
)
Expand Down
4 changes: 4 additions & 0 deletions src/tau_coding/tui/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections.abc import Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal

from tau_agent.messages import (
AgentMessage,
Expand Down Expand Up @@ -52,6 +53,7 @@ class ChatItem:
always_show_tool_result: bool = False
custom_type: str | None = None
details: dict[str, JSONValue] | None = None
highlight: Literal["update"] | None = None


@dataclass(slots=True)
Expand Down Expand Up @@ -87,6 +89,7 @@ def add_item(
always_show_tool_result: bool = False,
custom_type: str | None = None,
details: dict[str, JSONValue] | None = None,
highlight: Literal["update"] | None = None,
) -> None:
"""Append a transcript item."""
item = ChatItem(
Expand All @@ -97,6 +100,7 @@ def add_item(
always_show_tool_result=always_show_tool_result,
custom_type=custom_type,
details=details,
highlight=highlight,
)
self.items.append(item)
if tool_call_id is not None and role in {"tool", "skill"}:
Expand Down
4 changes: 3 additions & 1 deletion src/tau_coding/tui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1353,7 +1353,7 @@ def _split_rich_style_colors(style: str) -> tuple[str | None, str | None]:

def _use_plain_transcript_body(item: ChatItem) -> bool:
"""Return whether a transcript item can use fast selectable plain text."""
return item.role in {"user", "tool", "skill", "error"}
return item.highlight == "update" or item.role in {"user", "tool", "skill", "error"}


def _transcript_plain_body_text(
Expand Down Expand Up @@ -1634,6 +1634,8 @@ def render_chat_item(


def _chat_item_role_style(item: ChatItem, theme: TuiTheme) -> TuiRoleStyle:
if item.highlight == "update":
return TuiRoleStyle(border="#ffff00", body="bold #ffff00")
if item.role == "tool" and item.tool_result_text:
if item.tool_result_text.startswith("✓"):
return TuiRoleStyle(border=theme.success, body=theme.role_styles["tool"].body)
Expand Down
2 changes: 1 addition & 1 deletion src/tau_coding/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def message(self) -> str:
"""Return concise update guidance."""
return (
f"Tau {self.latest_version} is available (installed: {self.current_version}). "
f"Update with: uv tool upgrade {self.package_name}"
"Run `tau update` to upgrade."
)


Expand Down
175 changes: 175 additions & 0 deletions src/tau_coding/updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Upgrade Tau with the package manager that owns its environment."""

from __future__ import annotations

import json
import sys
from collections.abc import Callable
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
from subprocess import CompletedProcess, run
from typing import Literal

from tau_coding.update_check import PYPI_PACKAGE_NAME, fetch_latest_pypi_version

CommandRunner = Callable[..., CompletedProcess[str]]
LatestVersionFetcher = Callable[[], str | None]
InstallMethod = Literal["uv-tool", "uv-pip", "pipx", "pip"]


@dataclass(frozen=True, slots=True)
class UpdateResult:
"""Result of trying to upgrade Tau."""

command: tuple[str, ...] | None
stdout: str = ""
stderr: str = ""
failures: tuple[str, ...] = ()

@property
def succeeded(self) -> bool:
return self.command is not None


def update_tau(
*,
runner: CommandRunner = run,
python_executable: str | None = None,
environment_prefix: Path | None = None,
direct_url: str | None = None,
installer: str | None = None,
inspect_distribution: bool = True,
latest_version_fetcher: LatestVersionFetcher = fetch_latest_pypi_version,
) -> UpdateResult:
"""Upgrade Tau with the installer that owns the active environment.

Python distributions record their installer in ``INSTALLER``. uv and pipx
tool environments also leave ownership receipts. Managed, editable,
direct-URL, and unrecognized installations stop with instructions instead
of trying unrelated package managers.
"""
prefix = (environment_prefix or Path(sys.prefix)).resolve()
if inspect_distribution:
direct_url = _installed_direct_url()
installer = _installed_installer()
if direct_url:
return _failure(
"Tau was installed from a local or direct URL, so it cannot be safely "
f"updated from PyPI. Reinstall it from its original source: {direct_url}"
)

method = detect_install_method(prefix, installer=installer)
if method is None:
if (prefix / "conda-meta").is_dir():
return _failure(
"Tau is installed in a Conda/Pixi-managed environment. "
"Update it with the manager that created that environment."
)
installed_by = installer or "unknown"
return _failure(
"Could not identify a supported installer for this Tau environment "
f"({prefix}). Package metadata reports: {installed_by}."
)

latest_version: str | None = None
if method == "uv-tool":
try:
latest_version = latest_version_fetcher()
except Exception as exc: # noqa: BLE001 - report update lookup failures to the user
return _failure(f"Could not determine the latest Tau version from PyPI: {exc}")
if latest_version is None:
return _failure("Could not determine the latest Tau version from PyPI.")

executable = python_executable or sys.executable
command = _update_command(method, executable, latest_version=latest_version)
completed = _run(runner, command)
if isinstance(completed, str):
return _failure(f"{' '.join(command)}: {completed}")
if completed.returncode != 0:
return _failure(f"{' '.join(command)}: {_result_detail(completed)}")
return UpdateResult(
command=command,
stdout=completed.stdout.strip(),
stderr=completed.stderr.strip(),
)


def detect_install_method(prefix: Path, *, installer: str | None = None) -> InstallMethod | None:
"""Detect installer ownership from receipts and distribution metadata."""
if (prefix / "uv-receipt.toml").is_file():
return "uv-tool"
if (prefix / "pipx_metadata.json").is_file():
return "pipx"
normalized_installer = installer.strip().lower() if installer else None
if normalized_installer == "uv":
return "uv-pip"
if normalized_installer == "pip":
return "pip"
return None


def _update_command(
method: InstallMethod,
python_executable: str,
*,
latest_version: str | None = None,
) -> tuple[str, ...]:
if method == "uv-tool":
if latest_version is None:
raise ValueError("latest_version is required for uv tool updates")
return ("uv", "tool", "install", f"{PYPI_PACKAGE_NAME}@{latest_version}")
if method == "uv-pip":
return (
"uv",
"pip",
"install",
"--python",
python_executable,
"--upgrade",
PYPI_PACKAGE_NAME,
)
if method == "pipx":
return ("pipx", "upgrade", PYPI_PACKAGE_NAME)
return (python_executable, "-m", "pip", "install", "--upgrade", PYPI_PACKAGE_NAME)


def _installed_direct_url() -> str | None:
raw = _distribution_file("direct_url.json")
if not raw:
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return "an unrecognized direct source"
url = data.get("url") if isinstance(data, dict) else None
return url if isinstance(url, str) and url else "an unrecognized direct source"


def _installed_installer() -> str | None:
raw = _distribution_file("INSTALLER")
if not raw:
return None
return raw.strip() or None


def _distribution_file(filename: str) -> str | None:
try:
return distribution(PYPI_PACKAGE_NAME).read_text(filename)
except PackageNotFoundError:
return None


def _run(runner: CommandRunner, command: tuple[str, ...]) -> CompletedProcess[str] | str:
try:
return runner(command, capture_output=True, text=True, check=False)
except OSError as exc:
return str(exc)


def _result_detail(result: CompletedProcess[str]) -> str:
return result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"


def _failure(message: str) -> UpdateResult:
return UpdateResult(command=None, failures=(message,))
Loading