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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **Toggle auto-update from the CLI.** `/update auto on|off` turns silent startup auto-updates on or off (and `/update auto` reports the effective state); the same toggle now appears in the interactive `/settings` panel, and `pythinker info` reports the auto-update status. All three show the *effective* state — an external override (`PYTHINKER_CLI_NO_AUTO_UPDATE` or a source checkout) is surfaced as the reason and renders the `/settings` row read-only, so the toggle is never a silent no-op.

## 0.43.0 (2026-06-13)

- **Silent startup auto-updates (default on).** Managed and native installs now check for and apply updates in the background at startup, surfacing a restart-to-apply notice instead of a blocking prompt. Opt out with `auto_update = false` in config or `PYTHINKER_AUTO_UPDATE=0` in the environment. The Windows update path that replaces the running binary no longer escapes as an uncaught `SystemExit` and crashes the shell.
Expand Down
7 changes: 7 additions & 0 deletions docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ Check for and optionally install the latest Pythinker Code version.

Alias: `/upgrade`

Use `/update auto on` or `/update auto off` to turn silent startup auto-updates
on or off (persisted to the `auto_update` config field); `/update auto` with no
argument reports the effective state. When an external override is active — the
`PYTHINKER_CLI_NO_AUTO_UPDATE` kill-switch or a source checkout — it is surfaced
as the reason and outranks the setting. The same toggle is available in
`/settings`, and `pythinker info` reports the auto-update status.

### `/reload`

Reload the configuration file without exiting Pythinker Code.
Expand Down
52 changes: 52 additions & 0 deletions src/pythinker_code/cli/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,73 @@ class InfoData(TypedDict):
agent_spec_versions: list[str]
wire_protocol_version: str
python_version: str
auto_update: bool | None
auto_update_config: bool | None
auto_update_override: str | None


def _auto_update_info() -> tuple[bool | None, bool | None, str | None]:
"""Return ``(effective_enabled, config_value, override_reason)``.

Every element is ``None`` when the status cannot be resolved. The whole
block is guarded so an unreadable config or any other failure never turns
the always-available ``info`` diagnostic into a crash.
"""
try:
from pythinker_code.config import Config, get_config_file, load_config
from pythinker_code.update_policy import (
auto_update_enabled,
auto_update_override_reason,
)

override = auto_update_override_reason()
# `load_config()` seeds a default config file when none exists; `info`
# must stay read-only, so fall back to in-memory defaults when the user
# has no config file yet rather than creating one as a side effect.
config_exists = get_config_file(create=False).expanduser().exists()
config = load_config() if config_exists else Config()
return auto_update_enabled(config), config.auto_update, override
except (OSError, ValueError, ImportError) as exc:
# Read-only diagnostic: never abort `info`, but log the degraded path
# instead of silently masking a real config/policy failure. ConfigError
# and pydantic validation errors are ValueError subclasses.
from pythinker_code.utils.logging import logger

logger.debug("Could not resolve auto-update status for `info`: {}", exc)
return None, None, None


def _collect_info() -> InfoData:
from pythinker_code.agentspec import SUPPORTED_AGENT_SPEC_VERSIONS
from pythinker_code.constant import ORGANIZATION, get_version
from pythinker_code.wire.protocol import WIRE_PROTOCOL_VERSION

auto_update_effective, auto_update_config, auto_update_override = _auto_update_info()

return {
"pythinker_code_version": get_version(),
"organization": ORGANIZATION,
"agent_spec_versions": [str(version) for version in SUPPORTED_AGENT_SPEC_VERSIONS],
"wire_protocol_version": WIRE_PROTOCOL_VERSION,
"python_version": platform.python_version(),
"auto_update": auto_update_effective,
"auto_update_config": auto_update_config,
"auto_update_override": auto_update_override,
}


def _auto_update_line(info: InfoData) -> str:
effective = info["auto_update"]
if effective is None:
return "auto-update: unknown"
state = "enabled" if effective else "disabled"
detail = f"config auto_update={'true' if info['auto_update_config'] else 'false'}"
override = info["auto_update_override"]
if override:
detail += f"; {override}"
return f"auto-update: {state} ({detail})"


def _emit_info(json_output: bool) -> None:
info = _collect_info()
if json_output:
Expand All @@ -43,6 +94,7 @@ def _emit_info(json_output: bool) -> None:
f"agent spec versions: {agent_versions_text}",
f"wire protocol: {info['wire_protocol_version']}",
f"python version: {info['python_version']}",
_auto_update_line(info),
]
for line in lines:
typer.echo(line)
Expand Down
10 changes: 7 additions & 3 deletions src/pythinker_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1206,9 +1206,13 @@ def _apply_agent_execution_profile(self) -> None:
self.ask_user_question_policy = "ask_except_auto"


def get_config_file() -> Path:
"""Get the configuration file path."""
return get_share_dir() / "config.toml"
def get_config_file(*, create: bool = True) -> Path:
"""Get the configuration file path.

Pass ``create=False`` to resolve the path without creating the share
directory, for read-only callers that must avoid filesystem side effects.
"""
return get_share_dir(create=create) / "config.toml"


