Skip to content

Commit fc6a226

Browse files
committed
feat(tui): add /update command, surface auto-update in doctor, alias pythinker update
The welcome banner advertised /update but no such command existed. /update (alias /upgrade) refreshes the CDN cache and starts the existing detached background installer, ignoring the rollout hold and the auto_install preference since the user explicitly asked; sources that cannot self-update get a copyable command instead. pythinker doctor now reports the auto-update state (on / off via tui.toml [upgrade].auto_install / disabled by PYTHINKER_CODE_NO_AUTO_UPDATE), and 'pythinker update' aliases the existing upgrade subcommand.
1 parent e9b6171 commit fc6a226

11 files changed

Lines changed: 258 additions & 5 deletions

File tree

.changeset/tui-update-command.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
Add the `/update` slash command (alias `/upgrade`) the welcome banner has been advertising: it checks the CDN for a newer version and installs it in the background, falling back to a copyable command for installs that cannot self-update (e.g. Homebrew). `pythinker doctor` now reports whether auto-update is on, off via `tui.toml [upgrade].auto_install`, or disabled by `PYTHINKER_CODE_NO_AUTO_UPDATE`.

apps/pythinker-code/src/cli/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export function createProgram(
110110
registerMigrateCommand(program, onMigrate);
111111
program
112112
.command('upgrade')
113+
.alias('update')
113114
.description('Upgrade Pythinker Code to the latest version.')
114115
.action(async () => {
115116
await onUpgrade();

apps/pythinker-code/src/cli/sub/doctor.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { z } from 'zod';
1515

1616
import { getTuiConfigPath, parseTuiConfig } from '#/tui/config';
1717
import { readUpdateCache } from '#/cli/update/cache';
18+
import { isAutoUpdateDisabledByEnv, shouldAutoInstallUpdates } from '#/cli/update/preflight';
1819
import { detectInstallSource } from '#/cli/update/source';
1920
import { getHostPackageRoot, getVersion } from '#/cli/version';
2021

@@ -48,6 +49,7 @@ export interface DoctorRuntimeInfo {
4849
readonly update?: {
4950
readonly latest: string | null;
5051
readonly checkedAt: string | null;
52+
readonly autoUpdate?: 'on' | 'off' | 'env-disabled';
5153
};
5254
}
5355

@@ -164,11 +166,12 @@ function resolveDeps(deps: Partial<DoctorDeps> | DoctorDeps | undefined): Resolv
164166
runtimeInfo:
165167
deps?.runtimeInfo ??
166168
(async () => {
167-
const [installSource, installations, ripgrep, update] = await Promise.all([
169+
const [installSource, installations, ripgrep, update, autoInstall] = await Promise.all([
168170
detectInstallSource(),
169171
findPythinkerExecutables(),
170172
findExistingRg(resolvePythinkerHome()),
171173
readUpdateCache(),
174+
shouldAutoInstallUpdates(),
172175
]);
173176
return {
174177
version: getVersion(),
@@ -177,7 +180,11 @@ function resolveDeps(deps: Partial<DoctorDeps> | DoctorDeps | undefined): Resolv
177180
executable: process.execPath,
178181
installations,
179182
ripgrep,
180-
update,
183+
update: {
184+
latest: update.latest,
185+
checkedAt: update.checkedAt,
186+
autoUpdate: isAutoUpdateDisabledByEnv() ? 'env-disabled' : autoInstall ? 'on' : 'off',
187+
},
181188
};
182189
}),
183190
};
@@ -361,6 +368,13 @@ function formatRuntimeInfo(info: DoctorRuntimeInfo | undefined): string[] {
361368
? []
362369
: [
363370
' Update channel: CDN staged rollout',
371+
...(info.update.autoUpdate === undefined
372+
? []
373+
: [
374+
info.update.autoUpdate === 'env-disabled'
375+
? ' Auto-update: disabled by PYTHINKER_CODE_NO_AUTO_UPDATE'
376+
: ` Auto-update: ${info.update.autoUpdate} (tui.toml [upgrade].auto_install)`,
377+
]),
364378
...(info.update.latest === null
365379
? [' Latest cached version: unavailable']
366380
: [

apps/pythinker-code/src/cli/update/preflight.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type InstallPromptOptions,
2020
} from './prompt';
2121
import { refreshUpdateCache } from './refresh';
22+
import { selectUpdateTarget } from './select';
2223
import {
2324
appendRolloutDecisionLog,
2425
decidePassiveUpdateTarget,
@@ -34,6 +35,7 @@ import {
3435
type InstallSource,
3536
type UpdateDecision,
3637
type UpdateInstallState,
38+
type UpdateCache,
3739
type UpdateManifest,
3840
type UpdatePreflightResult,
3941
type UpdateTarget,
@@ -432,13 +434,13 @@ async function showPendingBackgroundInstallNotice(
432434
* prompt. Migrated from pythinker-cli, where the variable gated all auto-update
433435
* behavior. Accepts the usual truthy values (`1`/`true`/`yes`/`on`).
434436
*/
435-
function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
437+
export function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
436438
const truthy = (value?: string): boolean =>
437439
['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
438440
return truthy(env['PYTHINKER_CODE_NO_AUTO_UPDATE']) || truthy(env['PYTHINKER_CLI_NO_AUTO_UPDATE']);
439441
}
440442

441-
async function shouldAutoInstallUpdates(): Promise<boolean> {
443+
export async function shouldAutoInstallUpdates(): Promise<boolean> {
442444
try {
443445
const config = await loadTuiConfig();
444446
return config.upgrade.autoInstall;
@@ -710,6 +712,77 @@ async function tryStartAutomaticBackgroundInstall(
710712
}
711713
}
712714

715+
export type ManualUpdateResult =
716+
| { readonly status: 'up-to-date' }
717+
| { readonly status: 'check-failed'; readonly message: string }
718+
| { readonly status: 'started'; readonly version: string }
719+
| { readonly status: 'in-progress'; readonly version: string }
720+
| { readonly status: 'manual'; readonly version: string; readonly command: string };
721+
722+
/**
723+
* Explicit user-requested update (TUI `/update`). Unlike the passive
724+
* preflight it ignores the rollout delay and the `auto_install` preference —
725+
* the user asked, so we install — but still reuses the background installer,
726+
* its lock, and its failure bookkeeping. The env kill-switch is also ignored:
727+
* it gates automatic behavior, not explicit requests (matching `pythinker upgrade`).
728+
*/
729+
export async function startManualUpdate(
730+
currentVersion: string,
731+
logger: UpdateLogger = log,
732+
): Promise<ManualUpdateResult> {
733+
let cache: UpdateCache;
734+
try {
735+
cache = await refreshUpdateCache();
736+
} catch (error) {
737+
return { status: 'check-failed', message: formatErrorMessage(error) };
738+
}
739+
const target = selectUpdateTarget(currentVersion, cache.latest);
740+
if (target === null) return { status: 'up-to-date' };
741+
742+
const platform = process.platform;
743+
const source = await detectInstallSource().catch(() => 'unsupported' as const);
744+
if (!canAutoInstall(source, platform)) {
745+
return {
746+
status: 'manual',
747+
version: target.version,
748+
command: installCommandFor(source, target.version, platform),
749+
};
750+
}
751+
752+
const installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState());
753+
if (hasFreshActiveInstall(installState)) {
754+
return {
755+
status: 'in-progress',
756+
version: installState.active?.version ?? target.version,
757+
};
758+
}
759+
// Repeated background failures fall back to the copyable command instead of
760+
// claiming "started" for an install startBackgroundInstall would refuse.
761+
if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) {
762+
return {
763+
status: 'manual',
764+
version: target.version,
765+
command: installCommandFor(source, target.version, platform),
766+
};
767+
}
768+
769+
try {
770+
await startBackgroundInstall(
771+
installState,
772+
currentVersion,
773+
target,
774+
source,
775+
platform,
776+
undefined,
777+
logger,
778+
rolloutTelemetryFor(resolveUpdateDeviceId(), target.version, cache.manifest, true),
779+
);
780+
return { status: 'started', version: target.version };
781+
} catch (error) {
782+
return { status: 'check-failed', message: formatErrorMessage(error) };
783+
}
784+
}
785+
713786
export function decideUpdateAction(
714787
target: UpdateTarget | null,
715788
isInteractive: boolean,

apps/pythinker-code/src/tui/commands/dispatch.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { handleMemoryCommand } from './memory';
5050
import {
5151
handleDoctorCommand,
5252
handleFeedbackCommand,
53+
handleUpdateCommand,
5354
handleHooksCommand,
5455
showMcpServers,
5556
showContextReport,
@@ -114,6 +115,7 @@ export { handleFastCommand } from './fast';
114115
export {
115116
handleDoctorCommand,
116117
handleFeedbackCommand,
118+
handleUpdateCommand,
117119
handleHooksCommand,
118120
showMcpServers,
119121
showContextReport,
@@ -306,6 +308,9 @@ async function handleBuiltInSlashCommand(
306308
case 'doctor':
307309
await handleDoctorCommand(host, args);
308310
return;
311+
case 'update':
312+
await handleUpdateCommand(host, args);
313+
return;
309314
case 'debug':
310315
await handleDebugCommand(host, args);
311316
return;

apps/pythinker-code/src/tui/commands/info.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from '@pythoughts/pythinker-code-sdk';
1010

1111
import { handleDoctor } from '#/cli/sub/doctor';
12+
import { startManualUpdate } from '#/cli/update/preflight';
1213
import { PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app';
1314
import { openUrl } from '#/utils/open-url';
1415
import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel';
@@ -294,6 +295,42 @@ export async function handleHooksCommand(
294295
);
295296
}
296297

298+
export async function handleUpdateCommand(
299+
host: SlashCommandHost,
300+
args: string,
301+
): Promise<void> {
302+
if (args.trim().length > 0) {
303+
host.showError('Usage: /update');
304+
return;
305+
}
306+
host.showStatus('Checking for updates…');
307+
const currentVersion = host.state.appState.version;
308+
const result = await startManualUpdate(currentVersion);
309+
switch (result.status) {
310+
case 'up-to-date':
311+
host.showNotice('Pythinker Code is up to date', `v${currentVersion}`);
312+
return;
313+
case 'started':
314+
host.showNotice(
315+
`Updating to v${result.version}`,
316+
'Installing in the background — restart the CLI when it completes.',
317+
);
318+
return;
319+
case 'in-progress':
320+
host.showNotice(
321+
`Update to v${result.version} already in progress`,
322+
'Restart the CLI once it completes.',
323+
);
324+
return;
325+
case 'manual':
326+
host.showNotice(`Update available — v${result.version}`, `Run: ${result.command}`);
327+
return;
328+
case 'check-failed':
329+
host.showError(`Update check failed: ${result.message}`);
330+
return;
331+
}
332+
}
333+
297334
export async function handleDoctorCommand(
298335
host: SlashCommandHost,
299336
args: string,

apps/pythinker-code/src/tui/commands/registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,13 @@ export const BUILTIN_SLASH_COMMANDS = [
249249
priority: 60,
250250
availability: 'always',
251251
},
252+
{
253+
name: 'update',
254+
aliases: ['upgrade'],
255+
description: 'Update Pythinker Code to the latest version',
256+
priority: 60,
257+
availability: 'always',
258+
},
252259
{
253260
name: 'debug',
254261
aliases: [],

apps/pythinker-code/test/cli/doctor.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ function makeDeps(): {
4747
update: {
4848
latest: '1.3.0',
4949
checkedAt: '2026-07-29T12:00:00.000Z',
50+
autoUpdate: 'on' as const,
5051
},
5152
}),
5253
exit: (code) => {
@@ -126,6 +127,7 @@ describe('pythinker doctor', () => {
126127
' Package root: /opt/pythinker',
127128
' Executable: /usr/local/bin/node',
128129
' Update channel: CDN staged rollout',
130+
' Auto-update: on (tui.toml [upgrade].auto_install)',
129131
' Latest cached version: 1.3.0 (checked 2026-07-29T12:00:00.000Z)',
130132
].join('\n'),
131133
);

0 commit comments

Comments
 (0)