Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
24 changes: 23 additions & 1 deletion .agents/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,29 @@ The Rust implementation (`kagent`) lives in a separate repository and is **not**

Wait for explicit user approval before proceeding. If the user flags anything, fix it and re-confirm — do not push.

10. **Open the PR.** Commit all changes, push, and open a PR with `gh` describing the version bumps.
10. **Open the PR.** Commit all changes, push, and open a PR with `gh`. The PR description must follow this structure (see https://github.com/MoonshotAI/kimi-cli/pull/2225 for reference):

```markdown
## Summary
- Bump <package> to <version>
- Move the current release notes under <version>
- (If applicable) Move breaking-change entries under <version>
- (If applicable) Any additional noteworthy changes (dependency pin updates, etc.)

## Validation
- uv run python scripts/check_version_tag.py --pyproject <pyproject> --expected-version <version>
- (For root releases) uv run python scripts/check_version_tag.py --pyproject packages/kimi-code/pyproject.toml --expected-version <version>
- uv run python scripts/check_kimi_dependency_versions.py --root-pyproject pyproject.toml --kosong-pyproject packages/kosong/pyproject.toml --pykaos-pyproject packages/kaos/pyproject.toml
- make check

## Post-merge
After this PR lands, create and push the release tag:

```sh
git tag <tag>
git push origin <tag>
```
```
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 Fix unmatched Markdown fence in release skill instructions

The new PR template example opens a fenced block at ```markdown and then introduces ```sh inside it, which causes the outer fence to terminate early and leaves the trailing ``` unmatched. In rendered Markdown, this can swallow the rest of the document into a code block, making step 11 and stop conditions hard to read and easy to miss during release execution.

Useful? React with 👍 / 👎.


11. **Hand off the tag step.** After merge, switch to `main`, pull latest, and tell the user the exact `git tag` command for the final release tag (pick the right tag pattern from the table above — e.g. `git tag 1.43.0` for a root release, `git tag kosong-0.54.0` for a kosong release). The user will run the tag and push tags themselves.

Expand Down
39 changes: 32 additions & 7 deletions src/kimi_cli/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,17 +200,39 @@ def __init__(
self._current_prompt_approval_request: ApprovalRequest | None = None
self._approval_modal: ApprovalPromptDelegate | None = None
self._exit_after_run = False
soul_slash_commands = list(soul.available_slash_commands)
shell_slash_commands = shell_slash_registry.list_commands()
self._available_slash_commands: dict[str, SlashCommand[Any]] = {
**{cmd.name: cmd for cmd in soul.available_slash_commands},
**{cmd.name: cmd for cmd in shell_slash_registry.list_commands()},
**{cmd.name: cmd for cmd in soul_slash_commands},
**{cmd.name: cmd for cmd in shell_slash_commands},
}
"""Shell-level slash commands + soul-level slash commands. Name to command mapping."""
"""Shell-level slash commands + soul-level slash commands. Primary name mapping."""
self._available_slash_command_index = self._index_slash_commands(
[*soul_slash_commands, *shell_slash_commands]
)
"""Shell-level slash commands + soul-level slash commands.
Primary name and alias mapping.
"""

@property
def available_slash_commands(self) -> dict[str, SlashCommand[Any]]:
"""Get all available slash commands, including shell-level and soul-level commands."""
return self._available_slash_commands

@staticmethod
def _index_slash_commands(commands: list[SlashCommand[Any]]) -> dict[str, SlashCommand[Any]]:
indexed: dict[str, SlashCommand[Any]] = {}
for command in commands:
indexed[command.name] = command
for alias in command.aliases:
indexed[alias] = command
return indexed

def _find_available_slash_command(self, name: str) -> SlashCommand[Any] | None:
return self._available_slash_command_index.get(name) or self._available_slash_commands.get(
name
)

def _print_cwd_lost_crash(self) -> None:
"""Print a crash report when the working directory is no longer accessible."""
runtime = self.soul.runtime if isinstance(self.soul, KimiSoul) else None
Expand Down Expand Up @@ -634,14 +656,16 @@ def _can_auto_trigger_pending() -> bool:
continue

if slash_cmd_call := self._agent_slash_command_call(user_input):
available_command = self._find_available_slash_command(slash_cmd_call.name)
is_soul_slash = (
slash_cmd_call.name in self._available_slash_commands
available_command is not None
and shell_slash_registry.find_command(slash_cmd_call.name) is None
)
if is_soul_slash:
from kimi_cli.telemetry import track

track("input_command", command=slash_cmd_call.name)
assert available_command is not None
track("input_command", command=available_command.name)
background_autotrigger_armed = True
resume_prompt.set()
await self.run_soul_command(slash_cmd_call.raw_input)
Expand Down Expand Up @@ -755,7 +779,8 @@ async def _run_slash_command(self, command_call: SlashCommandCall) -> None:
from kimi_cli.cli import Reload, SwitchToVis, SwitchToWeb
from kimi_cli.telemetry import track

if command_call.name not in self._available_slash_commands:
available_command = self._find_available_slash_command(command_call.name)
if available_command is None:
logger.info("Unknown slash command /{command}", command=command_call.name)
track("input_command_invalid")
console.print(
Expand All @@ -764,7 +789,7 @@ async def _run_slash_command(self, command_call: SlashCommandCall) -> None:
)
return

track("input_command", command=command_call.name)
track("input_command", command=available_command.name)

command = shell_slash_registry.find_command(command_call.name)
if command is None:
Expand Down
21 changes: 14 additions & 7 deletions src/kimi_cli/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class CwdLostError(OSError):
class SlashCommandCompleter(Completer):
"""
A completer that:
- Shows one line per slash command using the canonical "/name"
- Shows canonical matches as "/name" and alias matches as "/name (alias)"
- Fuzzy-matches by primary name or any alias while inserting the canonical "/name"
- Only activates when the current token starts with '/'
"""
Expand Down Expand Up @@ -142,25 +142,32 @@ def get_completions(
token = text[last_space + 1 :]

typed = token[1:]
if typed and typed in self._command_lookup:
return
mention_doc = Document(text=typed, cursor_position=len(typed))
candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))

seen: set[str] = set()

candidate_triggers: list[str] = []
if typed and typed in self._command_lookup:
candidate_triggers.append(typed)
for candidate in candidates:
commands = self._command_lookup.get(candidate.text)
if candidate.text not in candidate_triggers:
candidate_triggers.append(candidate.text)

for trigger in candidate_triggers:
commands = self._command_lookup.get(trigger)
if not commands:
continue
for cmd in commands:
if cmd.name in seen:
continue
seen.add(cmd.name)
completion_text = f"/{cmd.name}"
if trigger == cmd.name and typed == cmd.name:
completion_text += " "
yield Completion(
text=f"/{cmd.name}",
text=completion_text,
start_position=-len(token),
display=f"/{cmd.name}",
display=cmd.display_name(trigger),
display_meta=cmd.description,
)

Expand Down
30 changes: 27 additions & 3 deletions src/kimi_cli/ui/shell/slash.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

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

from prompt_toolkit.shortcuts.choice_input import ChoiceInput
Expand Down Expand Up @@ -63,6 +63,30 @@ def exit(app: Shell, args: str):
]


def _unique_commands(commands: Iterable[SlashCommand[Any]]) -> list[SlashCommand[Any]]:
unique: list[SlashCommand[Any]] = []
seen: set[str] = set()
for cmd in commands:
if cmd.name in seen:
continue
unique.append(cmd)
seen.add(cmd.name)
return unique


def _expanded_command_items(commands: Iterable[SlashCommand[Any]]) -> list[tuple[str, str]]:
items: list[tuple[str, str]] = []
for cmd in sorted(_unique_commands(commands), key=lambda c: c.name):
seen = {cmd.name}
items.append((cmd.display_name(cmd.name), cmd.description))
for alias in cmd.aliases:
if alias in seen:
continue
items.append((cmd.display_name(alias), cmd.description))
seen.add(alias)
return items


@registry.command(aliases=["h", "?"])
@shell_mode_registry.command(aliases=["h", "?"])
def help(app: Shell, args: str):
Expand Down Expand Up @@ -115,15 +139,15 @@ def section(title: str, items: list[tuple[str, str]], color: str) -> BulletColum
renderables.append(
section(
"Slash commands",
[(c.slash_name(), c.description) for c in sorted(commands, key=lambda c: c.name)],
_expanded_command_items(commands),
"blue",
)
)
if skills:
renderables.append(
section(
"Skills",
[(c.slash_name(), c.description) for c in sorted(skills, key=lambda c: c.name)],
_expanded_command_items(skills),
"cyan",
)
)
Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/ui/shell/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class UsageRow:
reset_hint: str | None = None


@registry.command(aliases=["/status"])
@registry.command(aliases=["status"])
async def usage(app: Shell, args: str):
"""Display API usage and quota information"""
assert isinstance(app.soul, KimiSoul)
Expand Down
21 changes: 21 additions & 0 deletions src/kimi_cli/utils/slashcmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ class SlashCommand[F: Callable[..., None | Awaitable[None]]]:
func: F
aliases: list[str]

def display_name(self, trigger: str | None = None) -> str:
"""/name for canonical triggers, /name (alias) for alias triggers."""
if trigger is not None and trigger != self.name and trigger in self.aliases:
return f"/{self.name} ({trigger})"
return f"/{self.name}"

def slash_name(self):
"""/name (aliases)"""
if self.aliases:
Expand Down Expand Up @@ -92,6 +98,21 @@ def list_commands(self) -> list[SlashCommand[F]]:
"""Get all unique primary slash commands (without duplicating aliases)."""
return list(self._commands.values())

def iter_command_entries(self) -> list[tuple[str, SlashCommand[F]]]:
"""Get canonical and alias entries as (trigger, command) pairs."""
entries: list[tuple[str, SlashCommand[F]]] = []
for cmd in self._commands.values():
entries.append((cmd.name, cmd))
seen = {cmd.name}
for alias in cmd.aliases:
if alias in seen:
continue
if self._command_aliases.get(alias) is not cmd:
continue
entries.append((alias, cmd))
seen.add(alias)
return entries


@dataclass(frozen=True, slots=True, kw_only=True)
class SlashCommandCall:
Expand Down
29 changes: 28 additions & 1 deletion tests/ui_and_conv/test_shell_run_placeholders.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import kimi_cli.ui.shell as shell_module
from kimi_cli.soul import Soul
from kimi_cli.ui.shell.prompt import PromptMode, UserInput
from kimi_cli.utils.slashcmd import SlashCommand
from kimi_cli.utils.slashcmd import SlashCommand, SlashCommandCall
from kimi_cli.wire.types import TextPart


Expand Down Expand Up @@ -72,6 +72,33 @@ def _noop(app: object, args: str) -> None:
pass


@pytest.mark.asyncio
async def test_shell_slash_alias_tracks_canonical_command(monkeypatch) -> None:
tracked: list[tuple[str, dict[str, object]]] = []
fake_command = SlashCommand(
name="help",
description="help command",
func=_noop,
aliases=["h"],
)

monkeypatch.setattr(
"kimi_cli.telemetry.track",
lambda event, **properties: tracked.append((event, properties)),
)
monkeypatch.setattr(
shell_module.shell_slash_registry,
"find_command",
lambda name: fake_command if name == "h" else None,
)

shell = shell_module.Shell(cast(Soul, _make_fake_soul()))

await shell._run_slash_command(SlashCommandCall(name="h", args="", raw_input="/h"))

assert ("input_command", {"command": "help"}) in tracked


@pytest.fixture
def _patched_shell_run(monkeypatch):
_FakePromptSession.instances = []
Expand Down
44 changes: 43 additions & 1 deletion tests/ui_and_conv/test_shell_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@

from kimi_cli.cli import Reload
from kimi_cli.session import Session
from kimi_cli.ui.shell.slash import ShellSlashCmdFunc, shell_mode_registry
from kimi_cli.ui.shell.slash import (
ShellSlashCmdFunc,
_expanded_command_items,
shell_mode_registry,
)
from kimi_cli.ui.shell.slash import registry as shell_slash_registry
from kimi_cli.utils.slashcmd import SlashCommand
from kimi_cli.wire.types import TextPart
Expand Down Expand Up @@ -96,6 +100,44 @@ def test_not_in_soul_registry(self) -> None:
assert soul_slash_registry.find_command("new") is None


class TestAliasRegistration:
"""Verify shell aliases resolve to their canonical commands."""

def test_agent_mode_aliases_resolve_to_canonical_commands(self) -> None:
help_cmd = shell_slash_registry.find_command("h")
quit_cmd = shell_slash_registry.find_command("quit")
status_cmd = shell_slash_registry.find_command("status")

assert help_cmd is not None
assert quit_cmd is not None
assert status_cmd is not None
assert help_cmd.name == "help"
assert quit_cmd.name == "exit"
assert status_cmd.name == "usage"

def test_help_items_expand_aliases_as_separate_rows(self) -> None:
command = SlashCommand(
name="clear",
description="Clear the context",
func=lambda _shell, _args: None,
aliases=["reset", "reset"],
)

assert _expanded_command_items([command]) == [
("/clear", "Clear the context"),
("/clear (reset)", "Clear the context"),
]

def test_shell_mode_aliases_resolve_to_canonical_commands(self) -> None:
help_cmd = shell_mode_registry.find_command("h")
quit_cmd = shell_mode_registry.find_command("quit")

assert help_cmd is not None
assert quit_cmd is not None
assert help_cmd.name == "help"
assert quit_cmd.name == "exit"


# ---------------------------------------------------------------------------
# /new — behaviour
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading