Skip to content

Commit 7e476c1

Browse files
authored
feat(update): toggle auto-update via /update auto, /settings, and info (#133)
* feat(update): toggle auto-update via /update auto, /settings, and info Add an in-app way to turn silent startup auto-updates on or off: - `/update auto on|off` (and `/update auto` reports the effective state) - an effective-state-aware row in the interactive `/settings` panel - auto-update status in `pythinker info` All surfaces 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. Extract the pure policy resolver into a shell-free `update_policy` module so `pythinker info` reports status without importing the shell stack, and add `create=False` to get_share_dir/get_config_file so the read-only info path no longer materializes ~/.pythinker as a side effect. * refactor(update): address review — drop re-export indirection, narrow excepts - Repoint consumers (shell __init__, /update, /settings) and tests to import auto_update_enabled / auto_update_override_reason directly from the canonical `update_policy` module, and drop the unused re-export shims from ui/shell/update.py (resolves "unused import" findings). - Narrow the broad `except Exception` in `info._auto_update_info` to (OSError, ValueError, ImportError) and log the degraded path instead of silently swallowing (C03); ConfigError/pydantic errors are ValueError. - Narrow `update_policy.is_running_from_source_checkout` to (ImportError, AttributeError, OSError). - Strengthen the /update auto persist tests to assert real on-disk persistence via load_config rather than mock call-coupling.
1 parent 8ef84e4 commit 7e476c1

15 files changed

Lines changed: 575 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **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.
19+
1820
## 0.43.0 (2026-06-13)
1921

2022
- **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.

docs/en/reference/slash-commands.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,13 @@ Check for and optionally install the latest Pythinker Code version.
176176

177177
Alias: `/upgrade`
178178

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

181188
Reload the configuration file without exiting Pythinker Code.

src/pythinker_code/cli/info.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,73 @@ class InfoData(TypedDict):
1313
agent_spec_versions: list[str]
1414
wire_protocol_version: str
1515
python_version: str
16+
auto_update: bool | None
17+
auto_update_config: bool | None
18+
auto_update_override: str | None
19+
20+
21+
def _auto_update_info() -> tuple[bool | None, bool | None, str | None]:
22+
"""Return ``(effective_enabled, config_value, override_reason)``.
23+
24+
Every element is ``None`` when the status cannot be resolved. The whole
25+
block is guarded so an unreadable config or any other failure never turns
26+
the always-available ``info`` diagnostic into a crash.
27+
"""
28+
try:
29+
from pythinker_code.config import Config, get_config_file, load_config
30+
from pythinker_code.update_policy import (
31+
auto_update_enabled,
32+
auto_update_override_reason,
33+
)
34+
35+
override = auto_update_override_reason()
36+
# `load_config()` seeds a default config file when none exists; `info`
37+
# must stay read-only, so fall back to in-memory defaults when the user
38+
# has no config file yet rather than creating one as a side effect.
39+
config_exists = get_config_file(create=False).expanduser().exists()
40+
config = load_config() if config_exists else Config()
41+
return auto_update_enabled(config), config.auto_update, override
42+
except (OSError, ValueError, ImportError) as exc:
43+
# Read-only diagnostic: never abort `info`, but log the degraded path
44+
# instead of silently masking a real config/policy failure. ConfigError
45+
# and pydantic validation errors are ValueError subclasses.
46+
from pythinker_code.utils.logging import logger
47+
48+
logger.debug("Could not resolve auto-update status for `info`: {}", exc)
49+
return None, None, None
1650

1751

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

57+
auto_update_effective, auto_update_config, auto_update_override = _auto_update_info()
58+
2359
return {
2460
"pythinker_code_version": get_version(),
2561
"organization": ORGANIZATION,
2662
"agent_spec_versions": [str(version) for version in SUPPORTED_AGENT_SPEC_VERSIONS],
2763
"wire_protocol_version": WIRE_PROTOCOL_VERSION,
2864
"python_version": platform.python_version(),
65+
"auto_update": auto_update_effective,
66+
"auto_update_config": auto_update_config,
67+
"auto_update_override": auto_update_override,
2968
}
3069

3170

71+
def _auto_update_line(info: InfoData) -> str:
72+
effective = info["auto_update"]
73+
if effective is None:
74+
return "auto-update: unknown"
75+
state = "enabled" if effective else "disabled"
76+
detail = f"config auto_update={'true' if info['auto_update_config'] else 'false'}"
77+
override = info["auto_update_override"]
78+
if override:
79+
detail += f"; {override}"
80+
return f"auto-update: {state} ({detail})"
81+
82+
3283
def _emit_info(json_output: bool) -> None:
3384
info = _collect_info()
3485
if json_output:
@@ -43,6 +94,7 @@ def _emit_info(json_output: bool) -> None:
4394
f"agent spec versions: {agent_versions_text}",
4495
f"wire protocol: {info['wire_protocol_version']}",
4596
f"python version: {info['python_version']}",
97+
_auto_update_line(info),
4698
]
4799
for line in lines:
48100
typer.echo(line)

