diff --git a/.changeset/non-interactive-upgrade.md b/.changeset/non-interactive-upgrade.md new file mode 100644 index 000000000..c0df43b40 --- /dev/null +++ b/.changeset/non-interactive-upgrade.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add `-y, --yes` flag to `kimi upgrade` for non-interactive installs. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index faf1e1da8..2767d14af 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -12,7 +12,7 @@ import { registerProviderCommand } from './sub/provider'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; -export type UpgradeCommandHandler = () => void | Promise; +export type UpgradeCommandHandler = (yes?: boolean) => void | Promise; export function createProgram( version: string, @@ -84,8 +84,10 @@ export function createProgram( program .command('upgrade') .description('Upgrade Kimi Code to the latest version.') - .action(async () => { - await onUpgrade(); + .option('-y, --yes', 'Install the update without prompting.', false) + .action(async function () { + const opts = this.optsWithGlobals<{ readonly yes?: boolean }>(); + await onUpgrade(opts.yes === true); }); program diff --git a/apps/kimi-code/src/cli/sub/upgrade.ts b/apps/kimi-code/src/cli/sub/upgrade.ts index c54710645..4cdc2e1ea 100644 --- a/apps/kimi-code/src/cli/sub/upgrade.ts +++ b/apps/kimi-code/src/cli/sub/upgrade.ts @@ -44,6 +44,7 @@ export interface UpgradeDeps { readonly stdout: WritableLike; readonly stderr: WritableLike; readonly isInteractive: boolean; + readonly skipPrompt: boolean; readonly track: UpgradeTrack; readonly logger: UpgradeLogger; } @@ -86,7 +87,9 @@ export async function handleUpgrade( const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); const installCommand = installCommandFor(source, target.version, deps.platform); - if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) { + const autoInstallable = canAutoInstall(source, deps.platform); + + if (!autoInstallable || (!deps.isInteractive && !deps.skipPrompt)) { trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { current_version: currentVersion, target_version: target.version, @@ -101,42 +104,46 @@ export async function handleUpgrade( return 0; } - trackUpgradeEvent(deps.track, 'upgrade_command_prompted', { - current_version: currentVersion, - target_version: target.version, - source, - }); - logUpgradeInfo(deps.logger, 'manual upgrade prompted', { - currentVersion, - targetVersion: target.version, - source, - }); - const choice = await deps.promptForInstallChoice({ - currentVersion, - target, - installCommand, - installSource: source, - }); - if (choice === 'skip') { - trackUpgradeEvent(deps.track, 'upgrade_command_skipped', { + if (!deps.skipPrompt) { + trackUpgradeEvent(deps.track, 'upgrade_command_prompted', { current_version: currentVersion, target_version: target.version, source, }); - logUpgradeInfo(deps.logger, 'manual upgrade skipped', { + logUpgradeInfo(deps.logger, 'manual upgrade prompted', { currentVersion, targetVersion: target.version, source, }); - return 0; + const choice = await deps.promptForInstallChoice({ + currentVersion, + target, + installCommand, + installSource: source, + }); + if (choice === 'skip') { + trackUpgradeEvent(deps.track, 'upgrade_command_skipped', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade skipped', { + currentVersion, + targetVersion: target.version, + source, + }); + return 0; + } } try { - trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { - current_version: currentVersion, - target_version: target.version, - source, - }); + if (!deps.skipPrompt) { + trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { + current_version: currentVersion, + target_version: target.version, + source, + }); + } await deps.installUpdate(source, target.version, deps.platform); trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', { current_version: currentVersion, @@ -182,6 +189,7 @@ function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, isInteractive: overrides.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY), + skipPrompt: overrides.skipPrompt ?? false, track: overrides.track ?? trackTelemetry, logger: overrides.logger ?? log, }; diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index e94472590..c2bdfef37 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -71,7 +71,7 @@ async function handleMigrateCommand(version: string): Promise { await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); } -export async function handleUpgradeCommand(version: string): Promise { +export async function handleUpgradeCommand(version: string, yes = false): Promise { const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { track, @@ -94,7 +94,7 @@ export async function handleUpgradeCommand(version: string): Promise { version, uiMode: CLI_UI_MODE, }); - exitCode = await handleUpgrade(version, { track, logger: log }); + exitCode = await handleUpgrade(version, { track, logger: log, skipPrompt: yes }); } finally { await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); await harness.close().catch(() => {}); @@ -166,8 +166,8 @@ export function main(): void { process.exit(1); }); }, - () => { - void handleUpgradeCommand(version).catch(async (error: unknown) => { + (yes) => { + void handleUpgradeCommand(version, yes).catch(async (error: unknown) => { await logStartupFailure('upgrade', error); process.stderr.write(formatStartupError(error, { operation: 'upgrade' })); process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index e14629e01..3a7c7c486 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -332,6 +332,30 @@ describe('CLI options parsing', () => { expect(upgradeCalls).toBe(1); }); + it('routes upgrade --yes without calling the main action', () => { + let yesValue: boolean | undefined; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + (yes) => { + yesValue = yes; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'upgrade', '--yes']); + + expect(yesValue).toBe(true); + }); + it('registers the visible sub-commands', () => { const program = createProgram( '0.0.0', diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts index 8995f07cf..801373e78 100644 --- a/apps/kimi-code/test/cli/upgrade.test.ts +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -36,6 +36,7 @@ function createDeps(overrides: { readonly latest?: string | null; readonly source?: InstallSource; readonly isInteractive?: boolean; + readonly skipPrompt?: boolean; readonly promptForInstallChoice?: () => Promise; readonly installUpdate?: (source: InstallSource, version: string, platform: NodeJS.Platform) => Promise; } = {}) { @@ -62,6 +63,7 @@ function createDeps(overrides: { }, platform: 'darwin' as NodeJS.Platform, isInteractive: overrides.isInteractive ?? true, + skipPrompt: overrides.skipPrompt ?? false, }; } @@ -165,6 +167,36 @@ describe('handleUpgrade', () => { expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); }); + it('installs without prompting when --yes is used and the source supports auto install', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'npm-global', skipPrompt: true }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_succeeded', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); + }); + + it('prints the manual update command with --yes when the source cannot be auto-installed', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'unsupported', skipPrompt: true }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_manual_command', expect.objectContaining({ + target_version: '0.5.0', + source: 'unsupported', + })); + expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); + }); + it('returns a failing exit code when the foreground install fails', async () => { const { stderr, writable } = captureOutput(); const deps = createDeps({