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,76 @@
1
+ from kosong.base.message import ContentPart, Message, TextPart
2
+ from kosong.tooling import ToolError, ToolOk, ToolResult
3
+ from kosong.tooling.error import ToolRuntimeError
4
+
5
+
6
+ def system(message: str) -> ContentPart:
7
+ return TextPart(text=f"<system>{message}</system>")
8
+
9
+
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):
16
+ message += "\nThis is an unexpected error and the tool is probably not working."
17
+ content = [system(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 = []
30
+ non_text_parts = []
31
+ for part in content:
32
+ if isinstance(part, TextPart):
33
+ text_parts.append(part)
34
+ else:
35
+ non_text_parts.append(part)
36
+
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
+ )
50
+ )
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
+
60
+
61
+ def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
62
+ """Convert a tool return value to a list of message content parts."""
63
+ content = []
64
+ if result.message:
65
+ content.append(system(result.message))
66
+ match output := result.output:
67
+ case str(text):
68
+ if text:
69
+ content.append(TextPart(text=text))
70
+ case ContentPart():
71
+ content.append(output)
72
+ case _:
73
+ content.extend(list(output))
74
+ if not content:
75
+ content.append(system("Tool output is empty."))
76
+ return content
@@ -0,0 +1,25 @@
1
+ from contextvars import ContextVar
2
+ from typing import override
3
+
4
+ from kosong.base.message import ToolCall
5
+ from kosong.tooling import HandleResult, SimpleToolset
6
+
7
+ current_tool_call = ContextVar[ToolCall | None]("current_tool_call", default=None)
8
+
9
+
10
+ def get_current_tool_call_or_none() -> ToolCall | None:
11
+ """
12
+ Get the current tool call or None.
13
+ Expect to be not None when called from a `__call__` method of a tool.
14
+ """
15
+ return current_tool_call.get()
16
+
17
+
18
+ class CustomToolset(SimpleToolset):
19
+ @override
20
+ def handle(self, tool_call: ToolCall) -> HandleResult:
21
+ token = current_tool_call.set(tool_call)
22
+ try:
23
+ return super().handle(tool_call)
24
+ finally:
25
+ current_tool_call.reset(token)
kimi_cli/soul/wire.py ADDED
@@ -0,0 +1,101 @@
1
+ import asyncio
2
+ import uuid
3
+ from contextvars import ContextVar
4
+ from enum import Enum
5
+ from typing import NamedTuple
6
+
7
+ from kosong.base.message import ContentPart, ToolCall, ToolCallPart
8
+ from kosong.tooling import ToolResult
9
+
10
+ from kimi_cli.soul import StatusSnapshot
11
+ from kimi_cli.utils.logging import logger
12
+
13
+
14
+ class StepBegin(NamedTuple):
15
+ n: int
16
+
17
+
18
+ class StepInterrupted(NamedTuple):
19
+ pass
20
+
21
+
22
+ class StatusUpdate(NamedTuple):
23
+ status: StatusSnapshot
24
+
25
+
26
+ type ControlFlowEvent = StepBegin | StepInterrupted | StatusUpdate
27
+ type Event = ControlFlowEvent | ContentPart | ToolCall | ToolCallPart | ToolResult
28
+
29
+
30
+ class ApprovalResponse(Enum):
31
+ APPROVE = "approve"
32
+ APPROVE_FOR_SESSION = "approve_for_session"
33
+ REJECT = "reject"
34
+
35
+
36
+ class ApprovalRequest:
37
+ def __init__(self, tool_call_id: str, action: str, description: str):
38
+ self.id = str(uuid.uuid4())
39
+ self.tool_call_id = tool_call_id
40
+ self.action = action
41
+ self.description = description
42
+ self._future = asyncio.Future[ApprovalResponse]()
43
+
44
+ def __repr__(self) -> str:
45
+ return (
46
+ f"ApprovalRequest(id={self.id}, tool_call_id={self.tool_call_id}, "
47
+ f"action={self.action}, description={self.description})"
48
+ )
49
+
50
+ async def wait(self) -> ApprovalResponse:
51
+ """
52
+ Wait for the request to be resolved or cancelled.
53
+
54
+ Returns:
55
+ ApprovalResponse: The response to the approval request.
56
+ """
57
+ return await self._future
58
+
59
+ def resolve(self, response: ApprovalResponse) -> None:
60
+ """
61
+ Resolve the approval request with the given response.
62
+ This will cause the `wait()` method to return the response.
63
+ """
64
+ self._future.set_result(response)
65
+
66
+
67
+ type WireMessage = Event | ApprovalRequest
68
+
69
+
70
+ class Wire:
71
+ """
72
+ A channel for communication between the soul and the UI.
73
+ """
74
+
75
+ def __init__(self):
76
+ self._queue = asyncio.Queue[WireMessage]()
77
+
78
+ def send(self, msg: WireMessage) -> None:
79
+ if not isinstance(msg, ContentPart | ToolCallPart):
80
+ logger.debug("Sending wire message: {msg}", msg=msg)
81
+ self._queue.put_nowait(msg)
82
+
83
+ async def receive(self) -> WireMessage:
84
+ msg = await self._queue.get()
85
+ if not isinstance(msg, ContentPart | ToolCallPart):
86
+ logger.debug("Receiving wire message: {msg}", msg=msg)
87
+ return msg
88
+
89
+ def shutdown(self) -> None:
90
+ self._queue.shutdown()
91
+
92
+
93
+ current_wire = ContextVar[Wire | None]("current_wire", default=None)
94
+
95
+
96
+ def get_wire_or_none() -> Wire | None:
97
+ """
98
+ Get the current wire or None.
99
+ Expect to be not None when called from anywhere in the agent loop.
100
+ """
101
+ return current_wire.get()
@@ -0,0 +1,85 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import streamingjson
5
+ from kosong.utils.typing import JsonType
6
+
7
+ from kimi_cli.utils.string import shorten_middle
8
+
9
+
10
+ def extract_subtitle(lexer: streamingjson.Lexer, tool_name: str) -> str | None:
11
+ try:
12
+ curr_args: JsonType = json.loads(lexer.complete_json())
13
+ except json.JSONDecodeError:
14
+ return None
15
+ if not curr_args:
16
+ return None
17
+ subtitle: str = ""
18
+ match tool_name:
19
+ case "Task":
20
+ if not isinstance(curr_args, dict) or not curr_args.get("description"):
21
+ return None
22
+ subtitle = str(curr_args["description"])
23
+ case "SendDMail":
24
+ return "El Psy Kongroo"
25
+ case "Think":
26
+ if not isinstance(curr_args, dict) or not curr_args.get("thought"):
27
+ return None
28
+ subtitle = str(curr_args["thought"])
29
+ 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":
43
+ if not isinstance(curr_args, dict) or not curr_args.get("command"):
44
+ return None
45
+ subtitle = str(curr_args["command"])
46
+ case "ReadFile":
47
+ if not isinstance(curr_args, dict) or not curr_args.get("path"):
48
+ return None
49
+ subtitle = _normalize_path(str(curr_args["path"]))
50
+ case "Glob":
51
+ if not isinstance(curr_args, dict) or not curr_args.get("pattern"):
52
+ return None
53
+ subtitle = str(curr_args["pattern"])
54
+ case "Grep":
55
+ if not isinstance(curr_args, dict) or not curr_args.get("pattern"):
56
+ return None
57
+ subtitle = str(curr_args["pattern"])
58
+ case "WriteFile":
59
+ if not isinstance(curr_args, dict) or not curr_args.get("path"):
60
+ return None
61
+ subtitle = _normalize_path(str(curr_args["path"]))
62
+ case "StrReplaceFile":
63
+ if not isinstance(curr_args, dict) or not curr_args.get("path"):
64
+ return None
65
+ subtitle = _normalize_path(str(curr_args["path"]))
66
+ case "SearchWeb":
67
+ if not isinstance(curr_args, dict) or not curr_args.get("query"):
68
+ return None
69
+ subtitle = str(curr_args["query"])
70
+ case "FetchURL":
71
+ if not isinstance(curr_args, dict) or not curr_args.get("url"):
72
+ return None
73
+ subtitle = str(curr_args["url"])
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
79
+
80
+
81
+ def _normalize_path(path: str) -> str:
82
+ cwd = str(Path.cwd().absolute())
83
+ if path.startswith(cwd):
84
+ path = path[len(cwd) :].lstrip("/\\")
85
+ return path
@@ -0,0 +1,97 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+ from typing import override
4
+
5
+ from kosong.tooling import CallableTool2, ToolReturnType
6
+ from pydantic import BaseModel, Field
7
+
8
+ from kimi_cli.soul.approval import Approval
9
+ from kimi_cli.tools.utils import ToolRejectedError, ToolResultBuilder, load_desc
10
+
11
+ MAX_TIMEOUT = 5 * 60
12
+
13
+
14
+ class Params(BaseModel):
15
+ command: str = Field(description="The bash command to execute.")
16
+ timeout: int = Field(
17
+ description=(
18
+ "The timeout in seconds for the command to execute. "
19
+ "If the command takes longer than this, it will be killed."
20
+ ),
21
+ default=60,
22
+ ge=1,
23
+ le=MAX_TIMEOUT,
24
+ )
25
+
26
+
27
+ class Bash(CallableTool2[Params]):
28
+ name: str = "Bash"
29
+ description: str = load_desc(Path(__file__).parent / "bash.md", {})
30
+ params: type[Params] = Params
31
+
32
+ def __init__(self, approval: Approval, **kwargs):
33
+ super().__init__(**kwargs)
34
+ self._approval = approval
35
+
36
+ @override
37
+ async def __call__(self, params: Params) -> ToolReturnType:
38
+ builder = ToolResultBuilder()
39
+
40
+ if not await self._approval.request(
41
+ f"run command {params.command}", f"Run command `{params.command}`"
42
+ ):
43
+ return ToolRejectedError()
44
+
45
+ def stdout_cb(line: bytes):
46
+ line_str = line.decode()
47
+ builder.write(line_str)
48
+
49
+ def stderr_cb(line: bytes):
50
+ line_str = line.decode()
51
+ builder.write(line_str)
52
+
53
+ try:
54
+ exitcode = await _stream_subprocess(
55
+ params.command, stdout_cb, stderr_cb, params.timeout
56
+ )
57
+
58
+ if exitcode == 0:
59
+ return builder.ok("Command executed successfully.")
60
+ else:
61
+ return builder.error(
62
+ f"Command failed with exit code: {exitcode}.",
63
+ brief=f"Failed with exit code: {exitcode}",
64
+ )
65
+ except TimeoutError:
66
+ return builder.error(
67
+ f"Command killed by timeout ({params.timeout}s)",
68
+ brief=f"Killed by timeout ({params.timeout}s)",
69
+ )
70
+
71
+
72
+ async def _stream_subprocess(command: str, stdout_cb, stderr_cb, timeout: int) -> int:
73
+ async def _read_stream(stream, cb):
74
+ while True:
75
+ line = await stream.readline()
76
+ if line:
77
+ cb(line)
78
+ else:
79
+ break
80
+
81
+ # FIXME: if the event loop is cancelled, an exception may be raised when the process finishes
82
+ process = await asyncio.create_subprocess_shell(
83
+ command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
84
+ )
85
+
86
+ try:
87
+ await asyncio.wait_for(
88
+ asyncio.gather(
89
+ _read_stream(process.stdout, stdout_cb),
90
+ _read_stream(process.stderr, stderr_cb),
91
+ ),
92
+ timeout,
93
+ )
94
+ return await process.wait()
95
+ except TimeoutError:
96
+ process.kill()
97
+ raise
@@ -0,0 +1,31 @@
1
+ Execute a shell command. Use this tool to explore the filesystem, edit files, run scripts, get system information, etc.
2
+
3
+ **Output:**
4
+ The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the exit code will be provided in a system tag.
5
+
6
+ **Guidelines for safety and security:**
7
+ - Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls.
8
+ - The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, you shall set `timeout` argument to a reasonable value.
9
+ - Avoid using `..` to access files or directories outside of the working directory.
10
+ - Avoid modifying files outside of the working directory unless explicitly instructed to do so.
11
+ - Never run commands that require superuser privileges unless explicitly instructed to do so.
12
+
13
+ **Guidelines for efficiency:**
14
+ - For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la`
15
+ - Use `;` to run commands sequentially regardless of success/failure
16
+ - Use `||` for conditional execution (run second command only if first fails)
17
+ - Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands
18
+ - Always quote file paths containing spaces with double quotes (e.g., cd "/path with spaces/")
19
+ - Use `if`, `case`, `for`, `while` control flows to execute complex logic in a single call.
20
+ - Verify directory structure before create/edit/delete files or directories to reduce the risk of failure.
21
+
22
+ **Commands available:**
23
+ - Shell environment: cd, pwd, export, unset, env
24
+ - File system operations: ls, find, mkdir, rm, cp, mv, touch, chmod, chown
25
+ - File viewing/editing: cat, grep, head, tail, diff, patch
26
+ - Text processing: awk, sed, sort, uniq, wc
27
+ - System information/operations: ps, kill, top, df, free, uname, whoami, id, date
28
+ - Package management: pip, uv, npm, yarn, bun, cargo
29
+ - Network operations: curl, wget, ping, telnet, ssh
30
+ - Archive operations: tar, zip, unzip
31
+ - Other: Other commands available in the shell environment. Check the existence of a command by running `which <command>` before using it.
@@ -0,0 +1,38 @@
1
+ from pathlib import Path
2
+ from typing import override
3
+
4
+ from kosong.tooling import CallableTool2, ToolError, ToolReturnType
5
+
6
+ from kimi_cli.soul.denwarenji import DenwaRenji, DenwaRenjiError, DMail
7
+
8
+ NAME = "SendDMail"
9
+
10
+
11
+ class SendDMail(CallableTool2):
12
+ name: str = NAME
13
+ description: str = (Path(__file__).parent / "dmail.md").read_text()
14
+ params: type[DMail] = DMail
15
+
16
+ def __init__(self, denwa_renji: DenwaRenji, **kwargs):
17
+ super().__init__(**kwargs)
18
+ self._denwa_renji = denwa_renji
19
+
20
+ @override
21
+ async def __call__(self, params: DMail) -> ToolReturnType:
22
+ try:
23
+ self._denwa_renji.send_dmail(params)
24
+ except DenwaRenjiError as e:
25
+ return ToolError(
26
+ output="",
27
+ message=f"Failed to send D-Mail. Error: {str(e)}",
28
+ brief="Failed to send D-Mail",
29
+ )
30
+ # always return an error because a successful SendDMail call will never return
31
+ return ToolError(
32
+ output="",
33
+ message=(
34
+ "If you see this message, the D-Mail was not sent successfully. "
35
+ "This may be because some other tool that needs approval was rejected."
36
+ ),
37
+ brief="D-Mail not sent",
38
+ )
@@ -0,0 +1,15 @@
1
+ Send a message to the past, just like sending a D-Mail in Steins;Gate.
2
+
3
+ You can see some `user` messages with `CHECKPOINT {checkpoint_id}` wrapped in `<system>` tags in the context. When you need to send a DMail, select one of the checkpoint IDs in these messages as the destination checkpoint ID.
4
+
5
+ When a DMail is sent, the system will revert the current context to the specified checkpoint. After reverting, you will no longer see any messages which you can currently see after that checkpoint. The message in the DMail will be appended to the end of the context. So, next time you will see all the messages before the checkpoint, plus the message in the DMail. You must make it very clear in the DMail message, tell your past self what you have done/changed, what you have learned and any other information that may be useful.
6
+
7
+ When sending a DMail, DO NOT do much explanation to the user. The user do not care about this. Just explain to your past self.
8
+
9
+ Here are some typical scenarios you may want to send a DMail:
10
+
11
+ - You read a file, found it very large and most of the content is not relevant to the current task. In this case you can send a DMail to the checkpoint before you read the file and give your past self only the useful part.
12
+ - You searched the web, found the result very large.
13
+ - If you got what you need, you may send a DMail to the checkpoint before you searched the web and give your past self the useful part.
14
+ - If you did not get what you need, you may send a DMail to tell your past self to try another query.
15
+ - You wrote some code and it did not work as expected. You spent many struggling steps to fix it but the process is not relevant to the ultimate goal. In this case you can send a DMail to the checkpoint before you wrote the code and give your past self the fixed version of the code and tell yourself no need to write it again because you already wrote to the filesystem.
@@ -0,0 +1,21 @@
1
+ class FileOpsWindow:
2
+ """Maintains a window of file operations."""
3
+
4
+ pass
5
+
6
+
7
+ from .glob import Glob # noqa: E402
8
+ from .grep import Grep # noqa: E402
9
+ from .patch import PatchFile # noqa: E402
10
+ from .read import ReadFile # noqa: E402
11
+ from .replace import StrReplaceFile # noqa: E402
12
+ from .write import WriteFile # noqa: E402
13
+
14
+ __all__ = (
15
+ "ReadFile",
16
+ "Glob",
17
+ "Grep",
18
+ "WriteFile",
19
+ "StrReplaceFile",
20
+ "PatchFile",
21
+ )
@@ -0,0 +1,17 @@
1
+ Find files and directories using glob patterns. This tool supports standard glob syntax like `*`, `?`, and `**` for recursive searches.
2
+
3
+ **When to use:**
4
+ - Find files matching specific patterns (e.g., all Python files: `*.py`)
5
+ - Search for files recursively in subdirectories (e.g., `src/**/*.js`)
6
+ - Locate configuration files (e.g., `*.config.*`, `*.json`)
7
+ - Find test files (e.g., `test_*.py`, `*_test.go`)
8
+
9
+ **Example patterns:**
10
+ - `*.py` - All Python files in current directory
11
+ - `src/**/*.js` - All JavaScript files in src directory recursively
12
+ - `test_*.py` - Python test files starting with "test_"
13
+ - `*.config.{js,ts}` - Config files with .js or .ts extension
14
+
15
+ **Bad example patterns:**
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.
@@ -0,0 +1,149 @@
1
+ """Glob tool implementation."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import override
6
+
7
+ import aiofiles.os
8
+ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
9
+ from pydantic import BaseModel, Field
10
+
11
+ from kimi_cli.agent import BuiltinSystemPromptArgs
12
+ from kimi_cli.tools.utils import load_desc
13
+
14
+ MAX_MATCHES = 1000
15
+
16
+
17
+ class Params(BaseModel):
18
+ pattern: str = Field(description=("Glob pattern to match files/directories."))
19
+ directory: str | None = Field(
20
+ description=(
21
+ "Absolute path to the directory to search in (defaults to working directory)."
22
+ ),
23
+ default=None,
24
+ )
25
+ include_dirs: bool = Field(
26
+ description="Whether to include directories in results.",
27
+ default=True,
28
+ )
29
+
30
+
31
+ class Glob(CallableTool2[Params]):
32
+ name: str = "Glob"
33
+ description: str = load_desc(
34
+ Path(__file__).parent / "glob.md",
35
+ {
36
+ "MAX_MATCHES": str(MAX_MATCHES),
37
+ },
38
+ )
39
+ params: type[Params] = Params
40
+
41
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
42
+ super().__init__(**kwargs)
43
+ self._work_dir = builtin_args.KIMI_WORK_DIR
44
+
45
+ async def _validate_pattern(self, pattern: str) -> ToolError | None:
46
+ """Validate that the pattern is safe to use."""
47
+ if pattern.startswith("**"):
48
+ # TODO: give a `ls -la` result as the output
49
+ ls_result = await aiofiles.os.listdir(self._work_dir)
50
+ return ToolError(
51
+ output="\n".join(ls_result),
52
+ message=(
53
+ f"Pattern `{pattern}` starts with '**' which is not allowed. "
54
+ "This would recursively search all directories and may include large "
55
+ "directories like `node_modules`. Use more specific patterns instead. "
56
+ "For your convenience, a list of all files and directories in the "
57
+ "top level of the working directory is provided below."
58
+ ),
59
+ brief="Unsafe pattern",
60
+ )
61
+ return None
62
+
63
+ def _validate_directory(self, directory: Path) -> ToolError | None:
64
+ """Validate that the directory is safe to search."""
65
+ resolved_dir = directory.resolve()
66
+ resolved_work_dir = self._work_dir.resolve()
67
+
68
+ # Ensure the directory is within work directory
69
+ if not str(resolved_dir).startswith(str(resolved_work_dir)):
70
+ return ToolError(
71
+ message=(
72
+ f"`{directory}` is outside the working directory. "
73
+ "You can only search within the working directory."
74
+ ),
75
+ brief="Directory outside working directory",
76
+ )
77
+ return None
78
+
79
+ @override
80
+ async def __call__(self, params: Params) -> ToolReturnType:
81
+ try:
82
+ # Validate pattern safety
83
+ pattern_error = await self._validate_pattern(params.pattern)
84
+ if pattern_error:
85
+ return pattern_error
86
+
87
+ dir_path = Path(params.directory) if params.directory else self._work_dir
88
+
89
+ if not dir_path.is_absolute():
90
+ return ToolError(
91
+ message=(
92
+ f"`{params.directory}` is not an absolute path. "
93
+ "You must provide an absolute path to search."
94
+ ),
95
+ brief="Invalid directory",
96
+ )
97
+
98
+ # Validate directory safety
99
+ dir_error = self._validate_directory(dir_path)
100
+ if dir_error:
101
+ return dir_error
102
+
103
+ if not dir_path.exists():
104
+ return ToolError(
105
+ message=f"`{params.directory}` does not exist.",
106
+ brief="Directory not found",
107
+ )
108
+ if not dir_path.is_dir():
109
+ return ToolError(
110
+ message=f"`{params.directory}` is not a directory.",
111
+ brief="Invalid directory",
112
+ )
113
+
114
+ def _glob(pattern: str) -> list[Path]:
115
+ return list(dir_path.glob(pattern))
116
+
117
+ # Perform the glob search - users can use ** directly in pattern
118
+ matches = await asyncio.to_thread(_glob, params.pattern)
119
+
120
+ # Filter out directories if not requested
121
+ if not params.include_dirs:
122
+ matches = [p for p in matches if p.is_file()]
123
+
124
+ # Sort for consistent output
125
+ matches.sort()
126
+
127
+ # Limit matches
128
+ message = (
129
+ f"Found {len(matches)} matches for pattern `{params.pattern}`."
130
+ if len(matches) > 0
131
+ else "No matches found for pattern `{params.pattern}`."
132
+ )
133
+ if len(matches) > MAX_MATCHES:
134
+ matches = matches[:MAX_MATCHES]
135
+ message += (
136
+ f" Only the first {MAX_MATCHES} matches are returned. "
137
+ "You may want to use a more specific pattern."
138
+ )
139
+
140
+ return ToolOk(
141
+ output="\n".join(str(p.relative_to(dir_path)) for p in matches),
142
+ message=message,
143
+ )
144
+
145
+ except Exception as e:
146
+ return ToolError(
147
+ message=f"Failed to search for pattern {params.pattern}. Error: {e}",
148
+ brief="Glob failed",
149
+ )
@@ -0,0 +1,5 @@
1
+ A powerful search tool based-on ripgrep.
2
+
3
+ **Tips:**
4
+ - ALWAYS use Grep tool instead of running `grep` or `rg` command with Bash tool.
5
+ - Use the ripgrep pattern syntax, not grep syntax. E.g. you need to escape braces like `\\{` to search for `{`.