src/pythinker_code/config.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,9 +1206,13 @@ def _apply_agent_execution_profile(self) -> None:
12061206
self.ask_user_question_policy = "ask_except_auto"
12071207

12081208

1209-
def get_config_file() -> Path:
1210-
"""Get the configuration file path."""
1211-
return get_share_dir() / "config.toml"
1209+
def get_config_file(*, create: bool = True) -> Path:
1210+
"""Get the configuration file path.
1211+
1212+
Pass ``create=False`` to resolve the path without creating the share
1213+
directory, for read-only callers that must avoid filesystem side effects.
1214+
"""
1215+
return get_share_dir(create=create) / "config.toml"
12121216

12131217

12141218
def get_default_config() -> Config:

src/pythinker_code/share.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,20 @@
55
from pathlib import Path
66

77

8-
def get_share_dir() -> Path:
9-
"""Get the share directory path."""
8+
def get_share_dir(*, create: bool = True) -> Path:
9+
"""Get the share directory path.
10+
11+
Creates and hardens the directory by default. Pass ``create=False`` to
12+
resolve the path without any filesystem side effect — needed by read-only
13+
callers (e.g. ``pythinker info``) that must not materialize ``~/.pythinker``
14+
just to look something up.
15+
"""
1016
if share_dir := os.getenv("PYTHINKER_SHARE_DIR"):
1117
share_dir = Path(share_dir)
1218
else:
1319
share_dir = Path.home() / ".pythinker"
20+
if not create:
21+
return share_dir
1422
share_dir.mkdir(parents=True, exist_ok=True)
1523
# Harden unconditionally: an older version may have left the dir at 0755, so
1624
# only tightening on first-create would leave that secret-bearing dir traversable.

src/pythinker_code/ui/shell/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@
7373
_detect_upgrade_command, # pyright: ignore[reportPrivateUsage]
7474
_mark_auto_update_check_attempt, # pyright: ignore[reportPrivateUsage]
7575
_should_auto_check_for_updates, # pyright: ignore[reportPrivateUsage]
76-
auto_update_enabled,
7776
consume_whats_new,
7877
format_managed_channel_notice,
7978
pending_update_notice,
@@ -92,6 +91,7 @@
9291
from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled, motion_disabled
9392
from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens
9493
from pythinker_code.ui.theme import tui_rich_style
94+
from pythinker_code.update_policy import auto_update_enabled
9595
from pythinker_code.utils.aioqueue import QueueShutDown
9696
from pythinker_code.utils.envvar import get_env_bool
9797
from pythinker_code.utils.logging import logger

src/pythinker_code/ui/shell/selectors/settings.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ def _float_values(current: float, presets: list[float]) -> list[str]:
4848

