Skip to content

Commit e81ff3b

Browse files
committed
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.
1 parent 8ef84e4 commit e81ff3b

14 files changed

Lines changed: 576 additions & 66 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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,67 @@ 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 Exception:
43+
return None, None, None
1644

1745

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

51+
auto_update_effective, auto_update_config, auto_update_override = _auto_update_info()
52+
2353
return {
2454
"pythinker_code_version": get_version(),
2555
"organization": ORGANIZATION,
2656
"agent_spec_versions": [str(version) for version in SUPPORTED_AGENT_SPEC_VERSIONS],
2757
"wire_protocol_version": WIRE_PROTOCOL_VERSION,
2858
"python_version": platform.python_version(),
59+
"auto_update": auto_update_effective,
60+
"auto_update_config": auto_update_config,
61+
"auto_update_override": auto_update_override,
2962
}
3063

3164

65+
def _auto_update_line(info: InfoData) -> str:
66+
effective = info["auto_update"]
67+
if effective is None:
68+
return "auto-update: unknown"
69+
state = "enabled" if effective else "disabled"
70+
detail = f"config auto_update={'true' if info['auto_update_config'] else 'false'}"
71+
override = info["auto_update_override"]
72+
if override:
73+
detail += f"; {override}"
74+
return f"auto-update: {state} ({detail})"
75+
76+
3277
def _emit_info(json_output: bool) -> None:
3378
info = _collect_info()
3479
if json_output:
@@ -43,6 +88,7 @@ def _emit_info(json_output: bool) -> None:
4388
f"agent spec versions: {agent_versions_text}",
4489
f"wire protocol: {info['wire_protocol_version']}",
4590
f"python version: {info['python_version']}",
91+
_auto_update_line(info),
4692
]
4793
for line in lines:
4894
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/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.ui.shell.update 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.shell.update import auto_update_enabled, auto_update_override_reason
2110+
from pythinker_code.ui.theme import get_tui_tokens as _get_tok
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: 17 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,22 @@
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 so lightweight callers (e.g.
37+
# `pythinker info`) can resolve auto-update status without importing this stack.
38+
# Re-exported here under the historical names so existing call sites/tests work.
39+
from pythinker_code.update_policy import (
40+
auto_update_disabled as _auto_update_disabled,
41+
)
42+
from pythinker_code.update_policy import (
43+
auto_update_enabled as auto_update_enabled,
44+
)
45+
from pythinker_code.update_policy import (
46+
auto_update_override_reason as auto_update_override_reason,
47+
)
48+
from pythinker_code.update_policy import (
49+
is_running_from_source_checkout as _is_running_from_source_checkout,
50+
)
3851
from pythinker_code.utils.aiohttp import new_client_session
3952
from pythinker_code.utils.logging import logger
4053
from pythinker_code.utils.subprocess_env import get_clean_env
@@ -238,12 +251,6 @@ async def _get_latest_version(session: aiohttp.ClientSession) -> str | None:
238251
return None
239252

240253

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-
247254
def format_managed_channel_notice(
248255
current: str,
249256
latest: str,
@@ -262,53 +269,6 @@ def format_managed_channel_notice(
262269
)
263270

264271

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-
312272
def _should_auto_check_for_updates(now: float | None = None) -> bool:
313273
if _auto_update_disabled() or _is_running_from_source_checkout():
314274
return False

0 commit comments

Comments
 (0)