Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
18 changes: 8 additions & 10 deletions src/kimi_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,8 +698,6 @@ async def run_shell(
self, command: str | None = None, *, prefill_text: str | None = None
) -> bool:
"""Run the Kimi Code CLI instance with shell UI."""
from rich.text import Text

from kimi_cli.ui.shell import Shell, WelcomeInfoItem

if command is None:
Expand Down Expand Up @@ -768,16 +766,16 @@ async def run_shell(
level=WelcomeInfoItem.Level.WARN,
)
)
from kimi_cli.ui.shell.migration_nudge import (
already_installed_text,
kimi_code_installed,
welcome_card_text,
)

welcome_info.append(
WelcomeInfoItem(
name="\nTip",
value=Text.assemble(
"We just released Kimi Code — our new coding agent. Check it out at ",
Text(
"https://www.kimi.com/code",
style="link https://www.kimi.com/code underline",
),
),
name="\n✨ Update",
value=already_installed_text() if kimi_code_installed() else welcome_card_text(),
level=WelcomeInfoItem.Level.WARN,
)
)
Expand Down
11 changes: 6 additions & 5 deletions src/kimi_cli/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from kimi_cli.ui.shell.console import console
from kimi_cli.ui.shell.echo import render_user_echo_text
from kimi_cli.ui.shell.mcp_status import render_mcp_prompt
from kimi_cli.ui.shell.migration_nudge import print_migration_goodbye
from kimi_cli.ui.shell.prompt import (
BgTaskCounts,
CustomPromptSession,
Expand Down Expand Up @@ -578,7 +579,7 @@ def _can_auto_trigger_pending() -> bool:
else:
bg_auto_failures = 0
if self._exit_after_run:
console.print("Bye!")
print_migration_goodbye(console)
break
continue

Expand All @@ -596,7 +597,7 @@ def _can_auto_trigger_pending() -> bool:
continue

if event.kind == "eof":
console.print("Bye!")
print_migration_goodbye(console)
break

if event.kind == "cwd_lost":
Expand All @@ -623,7 +624,7 @@ def _can_auto_trigger_pending() -> bool:

if self._should_exit_input(user_input):
logger.debug("Exiting by slash command")
console.print("Bye!")
print_migration_goodbye(console)
break

if user_input.mode == PromptMode.SHELL:
Expand Down Expand Up @@ -668,7 +669,7 @@ def _can_auto_trigger_pending() -> bool:
await self.run_soul_command(slash_cmd_call.raw_input)
console.print()
if self._exit_after_run:
console.print("Bye!")
print_migration_goodbye(console)
break
else:
await self._run_slash_command(slash_cmd_call)
Expand All @@ -680,7 +681,7 @@ def _can_auto_trigger_pending() -> bool:
await self.run_soul_command(user_input.content)
console.print()
if self._exit_after_run:
console.print("Bye!")
print_migration_goodbye(console)
break
finally:
prompt_task.cancel()
Expand Down
95 changes: 95 additions & 0 deletions src/kimi_cli/ui/shell/migration_nudge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import annotations

import contextlib
from datetime import date
from pathlib import Path

from rich.console import Console
from rich.text import Text

from kimi_cli.share import get_share_dir

_INSTALL_SH = "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash"
_INSTALL_PS = "irm https://code.kimi.com/kimi-code/install.ps1 | iex"


def install_command(platform: str) -> str:
"""Return the Kimi Code install command for the given sys.platform value."""
if platform == "win32":
return _INSTALL_PS
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invoke PowerShell explicitly for Windows upgrades

When sys.platform == "win32", /upgrade passes this bare PowerShell pipeline to _run_shell_command, which runs it through asyncio.create_subprocess_shell using the platform default shell (normally cmd.exe), where irm and iex are not commands. Windows users who accept the prompt therefore get a shell failure instead of an install; return a command that explicitly launches PowerShell or teach the caller to use PowerShell for this platform.

Useful? React with 👍 / 👎.

return _INSTALL_SH


def kimi_code_installed(home: Path | None = None) -> bool:
"""True if the standalone Kimi Code is installed (its data dir ~/.kimi-code exists)."""
home = home or Path.home()
return (home / ".kimi-code").is_dir()


def exit_nudge_marker(share_dir: Path) -> Path:
"""Path of the throttle marker recording the last day the exit nudge was shown."""
return share_dir / ".migration-nudge"


def should_show_exit_nudge(marker: Path, today: str) -> bool:
"""Return True at most once per calendar day; record `today` when returning True.

`today` is an ISO date string (e.g. "2026-06-05"), injected for testability.
"""
try:
last = marker.read_text(encoding="utf-8").strip()
except OSError:
last = ""
if last == today:
return False
with contextlib.suppress(OSError):
marker.write_text(today, encoding="utf-8")
return True


def welcome_card_text() -> Text:
"""Welcome-screen card nudging users to upgrade (shown when Kimi Code is NOT installed)."""
return Text.assemble(
"The new Kimi Code is here — rebuilt to be faster and more powerful.\n",
"Run ",
("/upgrade", "bold"),
"; your config & sessions carry over.",
)


def already_installed_text() -> Text:
"""Welcome-screen note shown when Kimi Code IS already installed on this machine."""
return Text.assemble(
"The new Kimi Code is already installed. Start it in a fresh terminal with ",
("kimi", "bold"),
" (verify: ",
("which kimi", "cyan"),
" → ~/.kimi-code).",
Comment on lines +81 to +85
)


def exit_nudge_text() -> Text:
"""Throttled tip printed on graceful exit."""
return Text.assemble(
("Tip: ", "yellow"),
"The new Kimi Code is rebuilt to be faster and more powerful.\n",
"Install: ",
(_INSTALL_SH, "cyan"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 exit_nudge_text() hardcodes the bash install command, showing wrong command on Windows

exit_nudge_text() at line 77 hardcodes _INSTALL_SH (the curl ... | bash command) in the exit nudge shown on graceful exit. On Windows, users will see a bash command they cannot use. The function should use install_command(sys.platform) to select the platform-appropriate command, as the /upgrade command correctly does at src/kimi_cli/ui/shell/slash.py:730.

Suggested change
(_INSTALL_SH, "cyan"),
(install_command(__import__("sys").platform), "cyan"),
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the platform installer in the exit nudge

When the CLI exits on Windows and Kimi Code is not installed, print_migration_goodbye() prints this exit_nudge_text(), but the text hard-codes the Unix curl ... | bash installer instead of using the platform-specific command. That means Windows users get an exit prompt with a command that depends on bash and bypasses the PowerShell installer path that install_command() otherwise defines, so the migration guidance is not actionable in that environment.

Useful? React with 👍 / 👎.

(" (or run /upgrade next time)", "grey50"),
Comment on lines +92 to +96
)


def print_migration_goodbye(
console: Console, *, home: Path | None = None, today: str | None = None
) -> None:
"""Print the farewell ("Bye!") plus, at most once per day, the migration tip.

Skipped entirely (only "Bye!") if Kimi Code is already installed.
`home`/`today` are injectable for testing; in production they default to the real values.
"""
console.print("Bye!")
if kimi_code_installed(home):
return
today = today or date.today().isoformat()
if should_show_exit_nudge(exit_nudge_marker(get_share_dir()), today):
console.print(exit_nudge_text())
38 changes: 38 additions & 0 deletions src/kimi_cli/ui/shell/slash.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import sys
from collections.abc import Awaitable, Callable, Iterable
from typing import TYPE_CHECKING, Any, cast

Expand Down Expand Up @@ -718,6 +719,43 @@ def vis(app: Shell, args: str):
raise SwitchToVis(session_id=session_id)


@registry.command
async def upgrade(app: Shell, args: str):
Comment on lines +722 to +723

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register /upgrade for shell mode too

When the prompt is toggled to shell mode with Ctrl-X, _run_shell_command only dispatches slash commands found in shell_mode_registry; because /upgrade is registered only in the agent-mode registry here, a user following the new welcome/exit guidance while in shell mode gets the "not available in shell mode" warning instead of the installer. Since this command does not require agent context, it should be registered in shell mode as well.

Useful? React with 👍 / 👎.

"""Install Kimi Code — the faster successor (migrates your config & sessions)"""
from kimi_cli.telemetry import track
from kimi_cli.ui.shell.migration_nudge import install_command

track("upgrade_invoked")

cmd = install_command(sys.platform)
console.print(
"[bold]This will install the new Kimi Code by running:[/bold]\n"
f" [cyan]{cmd}[/cyan]\n"
"Your existing config & sessions will be migrated automatically."
)
try:
choice = await ChoiceInput(
message="Proceed with installation? (↑↓ navigate, Enter select, Ctrl+C cancel):",
options=[("yes", "Yes, install now"), ("no", "No, just show me the command")],
default="yes",
).prompt_async()
except (EOFError, KeyboardInterrupt):
console.print("[grey50]Upgrade cancelled.[/grey50]")
return

if choice != "yes":
console.print(f"No problem. To install later, run:\n [cyan]{cmd}[/cyan]")
return

await app._run_shell_command(cmd) # pyright: ignore[reportPrivateUsage]
console.print(
"\n[green]The new Kimi Code is installed ✓[/green] "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not report success after a failed installer

If the installer exits non-zero (for example due to no network, a 404 from the install URL, or a shell error), _run_shell_command only waits for the subprocess and returns None, so this path still prints “The new Kimi Code is installed ✓” even though nothing was installed. The upgrade flow should observe the subprocess exit status or verify ~/.kimi-code before showing the success/migration message.

Useful? React with 👍 / 👎.

"Your config & sessions were migrated automatically.\n"
Comment on lines +756 to +758

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop claiming migration has already completed

After a successful installer run, this message says config and sessions were migrated, but the Kimi Code migration flow does not run during installation: the first kimi launch prompts the user, or they can run kimi migrate manually (per the migration docs). In the normal /upgrade path where the installer succeeds and the user has not yet launched the new CLI, this falsely tells them their data is already carried over; change this to say migration will be offered on first launch or to run kimi migrate.

Useful? React with 👍 / 👎.

"Open a [bold]new terminal[/bold] and run [bold]kimi[/bold] to start it.\n"
"[grey50](Verify with `which kimi` — it should point inside ~/.kimi-code.)[/grey50]"
)
Comment on lines +757 to +762

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 /upgrade unconditionally prints success message even when installation fails

After await app._run_shell_command(cmd) at line 750, the success message ("The new Kimi Code is installed ✓") is printed unconditionally at lines 751-756. _run_shell_command (src/kimi_cli/ui/shell/__init__.py:716) returns None, swallows exceptions internally, and does not check the subprocess exit code. If the install script fails (non-zero exit), the subprocess can't be created, or the network is down, the user still sees the success message claiming installation succeeded and config was migrated.

Prompt for agents
The /upgrade command in src/kimi_cli/ui/shell/slash.py:750-756 unconditionally prints a success message after calling app._run_shell_command(cmd). However, _run_shell_command (src/kimi_cli/ui/shell/__init__.py:716) is a void method that internally catches exceptions and does not expose the subprocess exit code. There are two approaches to fix this:

1. Check for the result after running: have _run_shell_command return the process exit code (or a bool), then conditionally print the success message only on success. On failure, print the manual install command instead. This requires modifying _run_shell_command's return type or adding a variant that returns status.

2. Run the subprocess directly in the upgrade function instead of delegating to _run_shell_command, so you can inspect proc.returncode after proc.wait(). On non-zero exit or exception, show a failure message with the manual command.

The design spec (section 5.1) explicitly says: "失败则打印命令让用户手动执行 + 文档链接" (on failure, print the command for manual execution + docs link).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



@registry.command
async def mcp(app: Shell, args: str):
"""Show MCP servers and tools"""
Expand Down
35 changes: 35 additions & 0 deletions tests/core/test_exit_nudge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

from pathlib import Path
from unittest.mock import Mock

from kimi_cli.ui.shell.migration_nudge import print_migration_goodbye


def _printed(console: Mock) -> str:
return " ".join(str(c.args[0]) for c in console.print.call_args_list if c.args)


def test_goodbye_shows_nudge_when_not_installed(tmp_path: Path, monkeypatch):
monkeypatch.setattr("kimi_cli.ui.shell.migration_nudge.get_share_dir", lambda: tmp_path)
console = Mock()
print_migration_goodbye(console, home=tmp_path, today="2026-06-05")
# "Bye!" plus the migration tip = 2 prints
assert console.print.call_count == 2
assert "Bye!" in _printed(console)

# same day -> throttled, only "Bye!"
console.print.reset_mock()
print_migration_goodbye(console, home=tmp_path, today="2026-06-05")
assert console.print.call_count == 1
assert "Bye!" in _printed(console)


def test_goodbye_skips_nudge_when_installed(tmp_path: Path, monkeypatch):
monkeypatch.setattr("kimi_cli.ui.shell.migration_nudge.get_share_dir", lambda: tmp_path)
(tmp_path / ".kimi-code").mkdir()
console = Mock()
print_migration_goodbye(console, home=tmp_path, today="2026-06-05")
# installed -> no nudge, only "Bye!"
assert console.print.call_count == 1
assert "Bye!" in _printed(console)
28 changes: 28 additions & 0 deletions tests/core/test_migration_nudge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from pathlib import Path

from kimi_cli.ui.shell import migration_nudge as mn


def test_install_command_per_platform():
assert mn.install_command("darwin") == (
"curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash"
)
assert mn.install_command("linux") == (
"curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash"
)
assert mn.install_command("win32") == ("irm https://code.kimi.com/kimi-code/install.ps1 | iex")


def test_kimi_code_installed_detects_dir(tmp_path: Path):
assert mn.kimi_code_installed(tmp_path) is False
(tmp_path / ".kimi-code").mkdir()
assert mn.kimi_code_installed(tmp_path) is True


def test_exit_nudge_throttled_once_per_day(tmp_path: Path):
marker = mn.exit_nudge_marker(tmp_path)
assert mn.should_show_exit_nudge(marker, "2026-06-05") is True
assert mn.should_show_exit_nudge(marker, "2026-06-05") is False
assert mn.should_show_exit_nudge(marker, "2026-06-06") is True
22 changes: 9 additions & 13 deletions tests/core/test_startup_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from unittest.mock import AsyncMock, Mock

import pytest
from rich.text import Text

import kimi_cli.app as app_module
import kimi_cli.ui.shell.startup as startup_module
Expand Down Expand Up @@ -120,9 +119,14 @@ def set_hook_engine(self, engine):


@pytest.mark.asyncio
async def test_run_shell_adds_new_coding_agent_download_tip(runtime, monkeypatch) -> None:
async def test_run_shell_adds_kimi_code_migration_card(runtime, monkeypatch) -> None:
from kimi_cli.ui.shell import WelcomeInfoItem

# Not installed -> the welcome screen shows the upgrade card (deterministic).
monkeypatch.setattr(
"kimi_cli.ui.shell.migration_nudge.kimi_code_installed", lambda home=None: False
)

captured: dict[str, object] = {}

class FakeShell:
Expand All @@ -149,17 +153,9 @@ async def fake_env():

welcome_info = cast("list[WelcomeInfoItem]", captured["welcome_info"])
tip = welcome_info[-1]
assert tip == WelcomeInfoItem(
name="\nTip",
value=Text.assemble(
"We just released Kimi Code — our new coding agent. Check it out at ",
Text(
"https://www.kimi.com/code",
style="link https://www.kimi.com/code underline",
),
),
level=WelcomeInfoItem.Level.WARN,
)
assert tip.name == "\n✨ Update"
assert tip.level == WelcomeInfoItem.Level.WARN
assert "/upgrade" in tip.value.plain


@pytest.mark.asyncio
Expand Down
20 changes: 20 additions & 0 deletions tests/core/test_welcome_migration_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from pathlib import Path

from kimi_cli.ui.shell.migration_nudge import (
already_installed_text,
kimi_code_installed,
welcome_card_text,
)


def test_card_when_not_installed(tmp_path: Path):
assert kimi_code_installed(tmp_path) is False
assert "/upgrade" in welcome_card_text().plain


def test_note_when_installed(tmp_path: Path):
(tmp_path / ".kimi-code").mkdir()
assert kimi_code_installed(tmp_path) is True
assert "already installed" in already_installed_text().plain
5 changes: 5 additions & 0 deletions tests/ui_and_conv/test_shell_run_placeholders.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ def _patched_shell_run(monkeypatch):
_FakePromptSession.responses = deque()
monkeypatch.setattr(shell_module, "CustomPromptSession", _FakePromptSession)
monkeypatch.setattr(shell_module, "_print_welcome_info", lambda *args, **kwargs: None)
# Neutralize the migration nudge so exit output stays deterministic; these
# tests exercise placeholder/exit routing, not the nudge (covered elsewhere).
monkeypatch.setattr(
shell_module, "print_migration_goodbye", lambda console: console.print("Bye!")
)
monkeypatch.setattr(shell_module, "get_env_bool", lambda name: True)
monkeypatch.setattr(shell_module, "ensure_tty_sane", lambda: None)
monkeypatch.setattr(shell_module, "ensure_new_line", lambda: None)
Expand Down
Loading
Loading