4949
def _build_settings_config(config: Config) -> SettingsListConfig:
5050
"""Build the settings-list config from a Pythinker ``Config`` object."""
51+
from pythinker_code.update_policy import auto_update_override_reason
52+
53+
_auto_update_override = auto_update_override_reason()
5154
model_values = [_NONE_MODEL_VALUE, *sorted(config.models)]
5255
current_model = config.default_model or _NONE_MODEL_VALUE
5356
current_model_cfg = config.models.get(config.default_model) if config.default_model else None
@@ -160,6 +163,21 @@ def _build_settings_config(config: Config) -> SettingsListConfig:
160163
current_value=_bool(config.telemetry),
161164
values=_BOOL_VALUES,
162165
),
166+
SettingItem(
167+
id="auto_update",
168+
label="Auto-update",
169+
description=(
170+
"Silently install new releases in the background at startup "
171+
"(applied on next restart)."
172+
if _auto_update_override is None
173+
else f"Auto-update is {_auto_update_override}; that override outranks this setting."
174+
),
175+
# Show the *effective* state, and make the row read-only when an
176+
# override (env kill-switch / source checkout) forces it off, so the
177+
# panel never offers a no-op toggle.
178+
current_value=(_bool(config.auto_update) if _auto_update_override is None else "false"),
179+
values=_BOOL_VALUES if _auto_update_override is None else None,
180+
),
163181
SettingItem(
164182
id="merge_all_available_skills",
165183
label="Merge all skills",
@@ -343,6 +361,13 @@ def mark(setting_id: str) -> None:
343361
if config.telemetry != new:
344362
config.telemetry = new
345363
mark(setting_id)
364+
case "auto_update":
365+
# Only reached for the live (non-override) row; a read-only row
366+
# never submits a change.
367+
new = value == "true"
368+
if config.auto_update != new:
369+
config.auto_update = new
370+
mark(setting_id)
346371
case "merge_all_available_skills":
347372
new = value == "true"
348373
if config.merge_all_available_skills != new:

src/pythinker_code/ui/shell/slash.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2084,11 +2084,15 @@ async def show_memory(app: Shell, args: str):
20842084

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

2091+
parts = args.strip().split()
2092+
if parts and parts[0].lower() in {"auto", "auto-update", "autoupdate"}:
2093+
await _auto_update_toggle(app, parts[1:])
2094+
return
2095+
20922096
async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult:
20932097
return await run_update_job(
20942098
print_output=print_output, check_only=check_only, source="slash"
@@ -2099,6 +2103,66 @@ async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult:
20992103
console.print("Updated — restart Pythinker to use the new version.")
21002104

21012105

2106+
async def _auto_update_toggle(app: Shell, args: list[str]) -> None:
2107+
"""Show or set the silent startup auto-update preference (`/update auto [on|off]`)."""
2108+
from pythinker_code.telemetry import track
2109+
from pythinker_code.ui.theme import get_tui_tokens as _get_tok
2110+
from pythinker_code.update_policy import auto_update_enabled, auto_update_override_reason
2111+
2112+
_t = _get_tok()
2113+
soul = ensure_pythinker_soul(app)
2114+
if soul is None:
2115+
return
2116+
config = soul.runtime.config
2117+
override = auto_update_override_reason()
2118+
2119+
def _print_override() -> None:
2120+
if override is not None:
2121+
console.print(f"[{_t.muted}]Note: {override}; this overrides the setting.[/]")
2122+
2123+
# No value → report effective state.
2124+
if not args:
2125+
effective = "on" if auto_update_enabled(config) else "off"
2126+
stored = "on" if config.auto_update else "off"
2127+
console.print(f"[{_t.info}]Auto-update: {effective}[/] (config auto_update={stored})")
2128+
_print_override()
2129+
return
2130+
2131+
value = args[0].lower()
2132+
if len(args) > 1 or value not in {"on", "off"}:
2133+
console.print(f"[{_t.warning}]Usage: /update auto [on|off][/]")
2134+
return
2135+
enabled = value == "on"
2136+
2137+
if config.auto_update == enabled:
2138+
console.print(f"[{_t.warning}]Auto-update already {value}.[/]")
2139+
_print_override()
2140+
return
2141+
2142+
config_file = config.source_file
2143+
if config_file is None:
2144+
console.print(
2145+
f"[{_t.warning}]Toggling auto-update requires a config file; "
2146+
f"restart without --config (or use --config-file) to persist settings.[/]"
2147+
)
2148+
return
2149+
try:
2150+
config_for_save = load_config(config_file)
2151+
config_for_save.auto_update = enabled
2152+
save_config(config_for_save, config_file)
2153+
except (ConfigError, OSError) as exc:
2154+
console.print(f"[{_t.error}]Failed to save config: {_rich_escape(exc)}[/]")
2155+
return
2156+
# auto_update is only consulted at startup, so nothing live depends on it:
2157+
# mirror the saved value into the running config instead of forcing a reload
2158+
# (a reload would re-trigger the startup auto-update task we just toggled).
2159+
config.auto_update = enabled
2160+
2161+
track("settings_update", changed="auto_update", count=1)
2162+
console.print(f"[{_t.success}]Auto-update {value}. Takes effect at next startup.[/]")
2163+
_print_override()
2164+
2165+
21022166
@registry.command
21032167
async def mcp(app: Shell, args: str):
21042168
"""Show MCP servers and tools"""

src/pythinker_code/ui/shell/update.py

Lines changed: 13 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@
1515
from enum import Enum, auto
1616
from pathlib import Path
1717
from shutil import which
18-
from typing import TYPE_CHECKING, cast
19-
20-
if TYPE_CHECKING:
21-
from pythinker_code.config import Config
18+
from typing import cast
2219

2320
import aiohttp
2421
import typer
@@ -35,6 +32,18 @@
3532
from pythinker_code.share import get_share_dir
3633
from pythinker_code.ui.shell.console import console
3734
from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens
35+
36+
# Pure policy lives in a shell-free module (`update_policy`) so lightweight
37+
# callers (e.g. `pythinker info`) can resolve auto-update status without
38+
# importing this stack. These two primitives are used internally below; the
39+
# public `auto_update_enabled` / `auto_update_override_reason` are imported
40+
# directly from `update_policy` by their consumers.
41+
from pythinker_code.update_policy import (
42+
auto_update_disabled as _auto_update_disabled,
43+
)
44+
from pythinker_code.update_policy import (
45+
is_running_from_source_checkout as _is_running_from_source_checkout,
46+
)
3847
from pythinker_code.utils.aiohttp import new_client_session
3948
from pythinker_code.utils.logging import logger
4049
from pythinker_code.utils.subprocess_env import get_clean_env
@@ -238,12 +247,6 @@ async def _get_latest_version(session: aiohttp.ClientSession) -> str | None:
238247
return None
239248

240249

241-
def _auto_update_disabled() -> bool:
242-
from pythinker_code.utils.envvar import get_env_bool
243-
244-
return get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE")
245-
246-
247250
def format_managed_channel_notice(
248251
current: str,
249252
latest: str,
@@ -262,53 +265,6 @@ def format_managed_channel_notice(
262265
)
263266

264267

265-
def _is_running_from_source_checkout() -> bool:
266-
"""Return true when invoked from this repository via ``uv run``/editable source.
267-
268-
In that mode PyPI can legitimately have a newer released version than the
269-
checkout's local ``pyproject.toml`` version. Showing the normal upgrade
270-
banner is noisy and suggests replacing the developer checkout.
271-
"""
272-
try:
273-
import pythinker_code
274-
275-
package_path = Path(pythinker_code.__file__).resolve()
276-
except Exception:
277-
return False
278-
279-
for parent in package_path.parents:
280-
pyproject = parent / "pyproject.toml"
281-
git_dir = parent / ".git"
282-
if pyproject.exists() and git_dir.exists():
283-
try:
284-
text = pyproject.read_text(encoding="utf-8")
285-
except OSError:
286-
return False
287-
return 'name = "pythinker-code"' in text or "name = 'pythinker-code'" in text
288-
return False
289-
290-
291-
def auto_update_enabled(config: Config) -> bool:
292-
"""Whether startup may silently install a newer release.
293-
294-
Precedence (highest first):
295-
1. ``PYTHINKER_CLI_NO_AUTO_UPDATE`` (the hard kill-switch) → disabled.
296-
2. ``config.auto_update is False`` → disabled.
297-
3. Source checkout → disabled.
298-
4. Otherwise → enabled.
299-
300-
Managed channels (Docker/Nix/Scoop/WinGet) are *not* special-cased here:
301-
they may be "enabled" but ``_do_update`` returns ``UPDATE_AVAILABLE`` and
302-
emits a channel hint instead of swapping the binary, so they never get a
303-
silent install regardless of this result.
304-
"""
305-
if _auto_update_disabled():
306-
return False
307-
if config.auto_update is False:
308-
return False
309-
return not _is_running_from_source_checkout()
310-
311-
312268
def _should_auto_check_for_updates(now: float | None = None) -> bool:
313269
if _auto_update_disabled() or _is_running_from_source_checkout():
314270
return False

0 commit comments

Comments
 (0)