kimi-cli 0.35__py3-none-any.whl → 0.52__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.
Files changed (88) hide show
  1. kimi_cli/CHANGELOG.md +165 -0
  2. kimi_cli/__init__.py +0 -374
  3. kimi_cli/agents/{koder → default}/agent.yaml +1 -1
  4. kimi_cli/agents/{koder → default}/system.md +1 -1
  5. kimi_cli/agentspec.py +115 -0
  6. kimi_cli/app.py +208 -0
  7. kimi_cli/cli.py +321 -0
  8. kimi_cli/config.py +33 -16
  9. kimi_cli/constant.py +4 -0
  10. kimi_cli/exception.py +16 -0
  11. kimi_cli/llm.py +144 -3
  12. kimi_cli/metadata.py +6 -69
  13. kimi_cli/prompts/__init__.py +4 -0
  14. kimi_cli/session.py +103 -0
  15. kimi_cli/soul/__init__.py +130 -9
  16. kimi_cli/soul/agent.py +159 -0
  17. kimi_cli/soul/approval.py +5 -6
  18. kimi_cli/soul/compaction.py +106 -0
  19. kimi_cli/soul/context.py +1 -1
  20. kimi_cli/soul/kimisoul.py +180 -80
  21. kimi_cli/soul/message.py +6 -6
  22. kimi_cli/soul/runtime.py +96 -0
  23. kimi_cli/soul/toolset.py +3 -2
  24. kimi_cli/tools/__init__.py +35 -31
  25. kimi_cli/tools/bash/__init__.py +25 -9
  26. kimi_cli/tools/bash/cmd.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +5 -4
  28. kimi_cli/tools/file/__init__.py +8 -0
  29. kimi_cli/tools/file/glob.md +1 -1
  30. kimi_cli/tools/file/glob.py +4 -4
  31. kimi_cli/tools/file/grep.py +36 -19
  32. kimi_cli/tools/file/patch.py +52 -10
  33. kimi_cli/tools/file/read.py +6 -5
  34. kimi_cli/tools/file/replace.py +16 -4
  35. kimi_cli/tools/file/write.py +16 -4
  36. kimi_cli/tools/mcp.py +7 -4
  37. kimi_cli/tools/task/__init__.py +60 -41
  38. kimi_cli/tools/task/task.md +1 -1
  39. kimi_cli/tools/todo/__init__.py +4 -2
  40. kimi_cli/tools/utils.py +1 -1
  41. kimi_cli/tools/web/fetch.py +2 -1
  42. kimi_cli/tools/web/search.py +13 -12
  43. kimi_cli/ui/__init__.py +0 -68
  44. kimi_cli/ui/acp/__init__.py +67 -38
  45. kimi_cli/ui/print/__init__.py +46 -69
  46. kimi_cli/ui/shell/__init__.py +145 -154
  47. kimi_cli/ui/shell/console.py +27 -1
  48. kimi_cli/ui/shell/debug.py +187 -0
  49. kimi_cli/ui/shell/keyboard.py +183 -0
  50. kimi_cli/ui/shell/metacmd.py +34 -81
  51. kimi_cli/ui/shell/prompt.py +245 -28
  52. kimi_cli/ui/shell/replay.py +104 -0
  53. kimi_cli/ui/shell/setup.py +19 -19
  54. kimi_cli/ui/shell/update.py +11 -5
  55. kimi_cli/ui/shell/visualize.py +576 -0
  56. kimi_cli/ui/wire/README.md +109 -0
  57. kimi_cli/ui/wire/__init__.py +340 -0
  58. kimi_cli/ui/wire/jsonrpc.py +48 -0
  59. kimi_cli/utils/__init__.py +0 -0
  60. kimi_cli/utils/aiohttp.py +10 -0
  61. kimi_cli/utils/changelog.py +6 -2
  62. kimi_cli/utils/clipboard.py +10 -0
  63. kimi_cli/utils/message.py +15 -1
  64. kimi_cli/utils/rich/__init__.py +33 -0
  65. kimi_cli/utils/rich/markdown.py +959 -0
  66. kimi_cli/utils/rich/markdown_sample.md +108 -0
  67. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  68. kimi_cli/utils/signals.py +41 -0
  69. kimi_cli/utils/string.py +8 -0
  70. kimi_cli/utils/term.py +114 -0
  71. kimi_cli/wire/__init__.py +73 -0
  72. kimi_cli/wire/message.py +191 -0
  73. kimi_cli-0.52.dist-info/METADATA +186 -0
  74. kimi_cli-0.52.dist-info/RECORD +99 -0
  75. kimi_cli-0.52.dist-info/entry_points.txt +3 -0
  76. kimi_cli/agent.py +0 -261
  77. kimi_cli/agents/koder/README.md +0 -3
  78. kimi_cli/prompts/metacmds/__init__.py +0 -4
  79. kimi_cli/soul/wire.py +0 -101
  80. kimi_cli/ui/shell/liveview.py +0 -158
  81. kimi_cli/utils/provider.py +0 -64
  82. kimi_cli-0.35.dist-info/METADATA +0 -24
  83. kimi_cli-0.35.dist-info/RECORD +0 -76
  84. kimi_cli-0.35.dist-info/entry_points.txt +0 -3
  85. /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
  86. /kimi_cli/prompts/{metacmds/compact.md → compact.md} +0 -0
  87. /kimi_cli/prompts/{metacmds/init.md → init.md} +0 -0
  88. {kimi_cli-0.35.dist-info → kimi_cli-0.52.dist-info}/WHEEL +0 -0
