kimi-cli 0.35__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 (76) hide show
  1. kimi_cli/CHANGELOG.md +304 -0
  2. kimi_cli/__init__.py +374 -0
  3. kimi_cli/agent.py +261 -0
  4. kimi_cli/agents/koder/README.md +3 -0
  5. kimi_cli/agents/koder/agent.yaml +24 -0
  6. kimi_cli/agents/koder/sub.yaml +11 -0
  7. kimi_cli/agents/koder/system.md +72 -0
  8. kimi_cli/config.py +138 -0
  9. kimi_cli/llm.py +8 -0
  10. kimi_cli/metadata.py +117 -0
  11. kimi_cli/prompts/metacmds/__init__.py +4 -0
  12. kimi_cli/prompts/metacmds/compact.md +74 -0
  13. kimi_cli/prompts/metacmds/init.md +21 -0
  14. kimi_cli/py.typed +0 -0
  15. kimi_cli/share.py +8 -0
  16. kimi_cli/soul/__init__.py +59 -0
  17. kimi_cli/soul/approval.py +69 -0
  18. kimi_cli/soul/context.py +142 -0
  19. kimi_cli/soul/denwarenji.py +37 -0
  20. kimi_cli/soul/kimisoul.py +248 -0
  21. kimi_cli/soul/message.py +76 -0
  22. kimi_cli/soul/toolset.py +25 -0
  23. kimi_cli/soul/wire.py +101 -0
  24. kimi_cli/tools/__init__.py +85 -0
  25. kimi_cli/tools/bash/__init__.py +97 -0
  26. kimi_cli/tools/bash/bash.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +38 -0
  28. kimi_cli/tools/dmail/dmail.md +15 -0
  29. kimi_cli/tools/file/__init__.py +21 -0
  30. kimi_cli/tools/file/glob.md +17 -0
  31. kimi_cli/tools/file/glob.py +149 -0
  32. kimi_cli/tools/file/grep.md +5 -0
  33. kimi_cli/tools/file/grep.py +285 -0
  34. kimi_cli/tools/file/patch.md +8 -0
  35. kimi_cli/tools/file/patch.py +131 -0
  36. kimi_cli/tools/file/read.md +14 -0
  37. kimi_cli/tools/file/read.py +139 -0
  38. kimi_cli/tools/file/replace.md +7 -0
  39. kimi_cli/tools/file/replace.py +132 -0
  40. kimi_cli/tools/file/write.md +5 -0
  41. kimi_cli/tools/file/write.py +107 -0
  42. kimi_cli/tools/mcp.py +85 -0
  43. kimi_cli/tools/task/__init__.py +156 -0
  44. kimi_cli/tools/task/task.md +26 -0
  45. kimi_cli/tools/test.py +55 -0
  46. kimi_cli/tools/think/__init__.py +21 -0
  47. kimi_cli/tools/think/think.md +1 -0
  48. kimi_cli/tools/todo/__init__.py +27 -0
  49. kimi_cli/tools/todo/set_todo_list.md +15 -0
  50. kimi_cli/tools/utils.py +150 -0
  51. kimi_cli/tools/web/__init__.py +4 -0
  52. kimi_cli/tools/web/fetch.md +1 -0
  53. kimi_cli/tools/web/fetch.py +94 -0
  54. kimi_cli/tools/web/search.md +1 -0
  55. kimi_cli/tools/web/search.py +126 -0
  56. kimi_cli/ui/__init__.py +68 -0
  57. kimi_cli/ui/acp/__init__.py +441 -0
  58. kimi_cli/ui/print/__init__.py +176 -0
  59. kimi_cli/ui/shell/__init__.py +326 -0
  60. kimi_cli/ui/shell/console.py +3 -0
  61. kimi_cli/ui/shell/liveview.py +158 -0
  62. kimi_cli/ui/shell/metacmd.py +309 -0
  63. kimi_cli/ui/shell/prompt.py +574 -0
  64. kimi_cli/ui/shell/setup.py +192 -0
  65. kimi_cli/ui/shell/update.py +204 -0
  66. kimi_cli/utils/changelog.py +101 -0
  67. kimi_cli/utils/logging.py +18 -0
  68. kimi_cli/utils/message.py +8 -0
  69. kimi_cli/utils/path.py +23 -0
  70. kimi_cli/utils/provider.py +64 -0
  71. kimi_cli/utils/pyinstaller.py +24 -0
  72. kimi_cli/utils/string.py +12 -0
  73. kimi_cli-0.35.dist-info/METADATA +24 -0
  74. kimi_cli-0.35.dist-info/RECORD +76 -0
  75. kimi_cli-0.35.dist-info/WHEEL +4 -0
  76. kimi_cli-0.35.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,326 @@
