kimi-cli 0.44__py3-none-any.whl → 0.78__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kimi-cli might be problematic. Click here for more details.

Files changed (137) hide show
  1. kimi_cli/CHANGELOG.md +349 -40
  2. kimi_cli/__init__.py +6 -0
  3. kimi_cli/acp/AGENTS.md +91 -0
  4. kimi_cli/acp/__init__.py +13 -0
  5. kimi_cli/acp/convert.py +111 -0
  6. kimi_cli/acp/kaos.py +270 -0
  7. kimi_cli/acp/mcp.py +46 -0
  8. kimi_cli/acp/server.py +335 -0
  9. kimi_cli/acp/session.py +445 -0
  10. kimi_cli/acp/tools.py +158 -0
  11. kimi_cli/acp/types.py +13 -0
  12. kimi_cli/agents/default/agent.yaml +4 -4
  13. kimi_cli/agents/default/sub.yaml +2 -1
  14. kimi_cli/agents/default/system.md +79 -21
  15. kimi_cli/agents/okabe/agent.yaml +17 -0
  16. kimi_cli/agentspec.py +53 -25
  17. kimi_cli/app.py +180 -52
  18. kimi_cli/cli/__init__.py +595 -0
  19. kimi_cli/cli/__main__.py +8 -0
  20. kimi_cli/cli/info.py +63 -0
  21. kimi_cli/cli/mcp.py +349 -0
  22. kimi_cli/config.py +153 -17
  23. kimi_cli/constant.py +3 -0
  24. kimi_cli/exception.py +23 -2
  25. kimi_cli/flow/__init__.py +117 -0
  26. kimi_cli/flow/d2.py +376 -0
  27. kimi_cli/flow/mermaid.py +218 -0
  28. kimi_cli/llm.py +129 -23
  29. kimi_cli/metadata.py +32 -7
  30. kimi_cli/platforms.py +262 -0
  31. kimi_cli/prompts/__init__.py +2 -0
  32. kimi_cli/prompts/compact.md +4 -5
  33. kimi_cli/session.py +223 -31
  34. kimi_cli/share.py +2 -0
  35. kimi_cli/skill.py +145 -0
  36. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  37. kimi_cli/skills/skill-creator/SKILL.md +351 -0
  38. kimi_cli/soul/__init__.py +51 -20
  39. kimi_cli/soul/agent.py +213 -85
  40. kimi_cli/soul/approval.py +86 -17
  41. kimi_cli/soul/compaction.py +64 -53
  42. kimi_cli/soul/context.py +38 -5
  43. kimi_cli/soul/denwarenji.py +2 -0
  44. kimi_cli/soul/kimisoul.py +442 -60
  45. kimi_cli/soul/message.py +54 -54
  46. kimi_cli/soul/slash.py +72 -0
  47. kimi_cli/soul/toolset.py +387 -6
  48. kimi_cli/toad.py +74 -0
  49. kimi_cli/tools/AGENTS.md +5 -0
  50. kimi_cli/tools/__init__.py +42 -34
  51. kimi_cli/tools/display.py +25 -0
  52. kimi_cli/tools/dmail/__init__.py +10 -10
  53. kimi_cli/tools/dmail/dmail.md +11 -9
  54. kimi_cli/tools/file/__init__.py +1 -3
  55. kimi_cli/tools/file/glob.py +20 -23
  56. kimi_cli/tools/file/grep.md +1 -1
  57. kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
  58. kimi_cli/tools/file/read.md +24 -6
  59. kimi_cli/tools/file/read.py +134 -50
  60. kimi_cli/tools/file/replace.md +1 -1
  61. kimi_cli/tools/file/replace.py +36 -29
  62. kimi_cli/tools/file/utils.py +282 -0
  63. kimi_cli/tools/file/write.py +43 -22
  64. kimi_cli/tools/multiagent/__init__.py +7 -0
  65. kimi_cli/tools/multiagent/create.md +11 -0
  66. kimi_cli/tools/multiagent/create.py +50 -0
  67. kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
  68. kimi_cli/tools/shell/__init__.py +120 -0
  69. kimi_cli/tools/{bash → shell}/bash.md +1 -2
  70. kimi_cli/tools/shell/powershell.md +25 -0
  71. kimi_cli/tools/test.py +4 -4
  72. kimi_cli/tools/think/__init__.py +2 -2
  73. kimi_cli/tools/todo/__init__.py +14 -8
  74. kimi_cli/tools/utils.py +64 -24
  75. kimi_cli/tools/web/fetch.py +68 -13
  76. kimi_cli/tools/web/search.py +10 -12
  77. kimi_cli/ui/acp/__init__.py +65 -412
  78. kimi_cli/ui/print/__init__.py +37 -49
  79. kimi_cli/ui/print/visualize.py +179 -0
  80. kimi_cli/ui/shell/__init__.py +141 -84
  81. kimi_cli/ui/shell/console.py +2 -0
  82. kimi_cli/ui/shell/debug.py +28 -23
  83. kimi_cli/ui/shell/keyboard.py +5 -1
  84. kimi_cli/ui/shell/prompt.py +220 -194
  85. kimi_cli/ui/shell/replay.py +111 -46
  86. kimi_cli/ui/shell/setup.py +89 -82
  87. kimi_cli/ui/shell/slash.py +422 -0
  88. kimi_cli/ui/shell/update.py +4 -2
  89. kimi_cli/ui/shell/usage.py +271 -0
  90. kimi_cli/ui/shell/visualize.py +574 -72
  91. kimi_cli/ui/wire/__init__.py +267 -0
  92. kimi_cli/ui/wire/jsonrpc.py +142 -0
  93. kimi_cli/ui/wire/protocol.py +1 -0
  94. kimi_cli/utils/__init__.py +0 -0
  95. kimi_cli/utils/aiohttp.py +2 -0
  96. kimi_cli/utils/aioqueue.py +72 -0
  97. kimi_cli/utils/broadcast.py +37 -0
  98. kimi_cli/utils/changelog.py +12 -7
  99. kimi_cli/utils/clipboard.py +12 -0
  100. kimi_cli/utils/datetime.py +37 -0
  101. kimi_cli/utils/environment.py +58 -0
  102. kimi_cli/utils/envvar.py +12 -0
  103. kimi_cli/utils/frontmatter.py +44 -0
  104. kimi_cli/utils/logging.py +7 -6
  105. kimi_cli/utils/message.py +9 -14
  106. kimi_cli/utils/path.py +99 -9
  107. kimi_cli/utils/pyinstaller.py +6 -0
  108. kimi_cli/utils/rich/__init__.py +33 -0
  109. kimi_cli/utils/rich/columns.py +99 -0
  110. kimi_cli/utils/rich/markdown.py +961 -0
  111. kimi_cli/utils/rich/markdown_sample.md +108 -0
  112. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  113. kimi_cli/utils/signals.py +2 -0
  114. kimi_cli/utils/slashcmd.py +124 -0
  115. kimi_cli/utils/string.py +2 -0
  116. kimi_cli/utils/term.py +168 -0
  117. kimi_cli/utils/typing.py +20 -0
  118. kimi_cli/wire/__init__.py +98 -29
  119. kimi_cli/wire/serde.py +45 -0
  120. kimi_cli/wire/types.py +299 -0
  121. kimi_cli-0.78.dist-info/METADATA +200 -0
  122. kimi_cli-0.78.dist-info/RECORD +135 -0
  123. kimi_cli-0.78.dist-info/entry_points.txt +4 -0
  124. kimi_cli/cli.py +0 -250
  125. kimi_cli/soul/runtime.py +0 -96
  126. kimi_cli/tools/bash/__init__.py +0 -99
  127. kimi_cli/tools/file/patch.md +0 -8
  128. kimi_cli/tools/file/patch.py +0 -143
  129. kimi_cli/tools/mcp.py +0 -85
  130. kimi_cli/ui/shell/liveview.py +0 -386
  131. kimi_cli/ui/shell/metacmd.py +0 -262
  132. kimi_cli/wire/message.py +0 -91
  133. kimi_cli-0.44.dist-info/METADATA +0 -188
  134. kimi_cli-0.44.dist-info/RECORD +0 -89
  135. kimi_cli-0.44.dist-info/entry_points.txt +0 -3
  136. /kimi_cli/tools/{task → multiagent}/task.md +0 -0
  137. {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
kimi_cli/soul/message.py CHANGED
@@ -1,69 +1,56 @@
1
- from kosong.base.message import ContentPart, Message, TextPart
2
- from kosong.tooling import ToolError, ToolOk, ToolResult
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from kosong.message import Message
3
6
  from kosong.tooling.error import ToolRuntimeError
4
7
 
8
+ from kimi_cli.llm import ModelCapability
9
+ from kimi_cli.wire.types import (
10
+ ContentPart,
11
+ ImageURLPart,
12
+ TextPart,
13
+ ThinkPart,
14
+ ToolResult,
15
+ VideoURLPart,
16
+ )
17
+
5
18
 
6
19
  def system(message: str) -> ContentPart:
7
20
  return TextPart(text=f"<system>{message}</system>")
8
21
 
9
22
 
10
- def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
11
- """Convert a tool result to a list of messages."""
12
- if isinstance(tool_result.result, ToolError):
13
- assert tool_result.result.message, "ToolError should have a message"
14
- message = tool_result.result.message
15
- if isinstance(tool_result.result, ToolRuntimeError):
23
+ def tool_result_to_message(tool_result: ToolResult) -> Message:
24
+ """Convert a tool result to a message."""
25
+ if tool_result.return_value.is_error:
26
+ assert tool_result.return_value.message, "Error return value should have a message"
27
+ message = tool_result.return_value.message
28
+ if isinstance(tool_result.return_value, ToolRuntimeError):
16
29
  message += "\nThis is an unexpected error and the tool is probably not working."
17
30
  content: list[ContentPart] = [system(f"ERROR: {message}")]
18
- if tool_result.result.output:
19
- content.append(TextPart(text=tool_result.result.output))
20
- return [
21
- Message(
22
- role="tool",
23
- content=content,
24
- tool_call_id=tool_result.tool_call_id,
25
- )
26
- ]
27
-
28
- content = tool_ok_to_message_content(tool_result.result)
29
- text_parts: list[ContentPart] = []
30
- non_text_parts: list[ContentPart] = []
31
- for part in content:
32
- if isinstance(part, TextPart):
33
- text_parts.append(part)
34
- else:
35
- non_text_parts.append(part)
31
+ if tool_result.return_value.output:
32
+ content.extend(_output_to_content_parts(tool_result.return_value.output))
33
+ else:
34
+ content: list[ContentPart] = []
35
+ if tool_result.return_value.message:
36
+ content.append(system(tool_result.return_value.message))
37
+ if tool_result.return_value.output:
38
+ content.extend(_output_to_content_parts(tool_result.return_value.output))
39
+ if not content:
40
+ content.append(system("Tool output is empty."))
36
41
 
37
- if not non_text_parts:
38
- return [
39
- Message(
40
- role="tool",
41
- content=text_parts,
42
- tool_call_id=tool_result.tool_call_id,
43
- )
44
- ]
45
-
46
- text_parts.append(
47
- system(
48
- "Tool output contains non-text parts. Non-text parts are sent as a user message below."
49
- )
42
+ return Message(
43
+ role="tool",
44
+ content=content,
45
+ tool_call_id=tool_result.tool_call_id,
50
46
  )
51
- return [
52
- Message(
53
- role="tool",
54
- content=text_parts,
55
- tool_call_id=tool_result.tool_call_id,
56
- ),
57
- Message(role="user", content=non_text_parts),
58
- ]
59
47
 
60
48
 
61
- def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
62
- """Convert a tool return value to a list of message content parts."""
49
+ def _output_to_content_parts(
50
+ output: str | ContentPart | Sequence[ContentPart],
51
+ ) -> list[ContentPart]:
63
52
  content: list[ContentPart] = []
64
- if result.message:
65
- content.append(system(result.message))
66
- match output := result.output:
53
+ match output:
67
54
  case str(text):
68
55
  if text:
69
56
  content.append(TextPart(text=text))
@@ -71,6 +58,19 @@ def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
71
58
  content.append(output)
72
59
  case _:
73
60
  content.extend(output)
74
- if not content:
75
- content.append(system("Tool output is empty."))
76
61
  return content
62
+
63
+
64
+ def check_message(
65
+ message: Message, model_capabilities: set[ModelCapability]
66
+ ) -> set[ModelCapability]:
67
+ """Check the message content, return the missing model capabilities."""
68
+ capabilities_needed = set[ModelCapability]()
69
+ for part in message.content:
70
+ if isinstance(part, ImageURLPart):
71
+ capabilities_needed.add("image_in")
72
+ elif isinstance(part, VideoURLPart):
73
+ capabilities_needed.add("video_in")
74
+ elif isinstance(part, ThinkPart):
75
+ capabilities_needed.add("thinking")
76
+ return capabilities_needed - model_capabilities
kimi_cli/soul/slash.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ from collections.abc import Awaitable, Callable
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from kosong.message import Message
9
+ from loguru import logger
10
+
11
+ import kimi_cli.prompts as prompts
12
+ from kimi_cli.soul import wire_send
13
+ from kimi_cli.soul.agent import load_agents_md
14
+ from kimi_cli.soul.context import Context
15
+ from kimi_cli.soul.message import system
16
+ from kimi_cli.utils.slashcmd import SlashCommandRegistry
17
+ from kimi_cli.wire.types import TextPart
18
+
19
+ if TYPE_CHECKING:
20
+ from kimi_cli.soul.kimisoul import KimiSoul
21
+
22
+ type SoulSlashCmdFunc = Callable[[KimiSoul, str], None | Awaitable[None]]
23
+ """
24
+ A function that runs as a KimiSoul-level slash command.
25
+
26
+ Raises:
27
+ Any exception that can be raised by `Soul.run`.
28
+ """
29
+
30
+ registry = SlashCommandRegistry[SoulSlashCmdFunc]()
31
+
32
+
33
+ @registry.command
34
+ async def init(soul: KimiSoul, args: str):
35
+ """Analyze the codebase and generate an `AGENTS.md` file"""
36
+ from kimi_cli.soul.kimisoul import KimiSoul
37
+
38
+ with tempfile.TemporaryDirectory() as temp_dir:
39
+ tmp_context = Context(file_backend=Path(temp_dir) / "context.jsonl")
40
+ tmp_soul = KimiSoul(soul.agent, context=tmp_context)
41
+ await tmp_soul.run(prompts.INIT)
42
+
43
+ agents_md = load_agents_md(soul.runtime.builtin_args.KIMI_WORK_DIR)
44
+ system_message = system(
45
+ "The user just ran `/init` slash command. "
46
+ "The system has analyzed the codebase and generated an `AGENTS.md` file. "
47
+ f"Latest AGENTS.md file content:\n{agents_md}"
48
+ )
49
+ await soul.context.append_message(Message(role="user", content=[system_message]))
50
+
51
+
52
+ @registry.command
53
+ async def compact(soul: KimiSoul, args: str):
54
+ """Compact the context"""
55
+ if soul.context.n_checkpoints == 0:
56
+ wire_send(TextPart(text="The context is empty."))
57
+ return
58
+
59
+ logger.info("Running `/compact`")
60
+ await soul.compact_context()
61
+ wire_send(TextPart(text="The context has been compacted."))
62
+
63
+
64
+ @registry.command
65
+ async def yolo(soul: KimiSoul, args: str):
66
+ """Toggle YOLO mode (auto-approve all actions)"""
67
+ if soul.runtime.approval.is_yolo():
68
+ soul.runtime.approval.set_yolo(False)
69
+ wire_send(TextPart(text="You only die once! Actions will require approval."))
70
+ else:
71
+ soul.runtime.approval.set_yolo(True)
72
+ wire_send(TextPart(text="You only live once! All actions will be auto-approved."))
kimi_cli/soul/toolset.py CHANGED
@@ -1,8 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import importlib
6
+ import inspect
7
+ import json
1
8
  from contextvars import ContextVar
2
- from typing import override
9
+ from dataclasses import dataclass
10
+ from datetime import timedelta
11
+ from typing import TYPE_CHECKING, Any, Literal, overload
12
+
13
+ from kosong.tooling import (
14
+ CallableTool,
15
+ CallableTool2,
16
+ HandleResult,
17
+ Tool,
18
+ ToolError,
19
+ ToolOk,
20
+ Toolset,
21
+ )
22
+ from kosong.tooling.error import (
23
+ ToolNotFoundError,
24
+ ToolParseError,
25
+ ToolRuntimeError,
26
+ )
27
+ from kosong.tooling.mcp import convert_mcp_content
28
+ from kosong.utils.typing import JsonType
29
+ from loguru import logger
30
+
31
+ from kimi_cli.exception import InvalidToolError, MCPRuntimeError
32
+ from kimi_cli.tools import SkipThisTool
33
+ from kimi_cli.tools.utils import ToolRejectedError
34
+ from kimi_cli.wire.types import ContentPart, ToolCall, ToolResult, ToolReturnValue
35
+
36
+ if TYPE_CHECKING:
37
+ import fastmcp
38
+ import mcp
39
+ from fastmcp.client.client import CallToolResult
40
+ from fastmcp.client.transports import ClientTransport
41
+ from fastmcp.mcp_config import MCPConfig
3
42
 
4
- from kosong.base.message import ToolCall
5
- from kosong.tooling import HandleResult, SimpleToolset
43
+ from kimi_cli.soul.agent import Runtime
6
44
 
7
45
  current_tool_call = ContextVar[ToolCall | None]("current_tool_call", default=None)
8
46
 
@@ -15,11 +53,354 @@ def get_current_tool_call_or_none() -> ToolCall | None:
15
53
  return current_tool_call.get()
16
54
 
17
55
 
18
- class CustomToolset(SimpleToolset):
19
- @override
56
+ type ToolType = CallableTool | CallableTool2[Any]
57
+
58
+
59
+ if TYPE_CHECKING:
60
+
61
+ def type_check(kimi_toolset: KimiToolset):
62
+ _: Toolset = kimi_toolset
63
+
64
+
65
+ class KimiToolset:
66
+ def __init__(self) -> None:
67
+ self._tool_dict: dict[str, ToolType] = {}
68
+ self._mcp_servers: dict[str, MCPServerInfo] = {}
69
+ self._mcp_loading_task: asyncio.Task[None] | None = None
70
+
71
+ def add(self, tool: ToolType) -> None:
72
+ self._tool_dict[tool.name] = tool
73
+
74
+ @overload
75
+ def find(self, tool_name_or_type: str) -> ToolType | None: ...
76
+ @overload
77
+ def find[T: ToolType](self, tool_name_or_type: type[T]) -> T | None: ...
78
+ def find(self, tool_name_or_type: str | type[ToolType]) -> ToolType | None:
79
+ if isinstance(tool_name_or_type, str):
80
+ return self._tool_dict.get(tool_name_or_type)
81
+ else:
82
+ for tool in self._tool_dict.values():
83
+ if isinstance(tool, tool_name_or_type):
84
+ return tool
85
+ return None
86
+
87
+ @property
88
+ def tools(self) -> list[Tool]:
89
+ return [tool.base for tool in self._tool_dict.values()]
90
+
20
91
  def handle(self, tool_call: ToolCall) -> HandleResult:
21
92
  token = current_tool_call.set(tool_call)
22
93
  try:
23
- return super().handle(tool_call)
94
+ if tool_call.function.name not in self._tool_dict:
95
+ return ToolResult(
96
+ tool_call_id=tool_call.id,
97
+ return_value=ToolNotFoundError(tool_call.function.name),
98
+ )
99
+
100
+ tool = self._tool_dict[tool_call.function.name]
101
+
102
+ try:
103
+ arguments: JsonType = json.loads(tool_call.function.arguments or "{}")
104
+ except json.JSONDecodeError as e:
105
+ return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e)))
106
+
107
+ async def _call():
108
+ try:
109
+ ret = await tool.call(arguments)
110
+ return ToolResult(tool_call_id=tool_call.id, return_value=ret)
111
+ except Exception as e:
112
+ return ToolResult(
113
+ tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e))
114
+ )
115
+
116
+ return asyncio.create_task(_call())
24
117
  finally:
25
118
  current_tool_call.reset(token)
119
+
120
+ @property
121
+ def mcp_servers(self) -> dict[str, MCPServerInfo]:
122
+ """Get MCP servers info."""
123
+ return self._mcp_servers
124
+
125
+ def load_tools(self, tool_paths: list[str], dependencies: dict[type[Any], Any]) -> None:
126
+ """
127
+ Load tools from paths like `kimi_cli.tools.shell:Shell`.
128
+
129
+ Raises:
130
+ InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded.
131
+ """
132
+
133
+ good_tools: list[str] = []
134
+ bad_tools: list[str] = []
135
+
136
+ for tool_path in tool_paths:
137
+ try:
138
+ tool = self._load_tool(tool_path, dependencies)
139
+ except SkipThisTool:
140
+ logger.info("Skipping tool: {tool_path}", tool_path=tool_path)
141
+ continue
142
+ if tool:
143
+ self.add(tool)
144
+ good_tools.append(tool_path)
145
+ else:
146
+ bad_tools.append(tool_path)
147
+ logger.info("Loaded tools: {good_tools}", good_tools=good_tools)
148
+ if bad_tools:
149
+ raise InvalidToolError(f"Invalid tools: {bad_tools}")
150
+
151
+ @staticmethod
152
+ def _load_tool(tool_path: str, dependencies: dict[type[Any], Any]) -> ToolType | None:
153
+ logger.debug("Loading tool: {tool_path}", tool_path=tool_path)
154
+ module_name, class_name = tool_path.rsplit(":", 1)
155
+ try:
156
+ module = importlib.import_module(module_name)
157
+ except ImportError:
158
+ return None
159
+ tool_cls = getattr(module, class_name, None)
160
+ if tool_cls is None:
161
+ return None
162
+ args: list[Any] = []
163
+ if "__init__" in tool_cls.__dict__:
164
+ # the tool class overrides the `__init__` of base class
165
+ for param in inspect.signature(tool_cls).parameters.values():
166
+ if param.kind == inspect.Parameter.KEYWORD_ONLY:
167
+ # once we encounter a keyword-only parameter, we stop injecting dependencies
168
+ break
169
+ # all positional parameters should be dependencies to be injected
170
+ if param.annotation not in dependencies:
171
+ raise ValueError(f"Tool dependency not found: {param.annotation}")
172
+ args.append(dependencies[param.annotation])
173
+ return tool_cls(*args)
174
+
175
+ # TODO(rc): remove `in_background` parameter and always load in background
176
+ async def load_mcp_tools(
177
+ self, mcp_configs: list[MCPConfig], runtime: Runtime, in_background: bool = True
178
+ ) -> None:
179
+ """
180
+ Load MCP tools from specified MCP configs.
181
+
182
+ Raises:
183
+ MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be
184
+ connected.
185
+ """
186
+ import fastmcp
187
+ from fastmcp.mcp_config import MCPConfig, RemoteMCPServer
188
+
189
+ from kimi_cli.ui.shell.prompt import toast
190
+
191
+ async def _check_oauth_tokens(server_url: str) -> bool:
192
+ """Check if OAuth tokens exist for the server."""
193
+ try:
194
+ from fastmcp.client.auth.oauth import FileTokenStorage
195
+
196
+ storage = FileTokenStorage(server_url=server_url)
197
+ tokens = await storage.get_tokens()
198
+ return tokens is not None
199
+ except Exception:
200
+ return False
201
+
202
+ def _toast_mcp(message: str) -> None:
203
+ if in_background:
204
+ toast(
205
+ message,
206
+ duration=10.0,
207
+ topic="mcp",
208
+ immediate=True,
209
+ position="right",
210
+ )
211
+
212
+ oauth_servers: dict[str, str] = {}
213
+
214
+ async def _connect_server(
215
+ server_name: str, server_info: MCPServerInfo
216
+ ) -> tuple[str, Exception | None]:
217
+ if server_info.status != "pending":
218
+ return server_name, None
219
+
220
+ server_info.status = "connecting"
221
+ try:
222
+ async with server_info.client as client:
223
+ for tool in await client.list_tools():
224
+ server_info.tools.append(
225
+ MCPTool(server_name, tool, client, runtime=runtime)
226
+ )
227
+
228
+ for tool in server_info.tools:
229
+ self.add(tool)
230
+
231
+ server_info.status = "connected"
232
+ logger.info("Connected MCP server: {server_name}", server_name=server_name)
233
+ return server_name, None
234
+ except Exception as e:
235
+ logger.error(
236
+ "Failed to connect MCP server: {server_name}, error: {error}",
237
+ server_name=server_name,
238
+ error=e,
239
+ )
240
+ server_info.status = "failed"
241
+ return server_name, e
242
+
243
+ async def _connect():
244
+ _toast_mcp("connecting to mcp servers...")
245
+ unauthorized_servers: dict[str, str] = {}
246
+ for server_name, server_info in self._mcp_servers.items():
247
+ server_url = oauth_servers.get(server_name)
248
+ if not server_url:
249
+ continue
250
+ if not await _check_oauth_tokens(server_url):
251
+ logger.warning(
252
+ "Skipping OAuth MCP server '{server_name}': not authorized. "
253
+ "Run 'kimi mcp auth {server_name}' first.",
254
+ server_name=server_name,
255
+ )
256
+ server_info.status = "unauthorized"
257
+ unauthorized_servers[server_name] = server_url
258
+
259
+ tasks = [
260
+ asyncio.create_task(_connect_server(server_name, server_info))
261
+ for server_name, server_info in self._mcp_servers.items()
262
+ if server_info.status == "pending"
263
+ ]
264
+ results = await asyncio.gather(*tasks) if tasks else []
265
+ failed_servers = {name: error for name, error in results if error is not None}
266
+
267
+ for mcp_config in mcp_configs:
268
+ # Skip empty MCP configs (no servers defined)
269
+ if not mcp_config.mcpServers:
270
+ logger.debug("Skipping empty MCP config: {mcp_config}", mcp_config=mcp_config)
271
+ continue
272
+
273
+ if failed_servers:
274
+ _toast_mcp("mcp connection failed")
275
+ raise MCPRuntimeError(f"Failed to connect MCP servers: {failed_servers}")
276
+ if unauthorized_servers:
277
+ _toast_mcp("mcp authorization needed")
278
+ else:
279
+ _toast_mcp("mcp servers connected")
280
+
281
+ for mcp_config in mcp_configs:
282
+ if not mcp_config.mcpServers:
283
+ logger.debug("Skipping empty MCP config: {mcp_config}", mcp_config=mcp_config)
284
+ continue
285
+
286
+ for server_name, server_config in mcp_config.mcpServers.items():
287
+ if isinstance(server_config, RemoteMCPServer) and server_config.auth == "oauth":
288
+ oauth_servers[server_name] = server_config.url
289
+
290
+ # Add mcp-session-id header for HTTP transports (skip OAuth servers)
291
+ if (
292
+ isinstance(server_config, RemoteMCPServer)
293
+ and server_config.auth != "oauth"
294
+ and not any(key.lower() == "mcp-session-id" for key in server_config.headers)
295
+ ):
296
+ server_config = server_config.model_copy(deep=True)
297
+ server_config.headers["Mcp-Session-Id"] = runtime.session.id
298
+
299
+ client = fastmcp.Client(MCPConfig(mcpServers={server_name: server_config}))
300
+ self._mcp_servers[server_name] = MCPServerInfo(
301
+ status="pending", client=client, tools=[]
302
+ )
303
+
304
+ if in_background:
305
+ self._mcp_loading_task = asyncio.create_task(_connect())
306
+ else:
307
+ await _connect()
308
+
309
+ async def wait_for_mcp_tools(self) -> None:
310
+ """Wait for background MCP tool loading to finish."""
311
+ task = self._mcp_loading_task
312
+ if not task:
313
+ return
314
+ try:
315
+ await task
316
+ finally:
317
+ if self._mcp_loading_task is task and task.done():
318
+ self._mcp_loading_task = None
319
+
320
+ async def cleanup(self) -> None:
321
+ """Cleanup any resources held by the toolset."""
322
+ if self._mcp_loading_task:
323
+ self._mcp_loading_task.cancel()
324
+ with contextlib.suppress(Exception):
325
+ await self._mcp_loading_task
326
+ for server_info in self._mcp_servers.values():
327
+ await server_info.client.close()
328
+
329
+
330
+ @dataclass(slots=True)
331
+ class MCPServerInfo:
332
+ status: Literal["pending", "connecting", "connected", "failed", "unauthorized"]
333
+ client: fastmcp.Client[Any]
334
+ tools: list[MCPTool[Any]]
335
+
336
+
337
+ class MCPTool[T: ClientTransport](CallableTool):
338
+ def __init__(
339
+ self,
340
+ server_name: str,
341
+ mcp_tool: mcp.Tool,
342
+ client: fastmcp.Client[T],
343
+ *,
344
+ runtime: Runtime,
345
+ **kwargs: Any,
346
+ ):
347
+ super().__init__(
348
+ name=mcp_tool.name,
349
+ description=(
350
+ f"This is an MCP (Model Context Protocol) tool from MCP server `{server_name}`.\n\n"
351
+ f"{mcp_tool.description or 'No description provided.'}"
352
+ ),
353
+ parameters=mcp_tool.inputSchema,
354
+ **kwargs,
355
+ )
356
+ self._mcp_tool = mcp_tool
357
+ self._client = client
358
+ self._runtime = runtime
359
+ self._timeout = timedelta(milliseconds=runtime.config.mcp.client.tool_call_timeout_ms)
360
+ self._action_name = f"mcp:{mcp_tool.name}"
361
+
362
+ async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue:
363
+ description = f"Call MCP tool `{self._mcp_tool.name}`."
364
+ if not await self._runtime.approval.request(self.name, self._action_name, description):
365
+ return ToolRejectedError()
366
+
367
+ try:
368
+ async with self._client as client:
369
+ result = await client.call_tool(
370
+ self._mcp_tool.name,
371
+ kwargs,
372
+ timeout=self._timeout,
373
+ raise_on_error=False,
374
+ )
375
+ return convert_mcp_tool_result(result)
376
+ except Exception as e:
377
+ # fastmcp raises `RuntimeError` on timeout and we cannot tell it from other errors
378
+ exc_msg = str(e).lower()
379
+ if "timeout" in exc_msg or "timed out" in exc_msg:
380
+ return ToolError(
381
+ message=(
382
+ f"Timeout while calling MCP tool `{self._mcp_tool.name}`. "
383
+ "You may explain to the user that the timeout config is set too low."
384
+ ),
385
+ brief="Timeout",
386
+ )
387
+ raise
388
+
389
+
390
+ def convert_mcp_tool_result(result: CallToolResult) -> ToolReturnValue:
391
+ """Convert MCP tool result to kosong tool return value.
392
+
393
+ Raises:
394
+ ValueError: If any content part has unsupported type or mime type.
395
+ """
396
+ content: list[ContentPart] = []
397
+ for part in result.content:
398
+ content.append(convert_mcp_content(part))
399
+ if result.is_error:
400
+ return ToolError(
401
+ output=content,
402
+ message="Tool returned an error. The output may be error message or incomplete output",
403
+ brief="",
404
+ )
405
+ else:
406
+ return ToolOk(output=content)
kimi_cli/toad.py ADDED
@@ -0,0 +1,74 @@
1
+ import importlib.util
2
+ import shlex
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+
11
+ def _default_acp_command() -> list[str]:
12
+ argv0 = sys.argv[0]
13
+ if argv0:
14
+ resolved = shutil.which(argv0)
15
+ resolved_path = Path(resolved).expanduser() if resolved else Path(argv0).expanduser()
16
+ if (
17
+ resolved_path.exists()
18
+ and resolved_path.suffix != ".py"
19
+ and not resolved_path.name.startswith(("python", "pypy"))
20
+ ):
21
+ return [str(resolved_path), "acp"]
22
+
23
+ return [sys.executable, "-m", "kimi_cli.cli", "acp"]
24
+
25
+
26
+ def _default_toad_command() -> list[str]:
27
+ if sys.version_info < (3, 14):
28
+ typer.echo("`kimi term` requires Python 3.14+ because Toad requires it.", err=True)
29
+ raise typer.Exit(code=1)
30
+ if importlib.util.find_spec("toad") is None:
31
+ typer.echo(
32
+ "Toad dependency is missing. Run `uv sync --python 3.14` or install kimi-cli with "
33
+ "Python 3.14.",
34
+ err=True,
35
+ )
36
+ raise typer.Exit(code=1)
37
+ return [sys.executable, "-m", "toad.cli"]
38
+
39
+
40
+ def _extract_project_dir(extra_args: list[str]) -> Path | None:
41
+ work_dir: str | None = None
42
+ idx = 0
43
+ while idx < len(extra_args):
44
+ arg = extra_args[idx]
45
+ if arg in ("--work-dir", "-w"):
46
+ if idx + 1 < len(extra_args):
47
+ work_dir = extra_args[idx + 1]
48
+ idx += 2
49
+ continue
50
+ elif arg.startswith("--work-dir=") or arg.startswith("-w="):
51
+ work_dir = arg.split("=", 1)[1]
52
+ elif arg.startswith("-w") and len(arg) > 2:
53
+ work_dir = arg[2:]
54
+ idx += 1
55
+
56
+ if not work_dir:
57
+ return None
58
+
59
+ return Path(work_dir).expanduser().resolve()
60
+
61
+
62
+ def run_term(ctx: typer.Context) -> None:
63
+ extra_args = list(ctx.args)
64
+ acp_args = _default_acp_command()
65
+ acp_command = shlex.join(acp_args)
66
+ toad_parts = _default_toad_command()
67
+ args = [*toad_parts, "acp", acp_command]
68
+ project_dir = _extract_project_dir(extra_args)
69
+ if project_dir is not None:
70
+ args.append(str(project_dir))
71
+
72
+ result = subprocess.run(args)
73
+ if result.returncode != 0:
74
+ raise typer.Exit(code=result.returncode)
@@ -0,0 +1,5 @@
1
+ # Kimi CLI Tools
2
+
3
+ ## Guidelines
4
+
5
+ - Except for `Task` tool, tools should not refer to any types in `kimi_cli/wire/`. When importing things like `ToolReturnValue`, `DisplayBlock`, import from `kosong.tooling`.