def get_default_config() -> Config:
Expand Down
12 changes: 10 additions & 2 deletions src/pythinker_code/share.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@
from pathlib import Path


def get_share_dir() -> Path:
"""Get the share directory path."""
def get_share_dir(*, create: bool = True) -> Path:
"""Get the share directory path.

Creates and hardens the directory by default. Pass ``create=False`` to
resolve the path without any filesystem side effect — needed by read-only
callers (e.g. ``pythinker info``) that must not materialize ``~/.pythinker``
just to look something up.
"""
if share_dir := os.getenv("PYTHINKER_SHARE_DIR"):
share_dir = Path(share_dir)
else:
share_dir = Path.home() / ".pythinker"
if not create:
return share_dir
share_dir.mkdir(parents=True, exist_ok=True)
# Harden unconditionally: an older version may have left the dir at 0755, so
# only tightening on first-create would leave that secret-bearing dir traversable.
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@
_detect_upgrade_command, # pyright: ignore[reportPrivateUsage]
_mark_auto_update_check_attempt, # pyright: ignore[reportPrivateUsage]
_should_auto_check_for_updates, # pyright: ignore[reportPrivateUsage]
auto_update_enabled,
consume_whats_new,
format_managed_channel_notice,
pending_update_notice,
Expand All @@ -92,6 +91,7 @@
from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled, motion_disabled
from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens
from pythinker_code.ui.theme import tui_rich_style
from pythinker_code.update_policy import auto_update_enabled
from pythinker_code.utils.aioqueue import QueueShutDown
from pythinker_code.utils.envvar import get_env_bool
from pythinker_code.utils.logging import logger
Expand Down
25 changes: 25 additions & 0 deletions src/pythinker_code/ui/shell/selectors/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ def _float_values(current: float, presets: list[float]) -> list[str]:

def _build_settings_config(config: Config) -> SettingsListConfig:
"""Build the settings-list config from a Pythinker ``Config`` object."""
from pythinker_code.update_policy import auto_update_override_reason

_auto_update_override = auto_update_override_reason()
model_values = [_NONE_MODEL_VALUE, *sorted(config.models)]
current_model = config.default_model or _NONE_MODEL_VALUE
current_model_cfg = config.models.get(config.default_model) if config.default_model else None
Expand Down Expand Up @@ -160,6 +163,21 @@ def _build_settings_config(config: Config) -> SettingsListConfig:
current_value=_bool(config.telemetry),
values=_BOOL_VALUES,
),
SettingItem(
id="auto_update",
label="Auto-update",
description=(
"Silently install new releases in the background at startup "
"(applied on next restart)."
if _auto_update_override is None
else f"Auto-update is {_auto_update_override}; that override outranks this setting."
),
# Show the *effective* state, and make the row read-only when an
# override (env kill-switch / source checkout) forces it off, so the
# panel never offers a no-op toggle.
current_value=(_bool(config.auto_update) if _auto_update_override is None else "false"),
values=_BOOL_VALUES if _auto_update_override is None else None,
),
SettingItem(
id="merge_all_available_skills",
label="Merge all skills",
Expand Down Expand Up @@ -343,6 +361,13 @@ def mark(setting_id: str) -> None:
if config.telemetry != new:
config.telemetry = new
mark(setting_id)
case "auto_update":
# Only reached for the live (non-override) row; a read-only row
# never submits a change.
new = value == "true"
if config.auto_update != new:
config.auto_update = new
mark(setting_id)
case "merge_all_available_skills":
new = value == "true"
if config.merge_all_available_skills != new:
Expand Down
68 changes: 66 additions & 2 deletions src/pythinker_code/ui/shell/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -2084,11 +2084,15 @@ async def show_memory(app: Shell, args: str):

@registry.command(name="update", aliases=["upgrade"])
async def update_command(app: Shell, args: str):
"""Check for and optionally install the latest Pythinker version."""
_ = args, app
"""Check for updates, or `auto [on|off]` to toggle silent startup auto-updates."""
from pythinker_code.ui.shell.update import UpdateResult, run_update_prompt
from pythinker_code.ui.shell.update_orchestrator import run_update_job

parts = args.strip().split()
if parts and parts[0].lower() in {"auto", "auto-update", "autoupdate"}:
await _auto_update_toggle(app, parts[1:])
return

async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult:
return await run_update_job(
print_output=print_output, check_only=check_only, source="slash"
Expand All @@ -2099,6 +2103,66 @@ async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult:
console.print("Updated — restart Pythinker to use the new version.")


async def _auto_update_toggle(app: Shell, args: list[str]) -> None:
"""Show or set the silent startup auto-update preference (`/update auto [on|off]`)."""
from pythinker_code.telemetry import track
from pythinker_code.ui.theme import get_tui_tokens as _get_tok
from pythinker_code.update_policy import auto_update_enabled, auto_update_override_reason

_t = _get_tok()
soul = ensure_pythinker_soul(app)
if soul is None:
return
config = soul.runtime.config
override = auto_update_override_reason()

def _print_override() -> None:
if override is not None:
console.print(f"[{_t.muted}]Note: {override}; this overrides the setting.[/]")

# No value → report effective state.
if not args:
effective = "on" if auto_update_enabled(config) else "off"
stored = "on" if config.auto_update else "off"
console.print(f"[{_t.info}]Auto-update: {effective}[/] (config auto_update={stored})")
_print_override()
return

value = args[0].lower()
if len(args) > 1 or value not in {"on", "off"}:
console.print(f"[{_t.warning}]Usage: /update auto [on|off][/]")
return
enabled = value == "on"

if config.auto_update == enabled:
console.print(f"[{_t.warning}]Auto-update already {value}.[/]")
_print_override()
return

config_file = config.source_file
if config_file is None:
console.print(
f"[{_t.warning}]Toggling auto-update requires a config file; "
f"restart without --config (or use --config-file) to persist settings.[/]"
)
return
try:
config_for_save = load_config(config_file)
config_for_save.auto_update = enabled
save_config(config_for_save, config_file)
except (ConfigError, OSError) as exc:
console.print(f"[{_t.error}]Failed to save config: {_rich_escape(exc)}[/]")
return
# auto_update is only consulted at startup, so nothing live depends on it:
# mirror the saved value into the running config instead of forcing a reload
# (a reload would re-trigger the startup auto-update task we just toggled).
config.auto_update = enabled

track("settings_update", changed="auto_update", count=1)
console.print(f"[{_t.success}]Auto-update {value}. Takes effect at next startup.[/]")
_print_override()


@registry.command
async def mcp(app: Shell, args: str):
"""Show MCP servers and tools"""
Expand Down
70 changes: 13 additions & 57 deletions src/pythinker_code/ui/shell/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@
from enum import Enum, auto
from pathlib import Path
from shutil import which
from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
from pythinker_code.config import Config
from typing import cast

import aiohttp
import typer
Expand All @@ -35,6 +32,18 @@
from pythinker_code.share import get_share_dir
from pythinker_code.ui.shell.console import console
from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens

# Pure policy lives in a shell-free module (`update_policy`) so lightweight
# callers (e.g. `pythinker info`) can resolve auto-update status without
# importing this stack. These two primitives are used internally below; the
# public `auto_update_enabled` / `auto_update_override_reason` are imported
# directly from `update_policy` by their consumers.
from pythinker_code.update_policy import (
auto_update_disabled as _auto_update_disabled,
)
from pythinker_code.update_policy import (
is_running_from_source_checkout as _is_running_from_source_checkout,
)
from pythinker_code.utils.aiohttp import new_client_session
from pythinker_code.utils.logging import logger
from pythinker_code.utils.subprocess_env import get_clean_env
Expand Down Expand Up @@ -238,12 +247,6 @@ async def _get_latest_version(session: aiohttp.ClientSession) -> str | None:
return None


def _auto_update_disabled() -> bool:
from pythinker_code.utils.envvar import get_env_bool

return get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE")


def format_managed_channel_notice(
current: str,
latest: str,
Expand All @@ -262,53 +265,6 @@ def format_managed_channel_notice(
)


def _is_running_from_source_checkout() -> bool:
"""Return true when invoked from this repository via ``uv run``/editable source.

In that mode PyPI can legitimately have a newer released version than the
checkout's local ``pyproject.toml`` version. Showing the normal upgrade
banner is noisy and suggests replacing the developer checkout.
"""
try:
import pythinker_code

package_path = Path(pythinker_code.__file__).resolve()
except Exception:
return False

for parent in package_path.parents:
pyproject = parent / "pyproject.toml"
git_dir = parent / ".git"
if pyproject.exists() and git_dir.exists():
try:
text = pyproject.read_text(encoding="utf-8")
except OSError:
return False
return 'name = "pythinker-code"' in text or "name = 'pythinker-code'" in text
return False


def auto_update_enabled(config: Config) -> bool:
"""Whether startup may silently install a newer release.

Precedence (highest first):
1. ``PYTHINKER_CLI_NO_AUTO_UPDATE`` (the hard kill-switch) → disabled.
2. ``config.auto_update is False`` → disabled.
3. Source checkout → disabled.
4. Otherwise → enabled.

Managed channels (Docker/Nix/Scoop/WinGet) are *not* special-cased here:
they may be "enabled" but ``_do_update`` returns ``UPDATE_AVAILABLE`` and
emits a channel hint instead of swapping the binary, so they never get a
silent install regardless of this result.
"""
if _auto_update_disabled():
return False
if config.auto_update is False:
return False
return not _is_running_from_source_checkout()


def _should_auto_check_for_updates(now: float | None = None) -> bool:
if _auto_update_disabled() or _is_running_from_source_checkout():
return False
Expand Down
Loading
Loading