@@ -0,0 +1,96 @@
1
+ import asyncio
2
+ import subprocess
3
+ import sys
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import NamedTuple
7
+
8
+ from kimi_cli.config import Config
9
+ from kimi_cli.llm import LLM
10
+ from kimi_cli.session import Session
11
+ from kimi_cli.soul.approval import Approval
12
+ from kimi_cli.soul.denwarenji import DenwaRenji
13
+ from kimi_cli.utils.logging import logger
14
+
15
+
16
+ class BuiltinSystemPromptArgs(NamedTuple):
17
+ """Builtin system prompt arguments."""
18
+
19
+ KIMI_NOW: str
20
+ """The current datetime."""
21
+ KIMI_WORK_DIR: Path
22
+ """The current working directory."""
23
+ KIMI_WORK_DIR_LS: str
24
+ """The directory listing of current working directory."""
25
+ KIMI_AGENTS_MD: str # TODO: move to first message from system prompt
26
+ """The content of AGENTS.md."""
27
+
28
+
29
+ def load_agents_md(work_dir: Path) -> str | None:
30
+ paths = [
31
+ work_dir / "AGENTS.md",
32
+ work_dir / "agents.md",
33
+ ]
34
+ for path in paths:
35
+ if path.is_file():
36
+ logger.info("Loaded agents.md: {path}", path=path)
37
+ return path.read_text(encoding="utf-8").strip()
38
+ logger.info("No AGENTS.md found in {work_dir}", work_dir=work_dir)
39
+ return None
40
+
41
+
42
+ def _list_work_dir(work_dir: Path) -> str:
43
+ if sys.platform == "win32":
44
+ ls = subprocess.run(
45
+ ["cmd", "/c", "dir", work_dir],
46
+ capture_output=True,
47
+ text=True,
48
+ encoding="utf-8",
49
+ errors="replace",
50
+ )
51
+ else:
52
+ ls = subprocess.run(
53
+ ["ls", "-la", work_dir],
54
+ capture_output=True,
55
+ text=True,
56
+ encoding="utf-8",
57
+ errors="replace",
58
+ )
59
+ return ls.stdout.strip()
60
+
61
+
62
+ class Runtime(NamedTuple):
63
+ """Agent runtime."""
64
+
65
+ config: Config
66
+ llm: LLM | None
67
+ session: Session
68
+ builtin_args: BuiltinSystemPromptArgs
69
+ denwa_renji: DenwaRenji
70
+ approval: Approval
71
+
72
+ @staticmethod
73
+ async def create(
74
+ config: Config,
75
+ llm: LLM | None,
76
+ session: Session,
77
+ yolo: bool,
78
+ ) -> "Runtime":
79
+ ls_output, agents_md = await asyncio.gather(
80
+ asyncio.to_thread(_list_work_dir, session.work_dir),
81
+ asyncio.to_thread(load_agents_md, session.work_dir),
82
+ )
83
+
84
+ return Runtime(
85
+ config=config,
86
+ llm=llm,
87
+ session=session,
88
+ builtin_args=BuiltinSystemPromptArgs(
89
+ KIMI_NOW=datetime.now().astimezone().isoformat(),
90
+ KIMI_WORK_DIR=session.work_dir,
91
+ KIMI_WORK_DIR_LS=ls_output,
92
+ KIMI_AGENTS_MD=agents_md or "",
93
+ ),
94
+ denwa_renji=DenwaRenji(),
95
+ approval=Approval(yolo=yolo),
96
+ )
kimi_cli/soul/toolset.py CHANGED
@@ -1,8 +1,9 @@
1
1
  from contextvars import ContextVar
2
2
  from typing import override
3
3
 
4
- from kosong.base.message import ToolCall
5
- from kosong.tooling import HandleResult, SimpleToolset
4
+ from kosong.message import ToolCall
5
+ from kosong.tooling import HandleResult
6
+ from kosong.tooling.simple import SimpleToolset
6
7
 
7
8
  current_tool_call = ContextVar[ToolCall | None]("current_tool_call", default=None)
8
9
 
@@ -1,81 +1,85 @@
1
1
  import json
2
2
  from pathlib import Path
3
+ from typing import cast
3
4
 
4
- import streamingjson
5
+ import streamingjson # pyright: ignore[reportMissingTypeStubs]
5
6
  from kosong.utils.typing import JsonType
6
7
 
7
8
  from kimi_cli.utils.string import shorten_middle
8
9
 
9
10
 
10
- def extract_subtitle(lexer: streamingjson.Lexer, tool_name: str) -> str | None:
11
+ class SkipThisTool(Exception):
12
+ """Raised when a tool decides to skip itself from the loading process."""
13
+
14
+ pass
15
+
16
+
17
+ def extract_key_argument(json_content: str | streamingjson.Lexer, tool_name: str) -> str | None:
18
+ if isinstance(json_content, streamingjson.Lexer):
19
+ json_str = json_content.complete_json()
20
+ else:
21
+ json_str = json_content
11
22
  try:
12
- curr_args: JsonType = json.loads(lexer.complete_json())
23
+ curr_args: JsonType = json.loads(json_str)
13
24
  except json.JSONDecodeError:
14
25
  return None
15
26
  if not curr_args:
16
27
  return None
17
- subtitle: str = ""
28
+ key_argument: str = ""
18
29
  match tool_name:
19
30
  case "Task":
20
31
  if not isinstance(curr_args, dict) or not curr_args.get("description"):
21
32
  return None
22
- subtitle = str(curr_args["description"])
33
+ key_argument = str(curr_args["description"])
23
34
  case "SendDMail":
24
35
  return "El Psy Kongroo"
25
36
  case "Think":
26
37
  if not isinstance(curr_args, dict) or not curr_args.get("thought"):
27
38
  return None
28
- subtitle = str(curr_args["thought"])
39
+ key_argument = str(curr_args["thought"])
29
40
  case "SetTodoList":
30
- if not isinstance(curr_args, dict) or not curr_args.get("todos"):
31
- return None
32
- if not isinstance(curr_args["todos"], list):
33
- return None
34
- for todo in curr_args["todos"]:
35
- if not isinstance(todo, dict) or not todo.get("title"):
36
- continue
37
- subtitle += f"• {todo['title']}"
38
- if todo.get("status"):
39
- subtitle += f" [{todo['status']}]"
40
- subtitle += "\n"
41
- return "\n" + subtitle.strip()
42
- case "Bash":
41
+ return None
42
+ case "Bash" | "CMD":
43
43
  if not isinstance(curr_args, dict) or not curr_args.get("command"):
44
44
  return None
45
- subtitle = str(curr_args["command"])
45
+ key_argument = str(curr_args["command"])
46
46
  case "ReadFile":
47
47
  if not isinstance(curr_args, dict) or not curr_args.get("path"):
48
48
  return None
49
- subtitle = _normalize_path(str(curr_args["path"]))
49
+ key_argument = _normalize_path(str(curr_args["path"]))
50
50
  case "Glob":
51
51
  if not isinstance(curr_args, dict) or not curr_args.get("pattern"):
52
52
  return None
53
- subtitle = str(curr_args["pattern"])
53
+ key_argument = str(curr_args["pattern"])
54
54
  case "Grep":
55
55
  if not isinstance(curr_args, dict) or not curr_args.get("pattern"):
56
56
  return None
57
- subtitle = str(curr_args["pattern"])
57
+ key_argument = str(curr_args["pattern"])
58
58
  case "WriteFile":
59
59
  if not isinstance(curr_args, dict) or not curr_args.get("path"):
60
60
  return None
61
- subtitle = _normalize_path(str(curr_args["path"]))
61
+ key_argument = _normalize_path(str(curr_args["path"]))
62
62
  case "StrReplaceFile":
63
63
  if not isinstance(curr_args, dict) or not curr_args.get("path"):
64
64
  return None
65
- subtitle = _normalize_path(str(curr_args["path"]))
65
+ key_argument = _normalize_path(str(curr_args["path"]))
66
66
  case "SearchWeb":
67
67
  if not isinstance(curr_args, dict) or not curr_args.get("query"):
68
68
  return None
69
- subtitle = str(curr_args["query"])
69
+ key_argument = str(curr_args["query"])
70
70
  case "FetchURL":
71
71
  if not isinstance(curr_args, dict) or not curr_args.get("url"):
72
72
  return None
73
- subtitle = str(curr_args["url"])
73
+ key_argument = str(curr_args["url"])
74
74
  case _:
75
- subtitle = "".join(lexer.json_content)
76
- if tool_name not in ["SetTodoList"]:
77
- subtitle = shorten_middle(subtitle, width=50)
78
- return subtitle
75
+ if isinstance(json_content, streamingjson.Lexer):
76
+ # lexer.json_content is list[str] based on streamingjson source code
77
+ content: list[str] = cast(list[str], json_content.json_content) # pyright: ignore[reportUnknownMemberType]
78
+ key_argument = "".join(content)
79
+ else:
80
+ key_argument = json_content
81
+ key_argument = shorten_middle(key_argument, width=50)
82
+ return key_argument
79
83
 
80
84
 
81
85
  def _normalize_path(path: str) -> str:
@@ -1,6 +1,8 @@
1
1
  import asyncio
2
+ import platform
3
+ from collections.abc import Callable
2
4
  from pathlib import Path
3
- from typing import override
5
+ from typing import Any, override
4
6
 
5
7
  from kosong.tooling import CallableTool2, ToolReturnType
6
8
  from pydantic import BaseModel, Field
@@ -24,12 +26,16 @@ class Params(BaseModel):
24
26
  )
25
27
 
26
28
 
29
+ _NAME = "CMD" if platform.system() == "Windows" else "Bash"
30
+ _DESC_FILE = "cmd.md" if platform.system() == "Windows" else "bash.md"
31
+
32
+
27
33
  class Bash(CallableTool2[Params]):
28
- name: str = "Bash"
29
- description: str = load_desc(Path(__file__).parent / "bash.md", {})
34
+ name: str = _NAME
35
+ description: str = load_desc(Path(__file__).parent / _DESC_FILE, {})
30
36
  params: type[Params] = Params
31
37
 
32
- def __init__(self, approval: Approval, **kwargs):
38
+ def __init__(self, approval: Approval, **kwargs: Any):
33
39
  super().__init__(**kwargs)
34
40
  self._approval = approval
35
41
 
@@ -38,16 +44,18 @@ class Bash(CallableTool2[Params]):
38
44
  builder = ToolResultBuilder()
39
45
 
40
46
  if not await self._approval.request(
41
- f"run command {params.command}", f"Run command `{params.command}`"
47
+ self.name,
48
+ "run shell command",
49
+ f"Run command `{params.command}`",
42
50
  ):
43
51
  return ToolRejectedError()
44
52
 
45
53
  def stdout_cb(line: bytes):
46
- line_str = line.decode()
54
+ line_str = line.decode(encoding="utf-8", errors="replace")
47
55
  builder.write(line_str)
48
56
 
49
57
  def stderr_cb(line: bytes):
50
- line_str = line.decode()
58
+ line_str = line.decode(encoding="utf-8", errors="replace")
51
59
  builder.write(line_str)
52
60
 
53
61
  try:
@@ -69,8 +77,13 @@ class Bash(CallableTool2[Params]):
69
77
  )
70
78
 
71
79
 
72
- async def _stream_subprocess(command: str, stdout_cb, stderr_cb, timeout: int) -> int:
73
- async def _read_stream(stream, cb):
80
+ async def _stream_subprocess(
81
+ command: str,
82
+ stdout_cb: Callable[[bytes], None],
83
+ stderr_cb: Callable[[bytes], None],
84
+ timeout: int,
85
+ ) -> int:
86
+ async def _read_stream(stream: asyncio.StreamReader, cb: Callable[[bytes], None]):
74
87
  while True:
75
88
  line = await stream.readline()
76
89
  if line:
@@ -83,6 +96,9 @@ async def _stream_subprocess(command: str, stdout_cb, stderr_cb, timeout: int) -
83
96
  command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
84
97
  )
85
98
 
99
+ assert process.stdout is not None, "stdout is None"
100
+ assert process.stderr is not None, "stderr is None"
101
+
86
102
  try:
87
103
  await asyncio.wait_for(
88
104
  asyncio.gather(
@@ -0,0 +1,31 @@
1
+ Execute a Windows Command Prompt (`cmd.exe`) command. Use this tool to explore the filesystem, inspect or edit files, run Windows scripts, collect system information, etc., whenever the agent is running on Windows.
2
+
3
+ Note that you are running on Windows, so make sure to use Windows commands, paths, and conventions.
4
+
5
+ **Output:**
6
+ The stdout and stderr streams are combined and returned as a single string. Extremely long output may be truncated. When a command fails, the exit code is provided in a system tag.
7
+
8
+ **Guidelines for safety and security:**
9
+ - Every tool call starts a fresh `cmd.exe` session. Environment variables, `cd` changes, and command history do not persist between calls.
10
+ - Do not launch interactive programs or anything that is expected to block indefinitely; ensure each command finishes promptly. Provide a `timeout` argument for potentially long runs.
11
+ - Avoid using `..` to leave the working directory, and never touch files outside that directory unless explicitly instructed.
12
+ - Never attempt commands that require elevated (Administrator) privileges unless explicitly authorized.
13
+
14
+ **Windows-specific tips:**
15
+ - Use `cd /d "<path>"` when you must switch drives and directories in one command.
16
+ - Quote any path containing spaces with double quotes. Escape special characters such as `&`, `|`, `>`, and `<` with `^` when needed.
17
+ - Prefer non-interactive file editing techniques such as `type`, `more`, `copy`, `powershell -Command "Get-Content"`, or `python - <<'PY' ... PY`.
18
+ - Convert forward slashes to backslashes only when a command explicitly requires it; most tooling on Windows accepts `/` as well.
19
+
20
+ **Guidelines for efficiency:**
21
+ - Chain related commands with `&&` (stop on failure) or `&` (always continue); use `||` to run a fallback after a failure.
22
+ - Redirect or pipe output with `>`, `>>`, `|`, and leverage `for /f`, `if`, and `set` to build richer one-liners instead of multiple tool calls.
23
+ - Reuse built-in utilities (e.g., `findstr`, `where`, `powershell`) to filter, transform, or locate data in a single invocation.
24
+
25
+ **Commands available:**
26
+ - Shell environment: `cd`, `dir`, `set`, `setlocal`, `echo`, `call`, `where`
27
+ - File operations: `type`, `copy`, `move`, `del`, `erase`, `mkdir`, `rmdir`, `attrib`, `mklink`
28
+ - Text/search: `find`, `findstr`, `more`, `sort`, `powershell -Command "Get-Content"`
29
+ - System info: `ver`, `systeminfo`, `tasklist`, `wmic`, `hostname`
30
+ - Archives/scripts: `tar`, `powershell -Command "Compress-Archive"`, `powershell`, `python`, `node`
31
+ - Other: Any other binaries available on the system PATH; run `where <command>` first if unsure.
@@ -1,19 +1,20 @@
1
1
  from pathlib import Path
2
- from typing import override
2
+ from typing import Any, override
3
3
 
4
4
  from kosong.tooling import CallableTool2, ToolError, ToolReturnType
5
5
 
6
6
  from kimi_cli.soul.denwarenji import DenwaRenji, DenwaRenjiError, DMail
7
+ from kimi_cli.tools.utils import load_desc
7
8
 
8
9
  NAME = "SendDMail"
9
10
 
10
11
 
11
- class SendDMail(CallableTool2):
12
+ class SendDMail(CallableTool2[DMail]):
12
13
  name: str = NAME
13
- description: str = (Path(__file__).parent / "dmail.md").read_text()
14
+ description: str = load_desc(Path(__file__).parent / "dmail.md")
14
15
  params: type[DMail] = DMail
15
16
 
16
- def __init__(self, denwa_renji: DenwaRenji, **kwargs):
17
+ def __init__(self, denwa_renji: DenwaRenji, **kwargs: Any) -> None:
17
18
  super().__init__(**kwargs)
18
19
  self._denwa_renji = denwa_renji
19
20
 
@@ -1,9 +1,17 @@
1
+ from enum import Enum
2
+
3
+
1
4
  class FileOpsWindow:
2
5
  """Maintains a window of file operations."""
3
6
 
4
7
  pass
5
8
 
6
9
 
10
+ class FileActions(str, Enum):
11
+ READ = "read file"
12
+ EDIT = "edit file"
13
+
14
+
7
15
  from .glob import Glob # noqa: E402
8
16
  from .grep import Grep # noqa: E402
9
17
  from .patch import PatchFile # noqa: E402
@@ -14,4 +14,4 @@ Find files and directories using glob patterns. This tool supports standard glob
14
14
 
15
15
  **Bad example patterns:**
16
16
  - `**`, `**/*.py` - Any pattern starting with '**' will be rejected. Because it would recursively search all directories and subdirectories, which is very likely to yield large result that exceeds your context size. Always use more specific patterns like `src/**/*.py` instead.
17
- - `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid recursivelly searching in such directories, other examples include `venv`, `.venv`, `__pycache__`, `target`. If you really need to search in a dependency, use more specific patterns like `node_modules/react/src/*` instead.
17
+ - `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid recursively searching in such directories, other examples include `venv`, `.venv`, `__pycache__`, `target`. If you really need to search in a dependency, use more specific patterns like `node_modules/react/src/*` instead.
@@ -2,13 +2,13 @@
2
2
 
3
3
  import asyncio
4
4
  from pathlib import Path
5
- from typing import override
5
+ from typing import Any, override
6
6
 
7
7
  import aiofiles.os
8
8
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
9
9
  from pydantic import BaseModel, Field
10
10
 
11
- from kimi_cli.agent import BuiltinSystemPromptArgs
11
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
12
12
  from kimi_cli.tools.utils import load_desc
13
13
 
14
14
  MAX_MATCHES = 1000
@@ -38,7 +38,7 @@ class Glob(CallableTool2[Params]):
38
38
  )
39
39
  params: type[Params] = Params
40
40
 
41
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
41
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs: Any) -> None:
42
42
  super().__init__(**kwargs)
43
43
  self._work_dir = builtin_args.KIMI_WORK_DIR
44
44
 
@@ -128,7 +128,7 @@ class Glob(CallableTool2[Params]):
128
128
  message = (
129
129
  f"Found {len(matches)} matches for pattern `{params.pattern}`."
130
130
  if len(matches) > 0
131
- else "No matches found for pattern `{params.pattern}`."
131
+ else f"No matches found for pattern `{params.pattern}`."
132
132
  )
133
133
  if len(matches) > MAX_MATCHES:
134
134
  matches = matches[:MAX_MATCHES]
@@ -1,20 +1,22 @@
1
1
  import asyncio
2
- import os
3
2
  import platform
4
3
  import shutil
5
4
  import stat
6
5
  import tarfile
7
6
  import tempfile
7
+ import zipfile
8
8
  from pathlib import Path
9
9
  from typing import override
10
10
 
11
11
  import aiohttp
12
- import ripgrepy
12
+ import ripgrepy # pyright: ignore[reportMissingTypeStubs]
13
13
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
14
14
  from pydantic import BaseModel, Field
15
15
 
16
16
  import kimi_cli
17
17
  from kimi_cli.share import get_share_dir
18
+ from kimi_cli.tools.utils import load_desc
19
+ from kimi_cli.utils.aiohttp import new_client_session
18
20
  from kimi_cli.utils.logging import logger
19
21
 
20
22
 
@@ -112,7 +114,7 @@ _RG_DOWNLOAD_LOCK = asyncio.Lock()
112
114
 
113
115
 
114
116
  def _rg_binary_name() -> str:
115
- return "rg.exe" if os.name == "nt" else "rg"
117
+ return "rg.exe" if platform.system() == "Windows" else "rg"
116
118
 
117
119
 
118
120
  def _find_existing_rg(bin_name: str) -> Path | None:
@@ -147,6 +149,8 @@ def _detect_target() -> str | None:
147
149
  os_name = "apple-darwin"
148
150
  elif sys_name == "Linux":
149
151
  os_name = "unknown-linux-musl" if arch == "x86_64" else "unknown-linux-gnu"
152
+ elif sys_name == "Windows":
153
+ os_name = "pc-windows-msvc"
150
154
  else:
151
155
  logger.error("Unsupported operating system for ripgrep: {sys_name}", sys_name=sys_name)
152
156
  return None
@@ -159,7 +163,9 @@ async def _download_and_install_rg(bin_name: str) -> Path:
159
163
  if not target:
160
164
  raise RuntimeError("Unsupported platform for ripgrep download")
161
165
 
162
- filename = f"ripgrep-{RG_VERSION}-{target}.tar.gz"
166
+ is_windows = "windows" in target
167
+ archive_ext = "zip" if is_windows else "tar.gz"
168
+ filename = f"ripgrep-{RG_VERSION}-{target}.{archive_ext}"
163
169
  url = f"{RG_BASE_URL}/{filename}"
164
170
  logger.info("Downloading ripgrep from {url}", url=url)
165
171
 
@@ -167,7 +173,7 @@ async def _download_and_install_rg(bin_name: str) -> Path:
167
173
  share_bin_dir.mkdir(parents=True, exist_ok=True)
168
174
  destination = share_bin_dir / bin_name
169
175
 
170
- async with aiohttp.ClientSession() as session:
176
+ async with new_client_session() as session:
171
177
  with tempfile.TemporaryDirectory(prefix="kimi-rg-") as tmpdir:
172
178
  tar_path = Path(tmpdir) / filename
173
179
 
@@ -182,19 +188,30 @@ async def _download_and_install_rg(bin_name: str) -> Path:
182
188
  raise RuntimeError("Failed to download ripgrep binary") from exc
183
189
 
184
190
  try:
185
- with tarfile.open(tar_path, "r:gz") as tar:
186
- member = next(
187
- (m for m in tar.getmembers() if Path(m.name).name == bin_name),
188
- None,
189
- )
190
- if not member:
191
- raise RuntimeError("Ripgrep binary not found in archive")
192
- extracted = tar.extractfile(member)
193
- if not extracted:
194
- raise RuntimeError("Failed to extract ripgrep binary")
195
- with open(destination, "wb") as dest_fh:
196
- shutil.copyfileobj(extracted, dest_fh)
197
- except (tarfile.TarError, OSError) as exc:
191
+ if is_windows:
192
+ with zipfile.ZipFile(tar_path, "r") as zf:
193
+ member_name = next(
194
+ (name for name in zf.namelist() if Path(name).name == bin_name),
195
+ None,
196
+ )
197
+ if not member_name:
198
+ raise RuntimeError("Ripgrep binary not found in archive")
199
+ with zf.open(member_name) as source, open(destination, "wb") as dest_fh:
200
+ shutil.copyfileobj(source, dest_fh)
201
+ else:
202
+ with tarfile.open(tar_path, "r:gz") as tar:
203
+ member = next(
204
+ (m for m in tar.getmembers() if Path(m.name).name == bin_name),
205
+ None,
206
+ )
207
+ if not member:
208
+ raise RuntimeError("Ripgrep binary not found in archive")
209
+ extracted = tar.extractfile(member)
210
+ if not extracted:
211
+ raise RuntimeError("Failed to extract ripgrep binary")
212
+ with open(destination, "wb") as dest_fh:
213
+ shutil.copyfileobj(extracted, dest_fh)
214
+ except (zipfile.BadZipFile, tarfile.TarError, OSError) as exc:
198
215
  raise RuntimeError("Failed to extract ripgrep archive") from exc
199
216
 
200
217
  destination.chmod(destination.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@@ -219,7 +236,7 @@ async def _ensure_rg_path() -> str:
219
236
 
220
237
  class Grep(CallableTool2[Params]):
221
238
  name: str = "Grep"
222
- description: str = (Path(__file__).parent / "grep.md").read_text()
239
+ description: str = load_desc(Path(__file__).parent / "grep.md")
223
240
  params: type[Params] = Params
224
241
 
225
242
  @override
@@ -1,12 +1,45 @@
1
1
  from pathlib import Path
2
- from typing import override
2
+ from typing import Any, Literal, override
3
3
 
4
4
  import aiofiles
5
- import patch_ng
5
+ import patch_ng # pyright: ignore[reportMissingTypeStubs]
6
6
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
7
7
  from pydantic import BaseModel, Field
8
8
 
9
- from kimi_cli.agent import BuiltinSystemPromptArgs
9
+ from kimi_cli.soul.approval import Approval
10
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
11
+ from kimi_cli.tools.file import FileActions
12
+ from kimi_cli.tools.utils import ToolRejectedError, load_desc
13
+
14
+
15
+ def _parse_patch(diff_bytes: bytes) -> patch_ng.PatchSet | None:
16
+ """Parse patch from bytes, returning PatchSet or None on error.
17
+
18
+ This wrapper provides type hints for the untyped patch_ng.fromstring function.
19
+ """
20
+ result: patch_ng.PatchSet | Literal[False] = patch_ng.fromstring(diff_bytes) # pyright: ignore[reportUnknownMemberType]
21
+ return result if result is not False else None
22
+
23
+
24
+ def _count_hunks(patch_set: patch_ng.PatchSet) -> int:
25
+ """Count total hunks across all items in a PatchSet.
26
+
27
+ This wrapper provides type hints for the untyped patch_ng library.
28
+ From source code inspection: PatchSet.items is list[Patch], Patch.hunks is list[Hunk].
29
+ Type ignore needed because patch_ng lacks type annotations.
30
+ """
31
+ items: list[patch_ng.Patch] = patch_set.items # pyright: ignore[reportUnknownMemberType]
32
+ # Each Patch has a hunks attribute (list[Hunk])
33
+ return sum(len(item.hunks) for item in items) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType]
34
+
35
+
36
+ def _apply_patch(patch_set: patch_ng.PatchSet, root: str) -> bool:
37
+ """Apply a patch to files under the given root directory.
38
+
39
+ This wrapper provides type hints for the untyped patch_ng.apply method.
40
+ """
41
+ success: Any = patch_set.apply(root=root) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
42
+ return bool(success) # pyright: ignore[reportUnknownArgumentType]
10
43
 
11
44
 
12
45
  class Params(BaseModel):
@@ -16,12 +49,13 @@ class Params(BaseModel):
16
49
 
17
50
  class PatchFile(CallableTool2[Params]):
18
51
  name: str = "PatchFile"
19
- description: str = (Path(__file__).parent / "patch.md").read_text()
52
+ description: str = load_desc(Path(__file__).parent / "patch.md")
20
53
  params: type[Params] = Params
21
54
 
22
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
55
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any):
23
56
  super().__init__(**kwargs)
24
57
  self._work_dir = builtin_args.KIMI_WORK_DIR
58
+ self._approval = approval
25
59
 
26
60
  def _validate_path(self, path: Path) -> ToolError | None:
27
61
  """Validate that the path is safe to patch."""
@@ -70,15 +104,23 @@ class PatchFile(CallableTool2[Params]):
70
104
  brief="Invalid path",
71
105
  )
72
106
 
107
+ # Request approval
108
+ if not await self._approval.request(
109
+ self.name,
110
+ FileActions.EDIT,
111
+ f"Patch file `{params.path}`",
112
+ ):
113
+ return ToolRejectedError()
114
+
73
115
  # Read the file content
74
116
  async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
75
117
  original_content = await f.read()
76
118
 
77
119
  # Create patch object directly from string (no temporary file needed!)
78
- patch_set = patch_ng.fromstring(params.diff.encode("utf-8"))
120
+ patch_set = _parse_patch(params.diff.encode("utf-8"))
79
121
 
80
- # Handle case where patch_ng.fromstring returns False on parse errors
81
- if not patch_set or patch_set is True:
122
+ # Handle case where parsing failed
123
+ if patch_set is None:
82
124
  return ToolError(
83
125
  message=(
84
126
  "Failed to parse diff content: invalid patch format or no valid hunks found"
@@ -87,7 +129,7 @@ class PatchFile(CallableTool2[Params]):
87
129
  )
88
130
 
89
131
  # Count total hunks across all items
90
- total_hunks = sum(len(item.hunks) for item in patch_set.items)
132
+ total_hunks = _count_hunks(patch_set)
91
133
 
92
134
  if total_hunks == 0:
93
135
  return ToolError(
@@ -96,7 +138,7 @@ class PatchFile(CallableTool2[Params]):
96
138
  )
97
139
 
98
140
  # Apply the patch
99
- success = patch_set.apply(root=str(p.parent))
141
+ success = _apply_patch(patch_set, str(p.parent))
100
142
 
101
143
  if not success:
102
144
  return ToolError(