1
+ import asyncio
2
+ import signal
3
+ from collections.abc import Awaitable, Coroutine
4
+ from typing import Any
5
+
6
+ from kosong.base.message import ContentPart, TextPart, ToolCall, ToolCallPart
7
+ from kosong.chat_provider import APIStatusError, ChatProviderError
8
+ from kosong.tooling import ToolResult
9
+ from rich.console import Group, RenderableType
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul
15
+ from kimi_cli.soul.kimisoul import KimiSoul
16
+ from kimi_cli.soul.wire import (
17
+ ApprovalRequest,
18
+ ApprovalResponse,
19
+ StatusUpdate,
20
+ StepBegin,
21
+ StepInterrupted,
22
+ Wire,
23
+ )
24
+ from kimi_cli.ui import RunCancelled, run_soul
25
+ from kimi_cli.ui.shell.console import console
26
+ from kimi_cli.ui.shell.liveview import StepLiveView
27
+ from kimi_cli.ui.shell.metacmd import get_meta_command
28
+ from kimi_cli.ui.shell.prompt import CustomPromptSession, PromptMode, toast
29
+ from kimi_cli.ui.shell.update import UpdateResult, do_update
30
+ from kimi_cli.utils.logging import logger
31
+
32
+
33
+ class Reload(Exception):
34
+ """Reload configuration."""
35
+
36
+ pass
37
+
38
+
39
+ class ShellApp:
40
+ def __init__(self, soul: Soul, welcome_info: dict[str, str] | None = None):
41
+ self.soul = soul
42
+ self.welcome_info = welcome_info or {}
43
+ self._background_tasks: set[asyncio.Task[Any]] = set()
44
+
45
+ async def run(self, command: str | None = None) -> bool:
46
+ if command is not None:
47
+ # run single command and exit
48
+ logger.info("Running agent with command: {command}", command=command)
49
+ return await self._run(command)
50
+
51
+ self._start_auto_update_task()
52
+
53
+ _print_welcome_info(self.soul.name or "Kimi CLI", self.soul.model, self.welcome_info)
54
+
55
+ with CustomPromptSession(lambda: self.soul.status) as prompt_session:
56
+ while True:
57
+ try:
58
+ user_input = await prompt_session.prompt()
59
+ except KeyboardInterrupt:
60
+ logger.debug("Exiting by KeyboardInterrupt")
61
+ console.print("[grey50]Tip: press Ctrl-D or send 'exit' to quit[/grey50]")
62
+ continue
63
+ except EOFError:
64
+ logger.debug("Exiting by EOF")
65
+ console.print("Bye!")
66
+ break
67
+
68
+ if not user_input:
69
+ logger.debug("Got empty input, skipping")
70
+ continue
71
+ logger.debug("Got user input: {user_input}", user_input=user_input)
72
+
73
+ if user_input.command in ["exit", "quit", "/exit", "/quit"]:
74
+ logger.debug("Exiting by meta command")
75
+ console.print("Bye!")
76
+ break
77
+
78
+ if user_input.mode == PromptMode.SHELL:
79
+ await self._run_shell_command(user_input.command)
80
+ continue
81
+
82
+ command = user_input.command
83
+ if command.startswith("/"):
84
+ logger.debug("Running meta command: {command}", command=command)
85
+ await self._run_meta_command(command[1:])
86
+ continue
87
+
88
+ logger.info("Running agent command: {command}", command=command)
89
+ await self._run(command)
90
+
91
+ return True
92
+
93
+ async def _run_shell_command(self, command: str) -> None:
94
+ """Run a shell command in foreground."""
95
+ if not command.strip():
96
+ return
97
+
98
+ logger.info("Running shell command: {cmd}", cmd=command)
99
+ loop = asyncio.get_running_loop()
100
+ try:
101
+ # TODO: For the sake of simplicity, we now use `create_subprocess_shell`.
102
+ # Later we should consider making this behave like a real shell.
103
+ proc = await asyncio.create_subprocess_shell(command)
104
+
105
+ def _handler():
106
+ logger.debug("SIGINT received.")
107
+ proc.terminate()
108
+
109
+ loop.add_signal_handler(signal.SIGINT, _handler)
110
+
111
+ await proc.wait()
112
+ except Exception as e:
113
+ logger.exception("Failed to run shell command:")
114
+ console.print(f"[red]Failed to run shell command: {e}[/red]")
115
+ finally:
116
+ loop.remove_signal_handler(signal.SIGINT)
117
+
118
+ async def _run(self, command: str) -> bool:
119
+ """
120
+ Run the soul and handle any known exceptions.
121
+
122
+ Returns:
123
+ bool: Whether the run is successful.
124
+ """
125
+ cancel_event = asyncio.Event()
126
+
127
+ def _handler():
128
+ logger.debug("SIGINT received.")
129
+ cancel_event.set()
130
+
131
+ loop = asyncio.get_running_loop()
132
+ loop.add_signal_handler(signal.SIGINT, _handler)
133
+
134
+ try:
135
+ await run_soul(self.soul, command, self._visualize, cancel_event)
136
+ return True
137
+ except LLMNotSet:
138
+ logger.error("LLM not set")
139
+ console.print("[red]LLM not set, send /setup to configure[/red]")
140
+ except ChatProviderError as e:
141
+ logger.exception("LLM provider error:")
142
+ if isinstance(e, APIStatusError) and e.status_code == 401:
143
+ console.print("[red]Authorization failed, please check your API key[/red]")
144
+ elif isinstance(e, APIStatusError) and e.status_code == 402:
145
+ console.print("[red]Membership expired, please renew your plan[/red]")
146
+ elif isinstance(e, APIStatusError) and e.status_code == 403:
147
+ console.print("[red]Quota exceeded, please upgrade your plan or retry later[/red]")
148
+ else:
149
+ console.print(f"[red]LLM provider error: {e}[/red]")
150
+ except MaxStepsReached as e:
151
+ logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
152
+ console.print(f"[yellow]Max steps reached: {e.n_steps}[/yellow]")
153
+ except RunCancelled:
154
+ logger.info("Cancelled by user")
155
+ console.print("[red]Interrupted by user[/red]")
156
+ except Reload:
157
+ # just propagate
158
+ raise
159
+ except BaseException as e:
160
+ logger.exception("Unknown error:")
161
+ console.print(f"[red]Unknown error: {e}[/red]")
162
+ raise # re-raise unknown error
163
+ finally:
164
+ loop.remove_signal_handler(signal.SIGINT)
165
+ return False
166
+
167
+ def _start_auto_update_task(self) -> None:
168
+ self._add_background_task(self._auto_update_background())
169
+
170
+ async def _auto_update_background(self) -> None:
171
+ toast("checking for updates...", duration=2.0)
172
+ result = await do_update(print=False, check_only=True)
173
+ if result == UpdateResult.UPDATE_AVAILABLE:
174
+ while True:
175
+ toast("new version found, run `uv tool upgrade ikimi` to upgrade", duration=30.0)
176
+ await asyncio.sleep(60.0)
177
+ elif result == UpdateResult.UPDATED:
178
+ toast("auto updated, restart to use the new version", duration=5.0)
179
+
180
+ def _add_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
181
+ task = asyncio.create_task(coro)
182
+ self._background_tasks.add(task)
183
+
184
+ def _cleanup(t: asyncio.Task[Any]) -> None:
185
+ self._background_tasks.discard(t)
186
+ try:
187
+ t.result()
188
+ except asyncio.CancelledError:
189
+ pass
190
+ except Exception:
191
+ logger.exception("Background task failed:")
192
+
193
+ task.add_done_callback(_cleanup)
194
+ return task
195
+
196
+ async def _visualize(self, wire: Wire):
197
+ """
198
+ A loop to consume agent events and visualize the agent behavior.
199
+ This loop never raise any exception except asyncio.CancelledError.
200
+ """
201
+ try:
202
+ # expect a StepBegin
203
+ assert isinstance(await wire.receive(), StepBegin)
204
+
205
+ while True:
206
+ # spin the moon at the beginning of each step
207
+ with console.status("", spinner="moon"):
208
+ msg = await wire.receive()
209
+
210
+ with StepLiveView(self.soul.status) as step:
211
+ # visualization loop for one step
212
+ while True:
213
+ match msg:
214
+ case TextPart(text=text):
215
+ step.append_text(text)
216
+ case ContentPart():
217
+ # TODO: support more content parts
218
+ step.append_text(f"[{msg.__class__.__name__}]")
219
+ case ToolCall():
220
+ step.append_tool_call(msg)
221
+ case ToolCallPart():
222
+ step.append_tool_call_part(msg)
223
+ case ToolResult():
224
+ step.append_tool_result(msg)
225
+ case ApprovalRequest():
226
+ msg.resolve(ApprovalResponse.APPROVE)
227
+ # TODO(approval): handle approval request
228
+ case StatusUpdate(status=status):
229
+ step.update_status(status)
230
+ case _:
231
+ break # break the step loop
232
+ msg = await wire.receive()
233
+
234
+ # cleanup the step live view
235
+ if isinstance(msg, StepInterrupted):
236
+ step.interrupt()
237
+ else:
238
+ step.finish()
239
+
240
+ if isinstance(msg, StepInterrupted):
241
+ # for StepInterrupted, the visualization loop should end immediately
242
+ break
243
+
244
+ assert isinstance(msg, StepBegin), "expect a StepBegin"
245
+ # start a new step
246
+ except asyncio.QueueShutDown:
247
+ logger.debug("Visualization loop shutting down")
248
+
249
+ async def _run_meta_command(self, command_str: str):
250
+ parts = command_str.split(" ")
251
+ command_name = parts[0]
252
+ command_args = parts[1:]
253
+ command = get_meta_command(command_name)
254
+ if command is None:
255
+ console.print(f"Meta command /{command_name} not found")
256
+ return
257
+ if command.kimi_soul_only and not isinstance(self.soul, KimiSoul):
258
+ console.print(f"Meta command /{command_name} not supported")
259
+ return
260
+ logger.debug(
261
+ "Running meta command: {command_name} with args: {command_args}",
262
+ command_name=command_name,
263
+ command_args=command_args,
264
+ )
265
+ try:
266
+ ret = command.func(self, command_args)
267
+ if isinstance(ret, Awaitable):
268
+ await ret
269
+ except LLMNotSet:
270
+ logger.error("LLM not set")
271
+ console.print("[red]LLM not set, send /setup to configure[/red]")
272
+ except ChatProviderError as e:
273
+ logger.exception("LLM provider error:")
274
+ console.print(f"[red]LLM provider error: {e}[/red]")
275
+ except Reload:
276
+ # just propagate
277
+ raise
278
+ except BaseException as e:
279
+ logger.exception("Unknown error:")
280
+ console.print(f"[red]Unknown error: {e}[/red]")
281
+ raise # re-raise unknown error
282
+
283
+
284
+ _KIMI_BLUE = "dodger_blue1"
285
+ _LOGO = f"""\
286
+ [{_KIMI_BLUE}]\
287
+ ▐█▛█▛█▌
288
+ ▐█████▌\
289
+ [{_KIMI_BLUE}]\
290
+ """
291
+
292
+
293
+ def _print_welcome_info(name: str, model: str, info_items: dict[str, str]) -> None:
294
+ head = Text.from_markup(f"[bold]Welcome to {name}![/bold]")
295
+ help_text = Text.from_markup("[grey50]Send /help for help information.[/grey50]")
296
+
297
+ # Use Table for precise width control
298
+ logo = Text.from_markup(_LOGO)
299
+ table = Table(show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False)
300
+ table.add_column(justify="left")
301
+ table.add_column(justify="left")
302
+ table.add_row(logo, Group(head, help_text))
303
+
304
+ rows: list[RenderableType] = [table]
305
+
306
+ rows.append(Text("")) # Empty line
307
+ rows.extend(
308
+ Text.from_markup(f"[grey50]{key}: {value}[/grey50]") for key, value in info_items.items()
309
+ )
310
+ if model:
311
+ rows.append(Text.from_markup(f"[grey50]Model: {model}[/grey50]"))
312
+ else:
313
+ rows.append(
314
+ Text.from_markup(
315
+ "[grey50]Model:[/grey50] [yellow]not set, send /setup to configure[/yellow]"
316
+ )
317
+ )
318
+
319
+ console.print(
320
+ Panel(
321
+ Group(*rows),
322
+ border_style=_KIMI_BLUE,
323
+ expand=False,
324
+ padding=(1, 2),
325
+ )
326
+ )
@@ -0,0 +1,3 @@
1
+ from rich.console import Console
2
+
3
+ console = Console(highlight=False)
@@ -0,0 +1,158 @@
1
+ import streamingjson
2
+ from kosong.base.message import ToolCall, ToolCallPart
3
+ from kosong.tooling import ToolError, ToolOk, ToolResult, ToolReturnType
4
+ from rich.console import Group, RenderableType
5
+ from rich.live import Live
6
+ from rich.markup import escape
7
+ from rich.spinner import Spinner
8
+ from rich.text import Text
9
+
10
+ from kimi_cli.soul import StatusSnapshot
11
+ from kimi_cli.tools import extract_subtitle
12
+ from kimi_cli.ui.shell.console import console
13
+
14
+
15
+ class _ToolCallDisplay:
16
+ def __init__(self, tool_call: ToolCall):
17
+ self._tool_name = tool_call.function.name
18
+ self._lexer = streamingjson.Lexer()
19
+ if tool_call.function.arguments is not None:
20
+ self._lexer.append_string(tool_call.function.arguments)
21
+
22
+ self._title_markup = f"Using [blue]{self._tool_name}[/blue]"
23
+ self._subtitle = extract_subtitle(self._lexer, self._tool_name)
24
+ self._finished = False
25
+ self._spinner = Spinner("dots", text=self._spinner_markup)
26
+ self.renderable: RenderableType = Group(self._spinner)
27
+
28
+ @property
29
+ def finished(self) -> bool:
30
+ return self._finished
31
+
32
+ @property
33
+ def _spinner_markup(self) -> str:
34
+ return self._title_markup + self._subtitle_markup
35
+
36
+ @property
37
+ def _subtitle_markup(self) -> str:
38
+ subtitle = self._subtitle
39
+ return f"[grey50]: {escape(subtitle)}[/grey50]" if subtitle else ""
40
+
41
+ def append_args_part(self, args_part: str):
42
+ if self.finished:
43
+ return
44
+ self._lexer.append_string(args_part)
45
+ # TODO: don't extract detail if it's already stable
46
+ new_subtitle = extract_subtitle(self._lexer, self._tool_name)
47
+ if new_subtitle and new_subtitle != self._subtitle:
48
+ self._subtitle = new_subtitle
49
+ self._spinner.update(text=self._spinner_markup)
50
+
51
+ def finish(self, result: ToolReturnType):
52
+ """
53
+ Finish the live display of a tool call.
54
+ After calling this, the `renderable` property should be re-rendered.
55
+ """
56
+ self._finished = True
57
+ sign = "[red]✗[/red]" if isinstance(result, ToolError) else "[green]✓[/green]"
58
+ lines = [
59
+ Text.from_markup(f"{sign} Used [blue]{self._tool_name}[/blue]" + self._subtitle_markup)
60
+ ]
61
+ if result.brief:
62
+ lines.append(
63
+ Text.from_markup(
64
+ f" {result.brief}", style="grey50" if isinstance(result, ToolOk) else "red"
65
+ )
66
+ )
67
+ self.renderable = Group(*lines)
68
+
69
+
70
+ class StepLiveView:
71
+ def __init__(self, status: StatusSnapshot):
72
+ self._line_buffer = Text("")
73
+ self._tool_calls: dict[str, _ToolCallDisplay] = {}
74
+ self._last_tool_call: _ToolCallDisplay | None = None
75
+ self._status_text: Text | None = Text(
76
+ self._format_status(status), style="grey50", justify="right"
77
+ )
78
+
79
+ def __enter__(self):
80
+ self._live = Live(
81
+ self._compose(),
82
+ console=console,
83
+ refresh_per_second=4,
84
+ transient=False, # leave the last frame on the screen
85
+ vertical_overflow="visible",
86
+ )
87
+ self._live.__enter__()
88
+ return self
89
+
90
+ def __exit__(self, exc_type, exc_value, traceback):
91
+ self._live.__exit__(exc_type, exc_value, traceback)
92
+
93
+ def _compose(self) -> RenderableType:
94
+ sections = []
95
+ if self._line_buffer:
96
+ sections.append(self._line_buffer)
97
+ for view in self._tool_calls.values():
98
+ sections.append(view.renderable)
99
+ if self._status_text:
100
+ sections.append(self._status_text)
101
+ return Group(*sections)
102
+
103
+ def _push_out(self, text: Text | str):
104
+ """
105
+ Push the text out of the live view to the console.
106
+ After this, the printed line will not be changed further.
107
+ """
108
+ console.print(text)
109
+
110
+ def append_text(self, text: str):
111
+ lines = text.split("\n")
112
+ prev_is_empty = not self._line_buffer
113
+ for line in lines[:-1]:
114
+ self._push_out(self._line_buffer + line)
115
+ self._line_buffer.plain = ""
116
+ self._line_buffer.append(lines[-1])
117
+ if (prev_is_empty and self._line_buffer) or (not prev_is_empty and not self._line_buffer):
118
+ self._live.update(self._compose())
119
+
120
+ def append_tool_call(self, tool_call: ToolCall):
121
+ self._tool_calls[tool_call.id] = _ToolCallDisplay(tool_call)
122
+ self._last_tool_call = self._tool_calls[tool_call.id]
123
+ self._live.update(self._compose())
124
+
125
+ def append_tool_call_part(self, tool_call_part: ToolCallPart):
126
+ if not tool_call_part.arguments_part:
127
+ return
128
+ if self._last_tool_call is None:
129
+ return
130
+ self._last_tool_call.append_args_part(tool_call_part.arguments_part)
131
+
132
+ def append_tool_result(self, tool_result: ToolResult):
133
+ if view := self._tool_calls.get(tool_result.tool_call_id):
134
+ view.finish(tool_result.result)
135
+ self._live.update(self._compose())
136
+
137
+ def update_status(self, status: StatusSnapshot):
138
+ if self._status_text is None:
139
+ return
140
+ self._status_text.plain = self._format_status(status)
141
+
142
+ def finish(self):
143
+ for view in self._tool_calls.values():
144
+ if not view.finished:
145
+ # this should not happen, but just in case
146
+ view.finish(ToolOk(output=""))
147
+ self._live.update(self._compose())
148
+
149
+ def interrupt(self):
150
+ for view in self._tool_calls.values():
151
+ if not view.finished:
152
+ view.finish(ToolError(message="", brief="Interrupted"))
153
+ self._live.update(self._compose())
154
+
155
+ @staticmethod
156
+ def _format_status(status: StatusSnapshot) -> str:
157
+ bounded = max(0.0, min(status.context_usage, 1.0))
158
+ return f"context: {bounded:.1%}"