├── .github
├── ISSUE_TEMPLATE
│ ├── 1-bug-report.yml
│ ├── 2-feature-request.yml
│ └── config.yml
├── dependabot.yml
├── pr-title-checker-config.json
└── workflows
│ ├── ci.yml
│ ├── pr-title-checker.yml
│ ├── release.yml
│ ├── translator.yml
│ └── typos.yml
├── .gitignore
├── .python-version
├── AGENTS.md
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── docs
└── images
│ └── setup.png
├── kimi.spec
├── pyproject.toml
├── pyrightconfig.json
├── src
└── kimi_cli
│ ├── CHANGELOG.md
│ ├── __init__.py
│ ├── agents
│ └── koder
│ │ ├── README.md
│ │ ├── agent.yaml
│ │ ├── sub.yaml
│ │ └── system.md
│ ├── agentspec.py
│ ├── cli.py
│ ├── config.py
│ ├── constant.py
│ ├── deps
│ └── Makefile
│ ├── llm.py
│ ├── metadata.py
│ ├── prompts
│ ├── __init__.py
│ ├── compact.md
│ └── init.md
│ ├── py.typed
│ ├── share.py
│ ├── soul
│ ├── __init__.py
│ ├── agent.py
│ ├── approval.py
│ ├── compaction.py
│ ├── context.py
│ ├── denwarenji.py
│ ├── globals.py
│ ├── kimisoul.py
│ ├── message.py
│ └── toolset.py
│ ├── tools
│ ├── __init__.py
│ ├── bash
│ │ ├── __init__.py
│ │ └── bash.md
│ ├── dmail
│ │ ├── __init__.py
│ │ └── dmail.md
│ ├── file
│ │ ├── __init__.py
│ │ ├── glob.md
│ │ ├── glob.py
│ │ ├── grep.md
│ │ ├── grep.py
│ │ ├── patch.md
│ │ ├── patch.py
│ │ ├── read.md
│ │ ├── read.py
│ │ ├── replace.md
│ │ ├── replace.py
│ │ ├── write.md
│ │ └── write.py
│ ├── mcp.py
│ ├── task
│ │ ├── __init__.py
│ │ └── task.md
│ ├── test.py
│ ├── think
│ │ ├── __init__.py
│ │ └── think.md
│ ├── todo
│ │ ├── __init__.py
│ │ └── set_todo_list.md
│ ├── utils.py
│ └── web
│ │ ├── __init__.py
│ │ ├── fetch.md
│ │ ├── fetch.py
│ │ ├── search.md
│ │ └── search.py
│ ├── ui
│ ├── __init__.py
│ ├── acp
│ │ └── __init__.py
│ ├── print
│ │ └── __init__.py
│ └── shell
│ │ ├── __init__.py
│ │ ├── console.py
│ │ ├── debug.py
│ │ ├── keyboard.py
│ │ ├── liveview.py
│ │ ├── metacmd.py
│ │ ├── prompt.py
│ │ ├── setup.py
│ │ ├── update.py
│ │ └── visualize.py
│ ├── utils
│ ├── aiohttp.py
│ ├── changelog.py
│ ├── logging.py
│ ├── message.py
│ ├── path.py
│ ├── pyinstaller.py
│ └── string.py
│ └── wire
│ ├── __init__.py
│ └── message.py
├── tests
├── __init__.py
├── conftest.py
├── test_agent_spec.py
├── test_bash.py
├── test_changelog.py
├── test_config.py
├── test_default_agent.py
├── test_fetch_url.py
├── test_file_completer.py
├── test_glob.py
├── test_grep.py
├── test_load_agent.py
├── test_load_agents_md.py
├── test_message_utils.py
├── test_metacmd.py
├── test_patch_file.py
├── test_pyinstaller_utils.py
├── test_read_file.py
├── test_result_builder.py
├── test_soul_message.py
├── test_str_replace_file.py
├── test_task_subagents.py
├── test_tool_descriptions.py
├── test_tool_schemas.py
├── test_utils_path.py
└── test_write_file.py
└── uv.lock
/.github/ISSUE_TEMPLATE/1-bug-report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: Report an issue that should be fixed
3 | labels:
4 | - bug
5 | - needs triage
6 | body:
7 | - type: markdown
8 | attributes:
9 | value: |
10 | Thank you for submitting a bug report! It helps make Kimi CLI better for everyone.
11 |
12 | If you need help or support using Kimi CLI, and are not reporting a bug, please post on [kimi-cli/discussions](https://github.com/MoonshotAI/kimi-cli/discussions), where you can ask questions or engage with others on ideas for how to improve Kimi CLI.
13 |
14 | Make sure you are running the latest version of Kimi CLI (`uv tool upgrade kimi-cli` to upgrade). The bug you are experiencing may already have been fixed.
15 |
16 | Please try to include as much information as possible.
17 |
18 | - type: input
19 | id: version
20 | attributes:
21 | label: What version of Kimi CLI is running?
22 | description: Copy the output of `kimi --version` or `/version`
23 | validations:
24 | required: true
25 | - type: input
26 | id: plan
27 | attributes:
28 | label: Which open platform/subscription were you using?
29 | description: The one you selected when running `/setup`
30 | validations:
31 | required: true
32 | - type: input
33 | id: model
34 | attributes:
35 | label: Which model were you using?
36 | description: The one you selected when running `/setup`, like `kimi-k2-turbo-preview`, `kimi-for-coding`, etc.
37 | - type: input
38 | id: platform
39 | attributes:
40 | label: What platform is your computer?
41 | description: |
42 | For MacOS and Linux: copy the output of `uname -mprs`
43 | For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console
44 | - type: textarea
45 | id: actual
46 | attributes:
47 | label: What issue are you seeing?
48 | description: Please include the full error messages and prompts with any private information redacted. If possible, please provide text instead of a screenshot.
49 | validations:
50 | required: true
51 | - type: textarea
52 | id: steps
53 | attributes:
54 | label: What steps can reproduce the bug?
55 | description: Explain the bug and provide a code snippet that can reproduce it. Please include session id and context usage if applicable.
56 | validations:
57 | required: true
58 | - type: textarea
59 | id: expected
60 | attributes:
61 | label: What is the expected behavior?
62 | description: If possible, please provide text instead of a screenshot.
63 | - type: textarea
64 | id: notes
65 | attributes:
66 | label: Additional information
67 | description: Is there anything else you think we should know?
68 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/2-feature-request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Propose a new feature for Kimi CLI
3 | labels:
4 | - enhancement
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Is Kimi CLI missing a feature that you'd like to see? Feel free to propose it here.
10 |
11 | Before you submit a feature:
12 | 1. Search existing issues for similar features. If you find one, 👍 it rather than opening a new one.
13 | 2. The Kimi CLI team will try to balance the varying needs of the community when prioritizing or rejecting new features. Please understand that not all features will be accepted.
14 |
15 | - type: textarea
16 | id: feature
17 | attributes:
18 | label: What feature would you like to see?
19 | validations:
20 | required: true
21 | - type: textarea
22 | id: notes
23 | attributes:
24 | label: Additional information
25 | description: Is there anything else you think we should know?
26 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Questions and General Discussion
4 | url: https://github.com/MoonshotAI/kimi-cli/discussions
5 | about: Have questions? Welcome to open a discussion!
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "uv"
9 | directory: "/"
10 | schedule:
11 | interval: "daily"
12 |
--------------------------------------------------------------------------------
/.github/pr-title-checker-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "LABEL": {
3 | "name": "Invalid PR Title",
4 | "color": "B60205"
5 | },
6 | "CHECKS": {
7 | "regexp": "^(feat|fix|test|refactor|chore|style|docs|perf|build|ci|revert)(\\(.*\\))?:.*"
8 | },
9 | "MESSAGES": {
10 | "failure": "The PR title is invalid. Please refer to https://www.conventionalcommits.org/en/v1.0.0/ for the convention."
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | test:
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | include:
15 | - runner: ubuntu-22.04
16 | target: x86_64-unknown-linux-gnu
17 | # - runner: ubuntu-22.04-arm
18 | # target: aarch64-unknown-linux-gnu
19 | # - runner: macos-13
20 | # target: x86_64-apple-darwin
21 | # - runner: macos-14
22 | # target: aarch64-apple-darwin
23 | runs-on: ${{ matrix.runner }}
24 | steps:
25 | - name: Checkout repository
26 | uses: actions/checkout@v4
27 |
28 | - name: Set up Python 3.13
29 | uses: actions/setup-python@v5
30 | with:
31 | python-version: "3.13"
32 | allow-prereleases: true
33 |
34 | - name: Set up uv
35 | uses: astral-sh/setup-uv@v1
36 | with:
37 | version: "0.8.5"
38 |
39 | - name: Prepare building environment
40 | run: make prepare
41 |
42 | - name: Run checks
43 | run: make check
44 |
45 | - name: Run tests
46 | run: make test
47 |
48 | - name: Build standalone binary
49 | run: make build
50 |
51 | - name: Smoke test binary --help
52 | run: ./dist/kimi --help
53 |
54 | - name: Upload binary artifact
55 | if: success()
56 | uses: actions/upload-artifact@v4
57 | with:
58 | name: kimi-${{ matrix.target }}
59 | path: dist/kimi
60 | if-no-files-found: error
61 | retention-days: 7
62 |
--------------------------------------------------------------------------------
/.github/workflows/pr-title-checker.yml:
--------------------------------------------------------------------------------
1 | name: PR Title Checker
2 |
3 | on:
4 | pull_request:
5 | types: [opened, edited, labeled]
6 |
7 | jobs:
8 | check:
9 | runs-on: ubuntu-latest
10 | name: pr-title-checker
11 | steps:
12 | - uses: thehanimo/pr-title-checker@v1.4.3
13 | with:
14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
15 | configuration_path: ".github/pr-title-checker-config.json"
16 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: write
11 |
12 | jobs:
13 | build:
14 | name: Build binaries (${{ matrix.target }})
15 | strategy:
16 | fail-fast: false
17 | matrix:
18 | include:
19 | - runner: ubuntu-22.04
20 | target: x86_64-unknown-linux-gnu
21 | # - runner: ubuntu-22.04-arm
22 | # target: aarch64-unknown-linux-gnu
23 | - runner: macos-13
24 | target: x86_64-apple-darwin
25 | - runner: macos-14
26 | target: aarch64-apple-darwin
27 | runs-on: ${{ matrix.runner }}
28 | steps:
29 | - name: Checkout repository
30 | uses: actions/checkout@v4
31 |
32 | - name: Set up Python 3.13
33 | uses: actions/setup-python@v5
34 | with:
35 | python-version: "3.13"
36 | allow-prereleases: true
37 |
38 | - name: Set up uv
39 | uses: astral-sh/setup-uv@v1
40 | with:
41 | version: "0.8.5"
42 |
43 | - name: Prepare building environment
44 | run: make prepare
45 |
46 | - name: Build standalone binary
47 | run: make build
48 |
49 | - name: Package artifact
50 | shell: bash
51 | env:
52 | TAG: ${{ github.ref_name }}
53 | run: |
54 | set -euxo pipefail
55 | mkdir -p artifacts
56 | # package the binary as tar.gz; keep filename with target and tag
57 | cp dist/kimi artifacts/kimi
58 | FILENAME="kimi-${TAG}-${{ matrix.target }}.tar.gz"
59 | tar -C artifacts -czf "artifacts/${FILENAME}" kimi
60 | rm -f artifacts/kimi
61 | echo "Built artifact: artifacts/${FILENAME}"
62 |
63 | - name: Upload artifact
64 | uses: actions/upload-artifact@v4
65 | env:
66 | TAG: ${{ github.ref_name }}
67 | with:
68 | name: kimi-${TAG}-${{ matrix.target }}
69 | path: artifacts/*.tar.gz
70 | if-no-files-found: error
71 | retention-days: 7
72 |
73 | release:
74 | name: Publish GitHub Release
75 | needs: build
76 | runs-on: ubuntu-latest
77 | steps:
78 | - name: Download all build artifacts
79 | uses: actions/download-artifact@v4
80 | with:
81 | path: ./downloads
82 | merge-multiple: true
83 |
84 | - name: Show downloaded files
85 | run: ls -laR downloads
86 |
87 | - name: Generate per-file SHA256 sums
88 | shell: bash
89 | run: |
90 | set -euxo pipefail
91 | cd downloads
92 | for f in *.tar.gz; do
93 | sha256sum "$f" > "$f.sha256"
94 | echo "sha256($(basename "$f"))=$(cut -d' ' -f1 "$f.sha256")"
95 | done
96 | ls -la
97 |
98 | - name: Create GitHub Release and upload assets
99 | uses: softprops/action-gh-release@v2
100 | with:
101 | tag_name: ${{ github.ref_name }}
102 | name: ${{ github.ref_name }}
103 | generate_release_notes: true
104 | files: |
105 | downloads/*.tar.gz
106 | downloads/*.tar.gz.sha256
107 | env:
108 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
109 |
110 | publish-python:
111 | name: Publish Python package
112 | runs-on: ubuntu-latest
113 | steps:
114 | - name: Checkout repository
115 | uses: actions/checkout@v4
116 |
117 | - name: Set up Python 3.13
118 | uses: actions/setup-python@v5
119 | with:
120 | python-version: "3.13"
121 | allow-prereleases: true
122 |
123 | - name: Set up uv
124 | uses: astral-sh/setup-uv@v1
125 | with:
126 | version: "0.8.5"
127 |
128 | - name: Build distributions
129 | run: uv build
130 |
131 | - name: Publish to PyPI
132 | uses: pypa/gh-action-pypi-publish@release/v1
133 | with:
134 | user: __token__
135 | password: ${{ secrets.PYPI_API_TOKEN }}
136 | packages-dir: dist
137 |
--------------------------------------------------------------------------------
/.github/workflows/translator.yml:
--------------------------------------------------------------------------------
1 | name: "Translator"
2 | on:
3 | issues:
4 | types: [opened, edited]
5 | issue_comment:
6 | types: [created, edited]
7 | discussion:
8 | types: [created, edited]
9 | discussion_comment:
10 | types: [created, edited]
11 | pull_request_target:
12 | types: [opened, edited]
13 | pull_request_review_comment:
14 | types: [created, edited]
15 |
16 | jobs:
17 | translate:
18 | permissions:
19 | issues: write
20 | discussions: write
21 | pull-requests: write
22 | runs-on: ubuntu-latest
23 | steps:
24 | - uses: lizheming/github-translate-action@1.1.2
25 | env:
26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27 | with:
28 | IS_MODIFY_TITLE: true
29 | APPEND_TRANSLATION: true
30 |
--------------------------------------------------------------------------------
/.github/workflows/typos.yml:
--------------------------------------------------------------------------------
1 | name: Typo checker
2 | on: [pull_request]
3 |
4 | jobs:
5 | run:
6 | name: Spell Check with Typos
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Checkout Actions Repository
10 | uses: actions/checkout@v4
11 |
12 | - name: Check spelling of the entire repository
13 | uses: crate-ci/typos@v1.38.1
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Python-generated files
2 | __pycache__/
3 | *.py[oc]
4 | build/
5 | dist/
6 | wheels/
7 | *.egg-info
8 |
9 | # Virtual environments
10 | .venv
11 |
12 | # Project files
13 | .vscode
14 | .env
15 | .env.local
16 | /tests_local
17 | uv.toml
18 | .idea/*
19 |
20 | # Build dependencies
21 | src/kimi_cli/deps/bin
22 | src/kimi_cli/deps/tmp
23 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.13
2 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Kimi CLI
2 |
3 | Thank you for being interested in contributing to Kimi CLI!
4 |
5 | We welcome all kinds of contributions, including bug fixes, features, document improvements, typo fixes, etc. However, as a rapidly evolving project, we highly recommend [raising an issue](https://github.com/MoonshotAI/kimi-cli/issues) to discuss about it before you start to work on something that may have significant impacts on the codebase or user experience. This kind of discussion can avoid conflicts between the community contributions and our internal roadmap. Thank you for your understanding!
6 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | RUFF := $(shell command -v ruff 2> /dev/null || echo "uv run ruff")
2 | PYRIGHT := $(shell command -v pyright 2> /dev/null || echo "uv run pyright")
3 |
4 | .DEFAULT_GOAL := prepare
5 |
6 | .PHONY: help
7 | help: ## Show available make targets.
8 | @echo "Available make targets:"
9 | @awk 'BEGIN { FS = ":.*## " } /^[A-Za-z0-9_.-]+:.*## / { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
10 |
11 | .PHONY: prepare
12 | prepare: download-deps ## Sync dependencies using locked versions.
13 | uv sync --frozen
14 |
15 | .PHONY: format
16 | format: ## Auto-format Python sources with ruff.
17 | uv run ruff check --fix
18 | uv run ruff format
19 |
20 | .PHONY: check
21 | check: ## Run linting and type checks.
22 | uv run ruff check
23 | uv run ruff format --check
24 | uv run pyright
25 |
26 |
27 | .PHONY: test
28 | test: ## Run the test suite with pytest.
29 | uv run pytest tests -vv
30 |
31 | .PHONY: build
32 | build: ## Build the standalone executable with PyInstaller.
33 | uv run pyinstaller kimi.spec
34 |
35 | include src/kimi_cli/deps/Makefile
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Kimi CLI
2 |
3 | [](https://github.com/MoonshotAI/kimi-cli/graphs/commit-activity)
4 | [](https://github.com/MoonshotAI/kimi-cli/actions)
5 | [](https://pypi.org/project/kimi-cli/)
6 | [](https://pypistats.org/packages/kimi-cli)
7 |
8 | [中文](https://www.kimi.com/coding/docs/kimi-cli.html)
9 |
10 | Kimi CLI is a new CLI agent that can help you with your software development tasks and terminal operations.
11 |
12 | > [!IMPORTANT]
13 | > Kimi CLI is currently in technical preview.
14 |
15 | ## Key features
16 |
17 | - Shell-like UI and raw shell command execution
18 | - Zsh integration
19 | - [Agent Client Protocol] support
20 | - MCP support
21 | - And more to come...
22 |
23 | [Agent Client Protocol]: https://github.com/agentclientprotocol/agent-client-protocol
24 |
25 | ## Installation
26 |
27 | > [!IMPORTANT]
28 | > Kimi CLI currently only supports macOS and Linux. Windows support is coming soon.
29 |
30 | Kimi CLI is published as a Python package on PyPI. We highly recommend installing it with [uv](https://docs.astral.sh/uv/). If you have not installed uv yet, please follow the instructions [here](https://docs.astral.sh/uv/getting-started/installation/) to install it first.
31 |
32 | Once uv is installed, you can install Kimi CLI with:
33 |
34 | ```sh
35 | uv tool install --python 3.13 kimi-cli
36 | ```
37 |
38 | Run `kimi --help` to check if Kimi CLI is installed successfully.
39 |
40 | > [!IMPORTANT]
41 | > Due to the security checks on macOS, the first time you run `kimi` command may take 10 seconds or more depending on your system environment.
42 |
43 | ## Upgrading
44 |
45 | Upgrade Kimi CLI to the latest version with:
46 |
47 | ```sh
48 | uv tool upgrade kimi-cli --no-cache
49 | ```
50 |
51 | ## Usage
52 |
53 | Run `kimi` command in the directory you want to work on, then send `/setup` to setup Kimi CLI:
54 |
55 | 
56 |
57 | After setup, Kimi CLI will be ready to use. You can send `/help` to get more information.
58 |
59 | ## Features
60 |
61 | ### Shell mode
62 |
63 | Kimi CLI is not only a coding agent, but also a shell. You can switch the mode by pressing `Ctrl-K`. In shell mode, you can directly run shell commands without leaving Kimi CLI.
64 |
65 | > [!NOTE]
66 | > Built-in shell commands like `cd` are not supported yet.
67 |
68 | ### Zsh integration
69 |
70 | You can use Kimi CLI together with Zsh, to empower your shell experience with AI agent capabilities.
71 |
72 | Install the [zsh-kimi-cli](https://github.com/MoonshotAI/zsh-kimi-cli) plugin via:
73 |
74 | ```sh
75 | git clone https://github.com/MoonshotAI/zsh-kimi-cli.git \
76 | ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/kimi-cli
77 | ```
78 |
79 | > [!NOTE]
80 | > If you are using a plugin manager other than Oh My Zsh, you may need to refer to the plugin's README for installation instructions.
81 |
82 | Then add `kimi-cli` to your Zsh plugin list in `~/.zshrc`:
83 |
84 | ```sh
85 | plugins=(... kimi-cli)
86 | ```
87 |
88 | After restarting Zsh, you can switch to agent mode by pressing `Ctrl-K`.
89 |
90 | ### ACP support
91 |
92 | Kimi CLI supports [Agent Client Protocol] out of the box. You can use it together with any ACP-compatible editor or IDE.
93 |
94 | For example, to use Kimi CLI with [Zed](https://zed.dev/), add the following configuration to your `~/.config/zed/settings.json`:
95 |
96 | ```json
97 | {
98 | "agent_servers": {
99 | "Kimi CLI": {
100 | "command": "kimi",
101 | "args": ["--acp"],
102 | "env": {}
103 | }
104 | }
105 | }
106 | ```
107 |
108 | Then you can create Kimi CLI threads in Zed's agent panel.
109 |
110 | ### Using MCP tools
111 |
112 | Kimi CLI supports the well-established MCP config convention. For example:
113 |
114 | ```json
115 | {
116 | "mcpServers": {
117 | "context7": {
118 | "url": "https://mcp.context7.com/mcp",
119 | "headers": {
120 | "CONTEXT7_API_KEY": "YOUR_API_KEY"
121 | }
122 | },
123 | "chrome-devtools": {
124 | "command": "npx",
125 | "args": ["-y", "chrome-devtools-mcp@latest"]
126 | }
127 | }
128 | }
129 | ```
130 |
131 | Run `kimi` with `--mcp-config-file` option to connect to the specified MCP servers:
132 |
133 | ```sh
134 | kimi --mcp-config-file /path/to/mcp.json
135 | ```
136 |
137 | ## Development
138 |
139 | To develop Kimi CLI, run:
140 |
141 | ```sh
142 | git clone https://github.com/MoonshotAI/kimi-cli.git
143 | cd kimi-cli
144 |
145 | make prepare # prepare the development environment
146 | ```
147 |
148 | Then you can start working on Kimi CLI.
149 |
150 | Refer to the following commands after you make changes:
151 |
152 | ```sh
153 | uv run kimi # run Kimi CLI
154 |
155 | make format # format code
156 | make check # run linting and type checking
157 | make test # run tests
158 | make help # show all make targets
159 | ```
160 |
161 | ## Contributing
162 |
163 | We welcome contributions to Kimi CLI! Please refer to [CONTRIBUTING.md](./CONTRIBUTING.md) for more information.
164 |
--------------------------------------------------------------------------------
/docs/images/setup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonshotAI/kimi-cli/78aae8e00b3848a0af66d8138450ba78091ab3c6/docs/images/setup.png
--------------------------------------------------------------------------------
/kimi.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 |
3 | from kimi_cli.utils.pyinstaller import datas, hiddenimports
4 |
5 | a = Analysis(
6 | ["src/kimi_cli/__init__.py"],
7 | pathex=[],
8 | binaries=[],
9 | datas=datas,
10 | hiddenimports=hiddenimports,
11 | hookspath=[],
12 | hooksconfig={},
13 | runtime_hooks=[],
14 | excludes=[],
15 | noarchive=False,
16 | optimize=0,
17 | )
18 | pyz = PYZ(a.pure)
19 |
20 | exe = EXE(
21 | pyz,
22 | a.scripts,
23 | a.binaries,
24 | a.datas,
25 | [],
26 | name="kimi",
27 | debug=False,
28 | bootloader_ignore_signals=False,
29 | strip=False,
30 | upx=True,
31 | upx_exclude=[],
32 | runtime_tmpdir=None,
33 | console=True,
34 | disable_windowed_traceback=False,
35 | argv_emulation=False,
36 | target_arch=None,
37 | codesign_identity=None,
38 | entitlements_file=None,
39 | )
40 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "kimi-cli"
3 | version = "0.41"
4 | description = "Kimi CLI is your next CLI agent."
5 | readme = "README.md"
6 | requires-python = ">=3.13"
7 | dependencies = [
8 | "agent-client-protocol>=0.4.9",
9 | "aiofiles>=25.1.0",
10 | "aiohttp>=3.13.1",
11 | "click>=8.3.0",
12 | "kosong>=0.15.0",
13 | "loguru>=0.7.3",
14 | "patch-ng>=1.19.0",
15 | "prompt-toolkit>=3.0.52",
16 | "pyyaml>=6.0.3",
17 | "rich>=14.2.0",
18 | "ripgrepy>=2.2.0",
19 | "streamingjson>=0.0.5",
20 | "trafilatura>=2.0.0",
21 | "tenacity>=9.1.2",
22 | "fastmcp>=2.12.5",
23 | "pydantic>=2.12.3",
24 | "httpx[socks]>=0.28.0",
25 | ]
26 |
27 | [dependency-groups]
28 | dev = [
29 | "inline-snapshot[black]>=0.30.1",
30 | "pyinstaller>=6.16.0",
31 | "pyright>=1.1.407",
32 | "pytest>=8.4.2",
33 | "pytest-asyncio>=1.2.0",
34 | "ruff>=0.14.1",
35 | ]
36 |
37 | [build-system]
38 | requires = ["uv_build>=0.8.5,<0.9.0"]
39 | build-backend = "uv_build"
40 |
41 | [tool.uv.build-backend]
42 | module-name = ["kimi_cli"]
43 | source-exclude = ["tests/**/*", "src/kimi_cli/deps/**/*"]
44 |
45 | [project.scripts]
46 | kimi = "kimi_cli.cli:main"
47 |
48 | [tool.ruff]
49 | line-length = 100
50 |
51 | [tool.ruff.lint]
52 | select = [
53 | "E", # pycodestyle
54 | "F", # Pyflakes
55 | "UP", # pyupgrade
56 | "B", # flake8-bugbear
57 | "SIM", # flake8-simplify
58 | "I", # isort
59 | ]
60 |
61 | [tool.typos.files]
62 | extend-exclude = ["kimi.spec", "pyinstaller.py"]
63 |
64 | [tool.typos.default.extend-words]
65 | datas = "datas"
66 |
--------------------------------------------------------------------------------
/pyrightconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "typeCheckingMode": "standard",
3 | "include": [
4 | "src/**/*.py",
5 | "tests/**/*.py"
6 | ],
7 | "exclude": [
8 | "**/__pycache__/**/*.py",
9 | ],
10 | "strict": [
11 | "src/kimi_cli/prompts",
12 | "src/kimi_cli/soul",
13 | // "src/kimi_cli/tools",
14 | // "src/kimi_cli/ui/acp",
15 | "src/kimi_cli/ui/print",
16 | // "src/kimi_cli/ui/shell",
17 | "src/kimi_cli/utils",
18 | "src/kimi_cli/wire",
19 | "src/kimi_cli/*.py",
20 | ]
21 | }
--------------------------------------------------------------------------------
/src/kimi_cli/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ../../CHANGELOG.md
--------------------------------------------------------------------------------
/src/kimi_cli/__init__.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import os
3 | import warnings
4 | from pathlib import Path
5 | from typing import Any, Literal
6 |
7 | import click
8 | from pydantic import SecretStr
9 |
10 | from kimi_cli.agentspec import DEFAULT_AGENT_FILE
11 | from kimi_cli.config import Config, LLMModel, LLMProvider
12 | from kimi_cli.llm import augment_provider_with_env_vars, create_llm
13 | from kimi_cli.metadata import Session
14 | from kimi_cli.soul.agent import load_agent_with_mcp
15 | from kimi_cli.soul.context import Context
16 | from kimi_cli.soul.globals import AgentGlobals
17 | from kimi_cli.soul.kimisoul import KimiSoul
18 | from kimi_cli.ui.acp import ACPServer
19 | from kimi_cli.ui.print import InputFormat, OutputFormat, PrintApp
20 | from kimi_cli.ui.shell import ShellApp
21 | from kimi_cli.utils.logging import StreamToLogger, logger
22 |
23 | UIMode = Literal["shell", "print", "acp"]
24 |
25 |
26 | async def kimi_run(
27 | *,
28 | config: Config,
29 | model_name: str | None,
30 | work_dir: Path,
31 | session: Session,
32 | command: str | None = None,
33 | agent_file: Path = DEFAULT_AGENT_FILE,
34 | ui: UIMode = "shell",
35 | input_format: InputFormat | None = None,
36 | output_format: OutputFormat | None = None,
37 | mcp_configs: list[dict[str, Any]] | None = None,
38 | yolo: bool = False,
39 | ) -> bool:
40 | """Run Kimi CLI."""
41 | model: LLMModel | None = None
42 | provider: LLMProvider | None = None
43 |
44 | # try to use config file
45 | if not model_name and config.default_model:
46 | # no --model specified && default model is set in config
47 | model = config.models[config.default_model]
48 | provider = config.providers[model.provider]
49 | if model_name and model_name in config.models:
50 | # --model specified && model is set in config
51 | model = config.models[model_name]
52 | provider = config.providers[model.provider]
53 |
54 | if not model:
55 | model = LLMModel(provider="", model="", max_context_size=100_000)
56 | provider = LLMProvider(type="kimi", base_url="", api_key=SecretStr(""))
57 |
58 | # try overwrite with environment variables
59 | assert provider is not None
60 | assert model is not None
61 | augment_provider_with_env_vars(provider, model)
62 |
63 | if not provider.base_url or not model.model:
64 | llm = None
65 | else:
66 | logger.info("Using LLM provider: {provider}", provider=provider)
67 | logger.info("Using LLM model: {model}", model=model)
68 | stream = ui != "print" # use non-streaming mode only for print UI
69 | llm = create_llm(provider, model, stream=stream, session_id=session.id)
70 |
71 | yolo = yolo or (ui == "print") # print mode implies yolo
72 | agent_globals = await AgentGlobals.create(config, llm, session, yolo)
73 | try:
74 | agent = await load_agent_with_mcp(agent_file, agent_globals, mcp_configs or [])
75 | except ValueError as e:
76 | raise click.BadParameter(f"Failed to load agent: {e}") from e
77 |
78 | if command is not None:
79 | command = command.strip()
80 | if not command:
81 | raise click.BadParameter("Command cannot be empty")
82 |
83 | context = Context(session.history_file)
84 | await context.restore()
85 |
86 | soul = KimiSoul(
87 | agent,
88 | agent_globals,
89 | context=context,
90 | loop_control=config.loop_control,
91 | )
92 |
93 | original_cwd = Path.cwd()
94 | os.chdir(work_dir)
95 |
96 | try:
97 | if ui == "shell":
98 | app = ShellApp(
99 | soul,
100 | welcome_info={
101 | "Directory": str(work_dir),
102 | "Session": session.id,
103 | },
104 | )
105 | # to ignore possible warnings from dateparser
106 | warnings.filterwarnings("ignore", category=DeprecationWarning)
107 | with contextlib.redirect_stderr(StreamToLogger()):
108 | return await app.run(command)
109 | elif ui == "print":
110 | app = PrintApp(
111 | soul,
112 | input_format or "text",
113 | output_format or "text",
114 | context.file_backend,
115 | )
116 | return await app.run(command)
117 | elif ui == "acp":
118 | if command is not None:
119 | logger.warning("ACP server ignores command argument")
120 | app = ACPServer(soul)
121 | return await app.run()
122 | else:
123 | raise click.BadParameter(f"Invalid UI mode: {ui}")
124 | finally:
125 | os.chdir(original_cwd)
126 |
--------------------------------------------------------------------------------
/src/kimi_cli/agents/koder/README.md:
--------------------------------------------------------------------------------
1 | # Kimi Koder
2 |
3 | Kimi Koder is a CLI agent that can help you with your software development tasks.
4 |
--------------------------------------------------------------------------------
/src/kimi_cli/agents/koder/agent.yaml:
--------------------------------------------------------------------------------
1 | version: 1
2 | agent:
3 | name: ""
4 | system_prompt_path: ./system.md
5 | system_prompt_args:
6 | ROLE_ADDITIONAL: ""
7 | tools:
8 | - "kimi_cli.tools.task:Task"
9 | # - "kimi_cli.tools.dmail:SendDMail"
10 | - "kimi_cli.tools.think:Think"
11 | - "kimi_cli.tools.todo:SetTodoList"
12 | - "kimi_cli.tools.bash:Bash"
13 | - "kimi_cli.tools.file:ReadFile"
14 | - "kimi_cli.tools.file:Glob"
15 | - "kimi_cli.tools.file:Grep"
16 | - "kimi_cli.tools.file:WriteFile"
17 | - "kimi_cli.tools.file:StrReplaceFile"
18 | # - "kimi_cli.tools.file:PatchFile"
19 | - "kimi_cli.tools.web:SearchWeb"
20 | - "kimi_cli.tools.web:FetchURL"
21 | subagents:
22 | koder:
23 | path: ./sub.yaml
24 | description: "Good at general software engineering tasks."
25 |
--------------------------------------------------------------------------------
/src/kimi_cli/agents/koder/sub.yaml:
--------------------------------------------------------------------------------
1 | version: 1
2 | agent:
3 | extend: ./agent.yaml
4 | system_prompt_args:
5 | ROLE_ADDITIONAL: |
6 | You are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You need to provide a comprehensive summary on what you have done and learned in your final message. If you wrote or modified any files, you must mention them in the summary.
7 | exclude_tools:
8 | - "kimi_cli.tools.task:Task"
9 | - "kimi_cli.tools.dmail:SendDMail"
10 | - "kimi_cli.tools.todo:SetTodoList"
11 | subagents: # make sure no subagents are provided
12 |
--------------------------------------------------------------------------------
/src/kimi_cli/agents/koder/system.md:
--------------------------------------------------------------------------------
1 | You are Kimi CLI. You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.
2 |
3 | ${ROLE_ADDITIONAL}
4 |
5 | # Prompt and Tool Use
6 |
7 | The user's requests are provided in natural language within `user` messages, which may contain code snippets, logs, file paths, or specific requirements. ALWAYS follow the user's requests, always stay on track. Do not do anything that is not asked.
8 |
9 | When handling the user's request, you can call available tools to accomplish the task. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools.
10 |
11 | You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance.
12 |
13 | The results of the tool calls will be returned to you in a `tool` message. In some cases, non-plain-text content might be sent as a `user` message following the `tool` message. You must decide on your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information.
14 |
15 | The system may, where appropriate, insert hints or information wrapped in `` and `` tags within `user` or `tool` messages. This information is relevant to the current task or tool calls, may or may not be important to you. Take this info into consideration when determining your next action.
16 |
17 | When responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise.
18 |
19 | # General Coding Guidelines
20 |
21 | Always think carefully. Be patient and thorough. Do not give up too early.
22 |
23 | ALWAYS, keep it stupidly simple. Do not overcomplicate things.
24 |
25 | When building something from scratch, you should:
26 |
27 | - Understand the user's requirements.
28 | - Design the architecture and make a plan for the implementation.
29 | - Write the code in a modular and maintainable way.
30 |
31 | When working on existing codebase, you should:
32 |
33 | - Understand the codebase and the user's requirements. Identify the ultimate goal and the most important criteria to achieve the goal.
34 | - For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes.
35 | - For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests.
36 | - For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes.
37 | - Make MINIMAL changes to achieve the goal. This is very important to your performance.
38 | - Follow the coding style of existing code in the project.
39 |
40 | # Working Environment
41 |
42 | ## Operating System
43 |
44 | The operating environment is not in a sandbox. Any action especially mutation you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory.
45 |
46 | ## Working Directory
47 |
48 | The current working directory is `${KIMI_WORK_DIR}`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, if so, you should strictly follow the requirements.
49 |
50 | The directory listing of current working directory is:
51 |
52 | ```
53 | ${KIMI_WORK_DIR_LS}
54 | ```
55 |
56 | Use this as your basic understanding of the project structure.
57 |
58 | ## Date and Time
59 |
60 | The current date and time in ISO format is `${KIMI_NOW}`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Bash tool with proper command.
61 |
62 | # Project Information
63 |
64 | Markdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root. The following content between two `---`s is the content of the root-level `AGENTS.md` file.
65 |
66 | `${KIMI_WORK_DIR}/AGENTS.md`:
67 |
68 | ---
69 |
70 | ${KIMI_AGENTS_MD}
71 |
72 | ---
73 |
--------------------------------------------------------------------------------
/src/kimi_cli/agentspec.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import Any, NamedTuple
3 |
4 | import yaml
5 | from pydantic import BaseModel, Field
6 |
7 |
8 | def get_agents_dir() -> Path:
9 | return Path(__file__).parent / "agents"
10 |
11 |
12 | DEFAULT_AGENT_FILE = get_agents_dir() / "koder" / "agent.yaml"
13 |
14 |
15 | class AgentSpec(BaseModel):
16 | """Agent specification."""
17 |
18 | extend: str | None = Field(default=None, description="Agent file to extend")
19 | name: str | None = Field(default=None, description="Agent name") # required
20 | system_prompt_path: Path | None = Field(
21 | default=None, description="System prompt path"
22 | ) # required
23 | system_prompt_args: dict[str, str] = Field(
24 | default_factory=dict, description="System prompt arguments"
25 | )
26 | tools: list[str] | None = Field(default=None, description="Tools") # required
27 | exclude_tools: list[str] | None = Field(default=None, description="Tools to exclude")
28 | subagents: dict[str, "SubagentSpec"] | None = Field(default=None, description="Subagents")
29 |
30 |
31 | class SubagentSpec(BaseModel):
32 | """Subagent specification."""
33 |
34 | path: Path = Field(description="Subagent file path")
35 | description: str = Field(description="Subagent description")
36 |
37 |
38 | class ResolvedAgentSpec(NamedTuple):
39 | """Resolved agent specification."""
40 |
41 | name: str
42 | system_prompt_path: Path
43 | system_prompt_args: dict[str, str]
44 | tools: list[str]
45 | exclude_tools: list[str]
46 | subagents: dict[str, "SubagentSpec"]
47 |
48 |
49 | def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
50 | """Load agent specification from file."""
51 | agent_spec = _load_agent_spec(agent_file)
52 | assert agent_spec.extend is None, "agent extension should be recursively resolved"
53 | if agent_spec.name is None:
54 | raise ValueError("Agent name is required")
55 | if agent_spec.system_prompt_path is None:
56 | raise ValueError("System prompt path is required")
57 | if agent_spec.tools is None:
58 | raise ValueError("Tools are required")
59 | return ResolvedAgentSpec(
60 | name=agent_spec.name,
61 | system_prompt_path=agent_spec.system_prompt_path,
62 | system_prompt_args=agent_spec.system_prompt_args,
63 | tools=agent_spec.tools,
64 | exclude_tools=agent_spec.exclude_tools or [],
65 | subagents=agent_spec.subagents or {},
66 | )
67 |
68 |
69 | def _load_agent_spec(agent_file: Path) -> AgentSpec:
70 | assert agent_file.is_file(), "expect agent file to exist"
71 | with open(agent_file, encoding="utf-8") as f:
72 | data: dict[str, Any] = yaml.safe_load(f)
73 |
74 | version = data.get("version", 1)
75 | if version != 1:
76 | raise ValueError(f"Unsupported agent spec version: {version}")
77 |
78 | agent_spec = AgentSpec(**data.get("agent", {}))
79 | if agent_spec.system_prompt_path is not None:
80 | agent_spec.system_prompt_path = agent_file.parent / agent_spec.system_prompt_path
81 | if agent_spec.subagents is not None:
82 | for v in agent_spec.subagents.values():
83 | v.path = agent_file.parent / v.path
84 | if agent_spec.extend:
85 | if agent_spec.extend == "default":
86 | base_agent_file = DEFAULT_AGENT_FILE
87 | else:
88 | base_agent_file = agent_file.parent / agent_spec.extend
89 | base_agent_spec = _load_agent_spec(base_agent_file)
90 | if agent_spec.name is not None:
91 | base_agent_spec.name = agent_spec.name
92 | if agent_spec.system_prompt_path is not None:
93 | base_agent_spec.system_prompt_path = agent_spec.system_prompt_path
94 | for k, v in agent_spec.system_prompt_args.items():
95 | # system prompt args should be merged instead of overwritten
96 | base_agent_spec.system_prompt_args[k] = v
97 | if agent_spec.tools is not None:
98 | base_agent_spec.tools = agent_spec.tools
99 | if agent_spec.exclude_tools is not None:
100 | base_agent_spec.exclude_tools = agent_spec.exclude_tools
101 | if agent_spec.subagents is not None:
102 | base_agent_spec.subagents = agent_spec.subagents
103 | agent_spec = base_agent_spec
104 | return agent_spec
105 |
--------------------------------------------------------------------------------
/src/kimi_cli/config.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 | from typing import Literal, Self
4 |
5 | from pydantic import BaseModel, Field, SecretStr, ValidationError, field_serializer, model_validator
6 |
7 | from kimi_cli.share import get_share_dir
8 | from kimi_cli.utils.logging import logger
9 |
10 |
11 | class LLMProvider(BaseModel):
12 | """LLM provider configuration."""
13 |
14 | type: Literal["kimi", "openai_legacy", "_chaos"]
15 | """Provider type"""
16 | base_url: str
17 | """API base URL"""
18 | api_key: SecretStr
19 | """API key"""
20 |
21 | @field_serializer("api_key", when_used="json")
22 | def dump_secret(self, v: SecretStr):
23 | return v.get_secret_value()
24 |
25 |
26 | class LLMModel(BaseModel):
27 | """LLM model configuration."""
28 |
29 | provider: str
30 | """Provider name"""
31 | model: str
32 | """Model name"""
33 | max_context_size: int
34 | """Maximum context size (unit: tokens)"""
35 |
36 |
37 | class LoopControl(BaseModel):
38 | """Agent loop control configuration."""
39 |
40 | max_steps_per_run: int = 100
41 | """Maximum number of steps in one run"""
42 | max_retries_per_step: int = 3
43 | """Maximum number of retries in one step"""
44 |
45 |
46 | class MoonshotSearchConfig(BaseModel):
47 | """Moonshot Search configuration."""
48 |
49 | base_url: str
50 | """Base URL for Moonshot Search service."""
51 | api_key: SecretStr
52 | """API key for Moonshot Search service."""
53 |
54 | @field_serializer("api_key", when_used="json")
55 | def dump_secret(self, v: SecretStr):
56 | return v.get_secret_value()
57 |
58 |
59 | class Services(BaseModel):
60 | """Services configuration."""
61 |
62 | moonshot_search: MoonshotSearchConfig | None = None
63 | """Moonshot Search configuration."""
64 |
65 |
66 | class Config(BaseModel):
67 | """Main configuration structure."""
68 |
69 | default_model: str = Field(default="", description="Default model to use")
70 | models: dict[str, LLMModel] = Field(default_factory=dict, description="List of LLM models")
71 | providers: dict[str, LLMProvider] = Field(
72 | default_factory=dict, description="List of LLM providers"
73 | )
74 | loop_control: LoopControl = Field(default_factory=LoopControl, description="Agent loop control")
75 | services: Services = Field(default_factory=Services, description="Services configuration")
76 |
77 | @model_validator(mode="after")
78 | def validate_model(self) -> Self:
79 | if self.default_model and self.default_model not in self.models:
80 | raise ValueError(f"Default model {self.default_model} not found in models")
81 | for model in self.models.values():
82 | if model.provider not in self.providers:
83 | raise ValueError(f"Provider {model.provider} not found in providers")
84 | return self
85 |
86 |
87 | def get_config_file() -> Path:
88 | """Get the configuration file path."""
89 | return get_share_dir() / "config.json"
90 |
91 |
92 | def get_default_config() -> Config:
93 | """Get the default configuration."""
94 | return Config(
95 | default_model="",
96 | models={},
97 | providers={},
98 | services=Services(),
99 | )
100 |
101 |
102 | def load_config() -> Config:
103 | """Load configuration from config file.
104 |
105 | Returns:
106 | Validated Config object.
107 | """
108 | config_file = get_config_file()
109 | logger.debug("Loading config from file: {file}", file=config_file)
110 |
111 | if not config_file.exists():
112 | config = get_default_config()
113 | logger.debug("No config file found, creating default config: {config}", config=config)
114 | with open(config_file, "w", encoding="utf-8") as f:
115 | f.write(config.model_dump_json(indent=2, exclude_none=True))
116 | return config
117 |
118 | try:
119 | with open(config_file, encoding="utf-8") as f:
120 | data = json.load(f)
121 | return Config(**data)
122 | except (json.JSONDecodeError, ValidationError) as e:
123 | raise ConfigError(f"Invalid configuration file: {config_file}") from e
124 |
125 |
126 | class ConfigError(Exception):
127 | """Configuration error."""
128 |
129 | def __init__(self, message: str):
130 | super().__init__(message)
131 |
132 |
133 | def save_config(config: Config):
134 | """Save configuration to config file."""
135 | config_file = get_config_file()
136 | logger.debug("Saving config to file: {file}", file=config_file)
137 | with open(config_file, "w", encoding="utf-8") as f:
138 | f.write(config.model_dump_json(indent=2, exclude_none=True))
139 |
--------------------------------------------------------------------------------
/src/kimi_cli/constant.py:
--------------------------------------------------------------------------------
1 | import importlib.metadata
2 |
3 | VERSION = importlib.metadata.version("kimi-cli")
4 | USER_AGENT = f"KimiCLI/{VERSION}"
5 |
--------------------------------------------------------------------------------
/src/kimi_cli/deps/Makefile:
--------------------------------------------------------------------------------
1 | THIS_DIR := $(patsubst %/,%,$(dir $(lastword $(MAKEFILE_LIST))))
2 | BIN_DIR := $(THIS_DIR)/bin
3 | TMP_DIR := $(THIS_DIR)/tmp
4 |
5 | # Allow override via environment: RG_VERSION=15.0.0 make download-ripgrep
6 | RG_VERSION ?= 15.0.0
7 | OS := $(shell uname -s)
8 | ARCH := $(shell uname -m)
9 |
10 | # Map OS/ARCH to ripgrep TARGET name
11 | # See: https://github.com/BurntSushi/ripgrep/releases
12 | ifeq ($(OS),Darwin)
13 | ifeq ($(ARCH),arm64)
14 | RG_TARGET := aarch64-apple-darwin
15 | else ifeq ($(ARCH),x86_64)
16 | RG_TARGET := x86_64-apple-darwin
17 | else
18 | $(error Unsupported macOS architecture: $(ARCH))
19 | endif
20 | else ifeq ($(OS),Linux)
21 | ifeq ($(ARCH),x86_64)
22 | RG_TARGET := x86_64-unknown-linux-musl
23 | else ifeq ($(ARCH),aarch64)
24 | RG_TARGET := aarch64-unknown-linux-gnu
25 | else ifeq ($(ARCH),armv7l)
26 | RG_TARGET := arm-unknown-linux-gnueabihf
27 | else
28 | $(error Unsupported Linux architecture: $(ARCH))
29 | endif
30 | else
31 | $(error Unsupported OS: $(OS))
32 | endif
33 |
34 | RG_URL := https://github.com/BurntSushi/ripgrep/releases/download/$(RG_VERSION)/ripgrep-$(RG_VERSION)-$(RG_TARGET).tar.gz
35 | RG_TAR := ripgrep-$(RG_VERSION)-$(RG_TARGET).tar.gz
36 |
37 |
38 | .PHONY: download-ripgrep
39 | download-ripgrep:
40 | @if [ -f "$(BIN_DIR)/rg" ]; then \
41 | echo "rg already installed at $(BIN_DIR)/rg"; \
42 | else \
43 | echo "Downloading ripgrep $(RG_VERSION) from: $(RG_URL)"; \
44 | mkdir -p "$(BIN_DIR)" "$(TMP_DIR)"; \
45 | if command -v curl >/dev/null 2>&1; then \
46 | curl -L --fail -o "$(TMP_DIR)/$(RG_TAR)" "$(RG_URL)"; \
47 | else \
48 | if command -v wget >/dev/null 2>&1; then \
49 | wget -O "$(TMP_DIR)/$(RG_TAR)" "$(RG_URL)"; \
50 | else \
51 | echo "Error: neither curl nor wget is available" && exit 1; \
52 | fi; \
53 | fi; \
54 | tar -xzf "$(TMP_DIR)/$(RG_TAR)" -C "$(TMP_DIR)"; \
55 | cp "$(TMP_DIR)/ripgrep-$(RG_VERSION)-$(RG_TARGET)/rg" "$(BIN_DIR)/rg"; \
56 | chmod +x "$(BIN_DIR)/rg"; \
57 | echo "rg installed at $(BIN_DIR)/rg"; \
58 | fi
59 |
60 |
61 | .PHONY: download-deps
62 | download-deps: download-ripgrep
63 |
--------------------------------------------------------------------------------
/src/kimi_cli/llm.py:
--------------------------------------------------------------------------------
1 | import os
2 | from typing import NamedTuple
3 |
4 | from kosong.base.chat_provider import ChatProvider
5 | from kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig
6 | from kosong.chat_provider.kimi import Kimi
7 | from kosong.chat_provider.openai_legacy import OpenAILegacy
8 | from pydantic import SecretStr
9 |
10 | from kimi_cli.config import LLMModel, LLMProvider
11 | from kimi_cli.constant import USER_AGENT
12 |
13 |
14 | class LLM(NamedTuple):
15 | chat_provider: ChatProvider
16 | max_context_size: int
17 |
18 |
19 | def augment_provider_with_env_vars(provider: LLMProvider, model: LLMModel):
20 | match provider.type:
21 | case "kimi":
22 | if base_url := os.getenv("KIMI_BASE_URL"):
23 | provider.base_url = base_url
24 | if api_key := os.getenv("KIMI_API_KEY"):
25 | provider.api_key = SecretStr(api_key)
26 | if model_name := os.getenv("KIMI_MODEL_NAME"):
27 | model.model = model_name
28 | if max_context_size := os.getenv("KIMI_MODEL_MAX_CONTEXT_SIZE"):
29 | model.max_context_size = int(max_context_size)
30 | case "openai_legacy":
31 | if base_url := os.getenv("OPENAI_BASE_URL"):
32 | provider.base_url = base_url
33 | if api_key := os.getenv("OPENAI_API_KEY"):
34 | provider.api_key = SecretStr(api_key)
35 | case _:
36 | pass
37 |
38 |
39 | def create_llm(
40 | provider: LLMProvider,
41 | model: LLMModel,
42 | *,
43 | stream: bool = True,
44 | session_id: str | None = None,
45 | ) -> LLM:
46 | match provider.type:
47 | case "kimi":
48 | chat_provider = Kimi(
49 | model=model.model,
50 | base_url=provider.base_url,
51 | api_key=provider.api_key.get_secret_value(),
52 | stream=stream,
53 | default_headers={
54 | "User-Agent": USER_AGENT,
55 | },
56 | )
57 | if session_id:
58 | chat_provider = chat_provider.with_generation_kwargs(prompt_cache_key=session_id)
59 | case "openai_legacy":
60 | chat_provider = OpenAILegacy(
61 | model=model.model,
62 | base_url=provider.base_url,
63 | api_key=provider.api_key.get_secret_value(),
64 | stream=stream,
65 | )
66 | case "_chaos":
67 | chat_provider = ChaosChatProvider(
68 | model=model.model,
69 | base_url=provider.base_url,
70 | api_key=provider.api_key.get_secret_value(),
71 | chaos_config=ChaosConfig(
72 | error_probability=0.8,
73 | error_types=[429, 500, 503],
74 | ),
75 | )
76 |
77 | return LLM(chat_provider=chat_provider, max_context_size=model.max_context_size)
78 |
--------------------------------------------------------------------------------
/src/kimi_cli/metadata.py:
--------------------------------------------------------------------------------
1 | import json
2 | import uuid
3 | from hashlib import md5
4 | from pathlib import Path
5 | from typing import NamedTuple
6 |
7 | from pydantic import BaseModel, Field
8 |
9 | from kimi_cli.share import get_share_dir
10 | from kimi_cli.utils.logging import logger
11 |
12 |
13 | def get_metadata_file() -> Path:
14 | return get_share_dir() / "kimi.json"
15 |
16 |
17 | class WorkDirMeta(BaseModel):
18 | """Metadata for a work directory."""
19 |
20 | path: str
21 | """The full path of the work directory."""
22 |
23 | last_session_id: str | None = None
24 | """Last session ID of this work directory."""
25 |
26 | @property
27 | def sessions_dir(self) -> Path:
28 | path = get_share_dir() / "sessions" / md5(self.path.encode()).hexdigest()
29 | path.mkdir(parents=True, exist_ok=True)
30 | return path
31 |
32 |
33 | class Metadata(BaseModel):
34 | """Kimi metadata structure."""
35 |
36 | work_dirs: list[WorkDirMeta] = Field(
37 | default_factory=list[WorkDirMeta], description="Work directory list"
38 | )
39 |
40 |
41 | def _load_metadata() -> Metadata:
42 | metadata_file = get_metadata_file()
43 | logger.debug("Loading metadata from file: {file}", file=metadata_file)
44 | if not metadata_file.exists():
45 | logger.debug("No metadata file found, creating empty metadata")
46 | return Metadata()
47 | with open(metadata_file, encoding="utf-8") as f:
48 | data = json.load(f)
49 | return Metadata(**data)
50 |
51 |
52 | def _save_metadata(metadata: Metadata):
53 | metadata_file = get_metadata_file()
54 | logger.debug("Saving metadata to file: {file}", file=metadata_file)
55 | with open(metadata_file, "w", encoding="utf-8") as f:
56 | json.dump(metadata.model_dump(), f, indent=2, ensure_ascii=False)
57 |
58 |
59 | class Session(NamedTuple):
60 | """A session of a work directory."""
61 |
62 | id: str
63 | work_dir: WorkDirMeta
64 | history_file: Path
65 |
66 |
67 | def new_session(work_dir: Path, _history_file: Path | None = None) -> Session:
68 | """Create a new session for a work directory."""
69 | logger.debug("Creating new session for work directory: {work_dir}", work_dir=work_dir)
70 |
71 | metadata = _load_metadata()
72 | work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
73 | if work_dir_meta is None:
74 | work_dir_meta = WorkDirMeta(path=str(work_dir))
75 | metadata.work_dirs.append(work_dir_meta)
76 |
77 | session_id = str(uuid.uuid4())
78 | if _history_file is None:
79 | history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
80 | work_dir_meta.last_session_id = session_id
81 | else:
82 | logger.warning("Using provided history file: {history_file}", history_file=_history_file)
83 | _history_file.parent.mkdir(parents=True, exist_ok=True)
84 | if _history_file.exists():
85 | assert _history_file.is_file()
86 | history_file = _history_file
87 |
88 | if history_file.exists():
89 | # truncate if exists
90 | logger.warning(
91 | "History file already exists, truncating: {history_file}", history_file=history_file
92 | )
93 | history_file.unlink()
94 | history_file.touch()
95 |
96 | _save_metadata(metadata)
97 | return Session(id=session_id, work_dir=work_dir_meta, history_file=history_file)
98 |
99 |
100 | def continue_session(work_dir: Path) -> Session | None:
101 | """Get the last session for a work directory."""
102 | logger.debug("Continuing session for work directory: {work_dir}", work_dir=work_dir)
103 |
104 | metadata = _load_metadata()
105 | work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
106 | if work_dir_meta is None:
107 | logger.debug("Work directory never been used")
108 | return None
109 | if work_dir_meta.last_session_id is None:
110 | logger.debug("Work directory never had a session")
111 | return None
112 |
113 | logger.debug(
114 | "Found last session for work directory: {session_id}",
115 | session_id=work_dir_meta.last_session_id,
116 | )
117 | session_id = work_dir_meta.last_session_id
118 | history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
119 | return Session(id=session_id, work_dir=work_dir_meta, history_file=history_file)
120 |
--------------------------------------------------------------------------------
/src/kimi_cli/prompts/__init__.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | INIT = (Path(__file__).parent / "init.md").read_text(encoding="utf-8")
4 | COMPACT = (Path(__file__).parent / "compact.md").read_text(encoding="utf-8")
5 |
--------------------------------------------------------------------------------
/src/kimi_cli/prompts/compact.md:
--------------------------------------------------------------------------------
1 | You are tasked with compacting a coding conversation context. This is critical for maintaining an effective working memory for the coding agent.
2 |
3 | **Compression Priorities (in order):**
4 | 1. **Current Task State**: What is being worked on RIGHT NOW
5 | 2. **Errors & Solutions**: All encountered errors and their resolutions
6 | 3. **Code Evolution**: Final working versions only (remove intermediate attempts)
7 | 4. **System Context**: Project structure, dependencies, environment setup
8 | 5. **Design Decisions**: Architectural choices and their rationale
9 | 6. **TODO Items**: Unfinished tasks and known issues
10 |
11 | **Compression Rules:**
12 | - MUST KEEP: Error messages, stack traces, working solutions, current task
13 | - MERGE: Similar discussions into single summary points
14 | - REMOVE: Redundant explanations, failed attempts (keep lessons learned), verbose comments
15 | - CONDENSE: Long code blocks → keep signatures + key logic only
16 |
17 | **Special Handling:**
18 | - For code: Keep full version if < 20 lines, otherwise keep signature + key logic
19 | - For errors: Keep full error message + final solution
20 | - For discussions: Extract decisions and action items only
21 |
22 | **Input Context to Compress:**
23 |
24 | ${CONTEXT}
25 |
26 | **Required Output Structure:**
27 |
28 |
29 | [What we're working on now]
30 |
31 |
32 |
33 | - [Key setup/config points]
34 | - ...more...
35 |
36 |
37 |
38 | - [Task]: [Brief outcome]
39 | - ...more...
40 |
41 |
42 |
43 | - [Issue]: [Status/Next steps]
44 | - ...more...
45 |
46 |
47 |
48 |
49 |
50 | [filename]
51 |
52 | **Summary:**
53 | [What this code file does]
54 |
55 | **Key elements:**
56 | - [Important functions/classes]
57 | - ...more...
58 |
59 | **Latest version:**
60 | [Critical code snippets in this file]
61 |
62 |
63 |
64 | [filename]
65 | ...Similar as above...
66 |
67 |
68 | ...more files...
69 |
70 |
71 |
72 | - [Any crucial information not covered above]
73 | - ...more...
74 |
75 |
--------------------------------------------------------------------------------
/src/kimi_cli/prompts/init.md:
--------------------------------------------------------------------------------
1 | You are a software engineering expert with many years of programming experience. Please explore the current project directory to understand the project's architecture and main details.
2 |
3 | Task requirements:
4 | 1. Analyze the project structure and identify key configuration files (such as pyproject.toml, package.json, Cargo.toml, etc.).
5 | 2. Understand the project's technology stack, build process and runtime architecture.
6 | 3. Identify how the code is organized and main module divisions.
7 | 4. Discover project-specific development conventions, testing strategies, and deployment processes.
8 |
9 | After the exploration, you should do a thorough summary of your findings and overwrite it into `AGENTS.md` file in the project root. You need to refer to what is already in the file when you do so.
10 |
11 | For your information, `AGENTS.md` is a file intended to be read by AI coding agents. Expect the reader of this file know nothing about the project.
12 |
13 | You should compose this file according to the actual project content. Do not make any assumptions or generalizations. Ensure the information is accurate and useful. You must use the natural language that is mainly used in the project's comments and documentation.
14 |
15 | Popular sections that people usually write in `AGENTS.md` are:
16 |
17 | - Project overview
18 | - Build and test commands
19 | - Code style guidelines
20 | - Testing instructions
21 | - Security considerations
22 |
--------------------------------------------------------------------------------
/src/kimi_cli/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonshotAI/kimi-cli/78aae8e00b3848a0af66d8138450ba78091ab3c6/src/kimi_cli/py.typed
--------------------------------------------------------------------------------
/src/kimi_cli/share.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 |
4 | def get_share_dir() -> Path:
5 | """Get the share directory path."""
6 | share_dir = Path.home() / ".kimi"
7 | share_dir.mkdir(parents=True, exist_ok=True)
8 | return share_dir
9 |
--------------------------------------------------------------------------------
/src/kimi_cli/soul/__init__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import contextlib
3 | from collections.abc import Callable, Coroutine
4 | from contextvars import ContextVar
5 | from typing import Any, NamedTuple, Protocol, runtime_checkable
6 |
7 | from kimi_cli.utils.logging import logger
8 | from kimi_cli.wire import Wire, WireUISide
9 | from kimi_cli.wire.message import WireMessage
10 |
11 |
12 | class LLMNotSet(Exception):
13 | """Raised when the LLM is not set."""
14 |
15 | pass
16 |
17 |
18 | class MaxStepsReached(Exception):
19 | """Raised when the maximum number of steps is reached."""
20 |
21 | n_steps: int
22 | """The number of steps that have been taken."""
23 |
24 | def __init__(self, n_steps: int):
25 | self.n_steps = n_steps
26 |
27 |
28 | class StatusSnapshot(NamedTuple):
29 | context_usage: float
30 | """The usage of the context, in percentage."""
31 |
32 |
33 | @runtime_checkable
34 | class Soul(Protocol):
35 | @property
36 | def name(self) -> str:
37 | """The name of the soul."""
38 | ...
39 |
40 | @property
41 | def model(self) -> str:
42 | """The LLM model used by the soul. Empty string indicates no LLM configured."""
43 | ...
44 |
45 | @property
46 | def status(self) -> StatusSnapshot:
47 | """The current status of the soul. The returned value is immutable."""
48 | ...
49 |
50 | async def run(self, user_input: str):
51 | """
52 | Run the agent with the given user input until the max steps or no more tool calls.
53 |
54 | Args:
55 | user_input (str): The user input to the agent.
56 |
57 | Raises:
58 | LLMNotSet: When the LLM is not set.
59 | ChatProviderError: When the LLM provider returns an error.
60 | MaxStepsReached: When the maximum number of steps is reached.
61 | asyncio.CancelledError: When the run is cancelled by user.
62 | """
63 | ...
64 |
65 |
66 | type UILoopFn = Callable[[WireUISide], Coroutine[Any, Any, None]]
67 | """A long-running async function to visualize the agent behavior."""
68 |
69 |
70 | class RunCancelled(Exception):
71 | """The run was cancelled by the cancel event."""
72 |
73 |
74 | async def run_soul(
75 | soul: "Soul",
76 | user_input: str,
77 | ui_loop_fn: UILoopFn,
78 | cancel_event: asyncio.Event,
79 | ) -> None:
80 | """
81 | Run the soul with the given user input, connecting it to the UI loop with a wire.
82 |
83 | `cancel_event` is a outside handle that can be used to cancel the run. When the
84 | event is set, the run will be gracefully stopped and a `RunCancelled` will be raised.
85 |
86 | Raises:
87 | LLMNotSet: When the LLM is not set.
88 | ChatProviderError: When the LLM provider returns an error.
89 | MaxStepsReached: When the maximum number of steps is reached.
90 | RunCancelled: When the run is cancelled by the cancel event.
91 | """
92 | wire = Wire()
93 | wire_token = _current_wire.set(wire)
94 |
95 | logger.debug("Starting UI loop with function: {ui_loop_fn}", ui_loop_fn=ui_loop_fn)
96 | ui_task = asyncio.create_task(ui_loop_fn(wire.ui_side))
97 |
98 | logger.debug("Starting soul run")
99 | soul_task = asyncio.create_task(soul.run(user_input))
100 |
101 | cancel_event_task = asyncio.create_task(cancel_event.wait())
102 | await asyncio.wait(
103 | [soul_task, cancel_event_task],
104 | return_when=asyncio.FIRST_COMPLETED,
105 | )
106 |
107 | try:
108 | if cancel_event.is_set():
109 | logger.debug("Cancelling the run task")
110 | soul_task.cancel()
111 | try:
112 | await soul_task
113 | except asyncio.CancelledError:
114 | raise RunCancelled from None
115 | else:
116 | assert soul_task.done() # either stop event is set or the run task is done
117 | cancel_event_task.cancel()
118 | with contextlib.suppress(asyncio.CancelledError):
119 | await cancel_event_task
120 | soul_task.result() # this will raise if any exception was raised in the run task
121 | finally:
122 | logger.debug("Shutting down the UI loop")
123 | # shutting down the wire should break the UI loop
124 | wire.shutdown()
125 | try:
126 | await asyncio.wait_for(ui_task, timeout=0.5)
127 | except asyncio.QueueShutDown:
128 | # expected
129 | pass
130 | except TimeoutError:
131 | logger.warning("UI loop timed out")
132 |
133 | _current_wire.reset(wire_token)
134 |
135 |
136 | _current_wire = ContextVar[Wire | None]("current_wire", default=None)
137 |
138 |
139 | def get_wire_or_none() -> Wire | None:
140 | """
141 | Get the current wire or None.
142 | Expect to be not None when called from anywhere in the agent loop.
143 | """
144 | return _current_wire.get()
145 |
146 |
147 | def wire_send(msg: WireMessage) -> None:
148 | """
149 | Send a wire message to the current wire.
150 | Take this as `print` and `input` for souls.
151 | Souls should always use this function to send wire messages.
152 | """
153 | wire = get_wire_or_none()
154 | assert wire is not None, "Wire is expected to be set when soul is running"
155 | wire.soul_side.send(msg)
156 |
--------------------------------------------------------------------------------
/src/kimi_cli/soul/agent.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import inspect
3 | import string
4 | from pathlib import Path
5 | from typing import Any, NamedTuple
6 |
7 | import fastmcp
8 | from kosong.tooling import CallableTool, CallableTool2, Toolset
9 |
10 | from kimi_cli.agentspec import ResolvedAgentSpec, load_agent_spec
11 | from kimi_cli.config import Config
12 | from kimi_cli.metadata import Session
13 | from kimi_cli.soul.approval import Approval
14 | from kimi_cli.soul.denwarenji import DenwaRenji
15 | from kimi_cli.soul.globals import AgentGlobals, BuiltinSystemPromptArgs
16 | from kimi_cli.soul.toolset import CustomToolset
17 | from kimi_cli.tools.mcp import MCPTool
18 | from kimi_cli.utils.logging import logger
19 |
20 |
21 | class Agent(NamedTuple):
22 | """The loaded agent."""
23 |
24 | name: str
25 | system_prompt: str
26 | toolset: Toolset
27 |
28 |
29 | async def load_agent_with_mcp(
30 | agent_file: Path,
31 | globals_: AgentGlobals,
32 | mcp_configs: list[dict[str, Any]],
33 | ) -> Agent:
34 | agent = load_agent(agent_file, globals_)
35 | assert isinstance(agent.toolset, CustomToolset)
36 | if mcp_configs:
37 | await _load_mcp_tools(agent.toolset, mcp_configs)
38 | return agent
39 |
40 |
41 | def load_agent(
42 | agent_file: Path,
43 | globals_: AgentGlobals,
44 | ) -> Agent:
45 | """
46 | Load agent from specification file.
47 |
48 | Raises:
49 | ValueError: If the agent spec is not valid.
50 | """
51 | logger.info("Loading agent: {agent_file}", agent_file=agent_file)
52 | agent_spec = load_agent_spec(agent_file)
53 |
54 | system_prompt = _load_system_prompt(
55 | agent_spec.system_prompt_path,
56 | agent_spec.system_prompt_args,
57 | globals_.builtin_args,
58 | )
59 |
60 | tool_deps = {
61 | ResolvedAgentSpec: agent_spec,
62 | AgentGlobals: globals_,
63 | Config: globals_.config,
64 | BuiltinSystemPromptArgs: globals_.builtin_args,
65 | Session: globals_.session,
66 | DenwaRenji: globals_.denwa_renji,
67 | Approval: globals_.approval,
68 | }
69 | tools = agent_spec.tools
70 | if agent_spec.exclude_tools:
71 | logger.debug("Excluding tools: {tools}", tools=agent_spec.exclude_tools)
72 | tools = [tool for tool in tools if tool not in agent_spec.exclude_tools]
73 | toolset = CustomToolset()
74 | bad_tools = _load_tools(toolset, tools, tool_deps)
75 | if bad_tools:
76 | raise ValueError(f"Invalid tools: {bad_tools}")
77 |
78 | return Agent(
79 | name=agent_spec.name,
80 | system_prompt=system_prompt,
81 | toolset=toolset,
82 | )
83 |
84 |
85 | def _load_system_prompt(
86 | path: Path, args: dict[str, str], builtin_args: BuiltinSystemPromptArgs
87 | ) -> str:
88 | logger.info("Loading system prompt: {path}", path=path)
89 | system_prompt = path.read_text(encoding="utf-8").strip()
90 | logger.debug(
91 | "Substituting system prompt with builtin args: {builtin_args}, spec args: {spec_args}",
92 | builtin_args=builtin_args,
93 | spec_args=args,
94 | )
95 | return string.Template(system_prompt).substitute(builtin_args._asdict(), **args)
96 |
97 |
98 | type ToolType = CallableTool | CallableTool2[Any]
99 | # TODO: move this to kosong.tooling.simple
100 |
101 |
102 | def _load_tools(
103 | toolset: CustomToolset,
104 | tool_paths: list[str],
105 | dependencies: dict[type[Any], Any],
106 | ) -> list[str]:
107 | bad_tools: list[str] = []
108 | for tool_path in tool_paths:
109 | tool = _load_tool(tool_path, dependencies)
110 | if tool:
111 | toolset += tool
112 | else:
113 | bad_tools.append(tool_path)
114 | logger.info("Loaded tools: {tools}", tools=[tool.name for tool in toolset.tools])
115 | if bad_tools:
116 | logger.error("Bad tools: {bad_tools}", bad_tools=bad_tools)
117 | return bad_tools
118 |
119 |
120 | def _load_tool(tool_path: str, dependencies: dict[type[Any], Any]) -> ToolType | None:
121 | logger.debug("Loading tool: {tool_path}", tool_path=tool_path)
122 | module_name, class_name = tool_path.rsplit(":", 1)
123 | try:
124 | module = importlib.import_module(module_name)
125 | except ImportError:
126 | return None
127 | cls = getattr(module, class_name, None)
128 | if cls is None:
129 | return None
130 | args: list[type[Any]] = []
131 | for param in inspect.signature(cls).parameters.values():
132 | if param.kind == inspect.Parameter.KEYWORD_ONLY:
133 | # once we encounter a keyword-only parameter, we stop injecting dependencies
134 | break
135 | # all positional parameters should be dependencies to be injected
136 | if param.annotation not in dependencies:
137 | raise ValueError(f"Tool dependency not found: {param.annotation}")
138 | args.append(dependencies[param.annotation])
139 | return cls(*args)
140 |
141 |
142 | async def _load_mcp_tools(
143 | toolset: CustomToolset,
144 | mcp_configs: list[dict[str, Any]],
145 | ):
146 | """
147 | Raises:
148 | ValueError: If the MCP config is not valid.
149 | RuntimeError: If the MCP server cannot be connected.
150 | """
151 | for mcp_config in mcp_configs:
152 | logger.info("Loading MCP tools from: {mcp_config}", mcp_config=mcp_config)
153 | client = fastmcp.Client(mcp_config)
154 | async with client:
155 | for tool in await client.list_tools():
156 | toolset += MCPTool(tool, client)
157 | return toolset
158 |
--------------------------------------------------------------------------------
/src/kimi_cli/soul/approval.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from kimi_cli.soul.toolset import get_current_tool_call_or_none
4 | from kimi_cli.utils.logging import logger
5 | from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse
6 |
7 |
8 | class Approval:
9 | def __init__(self, yolo: bool = False):
10 | self._request_queue = asyncio.Queue[ApprovalRequest]()
11 | self._yolo = yolo
12 | self._auto_approve_actions: set[str] = set() # TODO: persist across sessions
13 | """Set of action names that should automatically be approved."""
14 |
15 | def set_yolo(self, yolo: bool) -> None:
16 | self._yolo = yolo
17 |
18 | async def request(self, sender: str, action: str, description: str) -> bool:
19 | """
20 | Request approval for the given action. Intended to be called by tools.
21 |
22 | Args:
23 | sender (str): The name of the sender.
24 | action (str): The action to request approval for.
25 | This is used to identify the action for auto-approval.
26 | description (str): The description of the action. This is used to display to the user.
27 |
28 | Returns:
29 | bool: True if the action is approved, False otherwise.
30 |
31 | Raises:
32 | RuntimeError: If the approval is requested from outside a tool call.
33 | """
34 | tool_call = get_current_tool_call_or_none()
35 | if tool_call is None:
36 | raise RuntimeError("Approval must be requested from a tool call.")
37 |
38 | logger.debug(
39 | "{tool_name} ({tool_call_id}) requesting approval: {action} {description}",
40 | tool_name=tool_call.function.name,
41 | tool_call_id=tool_call.id,
42 | action=action,
43 | description=description,
44 | )
45 | if self._yolo:
46 | return True
47 |
48 | if action in self._auto_approve_actions:
49 | return True
50 |
51 | request = ApprovalRequest(tool_call.id, sender, action, description)
52 | self._request_queue.put_nowait(request)
53 | response = await request.wait()
54 | logger.debug("Received approval response: {response}", response=response)
55 | match response:
56 | case ApprovalResponse.APPROVE:
57 | return True
58 | case ApprovalResponse.APPROVE_FOR_SESSION:
59 | self._auto_approve_actions.add(action)
60 | return True
61 | case ApprovalResponse.REJECT:
62 | return False
63 |
64 | async def fetch_request(self) -> ApprovalRequest:
65 | """
66 | Fetch an approval request from the queue. Intended to be called by the soul.
67 | """
68 | return await self._request_queue.get()
69 |
--------------------------------------------------------------------------------
/src/kimi_cli/soul/compaction.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Sequence
2 | from string import Template
3 | from typing import TYPE_CHECKING, Protocol, runtime_checkable
4 |
5 | from kosong.base import generate
6 | from kosong.base.message import ContentPart, Message, TextPart
7 |
8 | import kimi_cli.prompts as prompts
9 | from kimi_cli.llm import LLM
10 | from kimi_cli.soul.message import system
11 | from kimi_cli.utils.logging import logger
12 |
13 |
14 | @runtime_checkable
15 | class Compaction(Protocol):
16 | async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
17 | """
18 | Compact a sequence of messages into a new sequence of messages.
19 |
20 | Args:
21 | messages (Sequence[Message]): The messages to compact.
22 | llm (LLM): The LLM to use for compaction.
23 |
24 | Returns:
25 | Sequence[Message]: The compacted messages.
26 |
27 | Raises:
28 | ChatProviderError: When the chat provider returns an error.
29 | """
30 | ...
31 |
32 |
33 | class SimpleCompaction(Compaction):
34 | MAX_PRESERVED_MESSAGES = 2
35 |
36 | async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
37 | history = list(messages)
38 | if not history:
39 | return history
40 |
41 | preserve_start_index = len(history)
42 | n_preserved = 0
43 | for index in range(len(history) - 1, -1, -1):
44 | if history[index].role in {"user", "assistant"}:
45 | n_preserved += 1
46 | if n_preserved == self.MAX_PRESERVED_MESSAGES:
47 | preserve_start_index = index
48 | break
49 |
50 | if n_preserved < self.MAX_PRESERVED_MESSAGES:
51 | return history
52 |
53 | to_compact = history[:preserve_start_index]
54 | to_preserve = history[preserve_start_index:]
55 |
56 | if not to_compact:
57 | # Let's hope this won't exceed the context size limit
58 | return to_preserve
59 |
60 | # Convert history to string for the compact prompt
61 | history_text = "\n\n".join(
62 | f"## Message {i + 1}\nRole: {msg.role}\nContent: {msg.content}"
63 | for i, msg in enumerate(to_compact)
64 | )
65 |
66 | # Build the compact prompt using string template
67 | compact_template = Template(prompts.COMPACT)
68 | compact_prompt = compact_template.substitute(CONTEXT=history_text)
69 |
70 | # Create input message for compaction
71 | compact_message = Message(role="user", content=compact_prompt)
72 |
73 | # Call generate to get the compacted context
74 | # TODO: set max completion tokens
75 | logger.debug("Compacting context...")
76 | compacted_msg, usage = await generate(
77 | chat_provider=llm.chat_provider,
78 | system_prompt="You are a helpful assistant that compacts conversation context.",
79 | tools=[],
80 | history=[compact_message],
81 | )
82 | if usage:
83 | logger.debug(
84 | "Compaction used {input} input tokens and {output} output tokens",
85 | input=usage.input,
86 | output=usage.output,
87 | )
88 |
89 | content: list[ContentPart] = [
90 | system("Previous context has been compacted. Here is the compaction output:")
91 | ]
92 | content.extend(
93 | [TextPart(text=compacted_msg.content)]
94 | if isinstance(compacted_msg.content, str)
95 | else compacted_msg.content
96 | )
97 | compacted_messages: list[Message] = [Message(role="assistant", content=content)]
98 | compacted_messages.extend(to_preserve)
99 | return compacted_messages
100 |
101 |
102 | if TYPE_CHECKING:
103 |
104 | def type_check(simple: SimpleCompaction):
105 | _: Compaction = simple
106 |
--------------------------------------------------------------------------------
/src/kimi_cli/soul/denwarenji.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, Field
2 |
3 |
4 | class DMail(BaseModel):
5 | message: str = Field(description="The message to send.")
6 | checkpoint_id: int = Field(description="The checkpoint to send the message back to.", ge=0)
7 | # TODO: allow restoring filesystem state to the checkpoint
8 |
9 |
10 | class DenwaRenjiError(Exception):
11 | pass
12 |
13 |
14 | class DenwaRenji:
15 | def __init__(self):
16 | self._pending_dmail: DMail | None = None
17 | self._n_checkpoints: int = 0
18 |
19 | def send_dmail(self, dmail: DMail):
20 | """Send a D-Mail. Intended to be called by the SendDMail tool."""
21 | if self._pending_dmail is not None:
22 | raise DenwaRenjiError("Only one D-Mail can be sent at a time")
23 | if dmail.checkpoint_id < 0:
24 | raise DenwaRenjiError("The checkpoint ID can not be negative")
25 | if dmail.checkpoint_id >= self._n_checkpoints:
26 | raise DenwaRenjiError("There is no checkpoint with the given ID")
27 | self._pending_dmail = dmail
28 |
29 | def set_n_checkpoints(self, n_checkpoints: int):
30 | """Set the number of checkpoints. Intended to be called by the soul."""
31 | self._n_checkpoints = n_checkpoints
32 |
33 | def fetch_pending_dmail(self) -> DMail | None:
34 | """Fetch a pending D-Mail. Intended to be called by the soul."""
35 | pending_dmail = self._pending_dmail
36 | self._pending_dmail = None
37 | return pending_dmail
38 |
--------------------------------------------------------------------------------
/src/kimi_cli/soul/globals.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 | import sys
3 | from datetime import datetime
4 | from pathlib import Path
5 | from typing import NamedTuple
6 |
7 | from kimi_cli.config import Config
8 | from kimi_cli.llm import LLM
9 | from kimi_cli.metadata import Session
10 | from kimi_cli.soul.approval import Approval
11 | from kimi_cli.soul.denwarenji import DenwaRenji
12 | from kimi_cli.utils.logging import logger
13 |
14 |
15 | class BuiltinSystemPromptArgs(NamedTuple):
16 | """Builtin system prompt arguments."""
17 |
18 | KIMI_NOW: str
19 | """The current datetime."""
20 | KIMI_WORK_DIR: Path
21 | """The current working directory."""
22 | KIMI_WORK_DIR_LS: str
23 | """The directory listing of current working directory."""
24 | KIMI_AGENTS_MD: str # TODO: move to first message from system prompt
25 | """The content of AGENTS.md."""
26 |
27 |
28 | def load_agents_md(work_dir: Path) -> str | None:
29 | paths = [
30 | work_dir / "AGENTS.md",
31 | work_dir / "agents.md",
32 | ]
33 | for path in paths:
34 | if path.is_file():
35 | logger.info("Loaded agents.md: {path}", path=path)
36 | return path.read_text(encoding="utf-8").strip()
37 | logger.info("No AGENTS.md found in {work_dir}", work_dir=work_dir)
38 | return None
39 |
40 |
41 | def _list_work_dir(work_dir: Path) -> str:
42 | if sys.platform == "win32":
43 | ls = subprocess.run(
44 | ["cmd", "/c", "dir", work_dir],
45 | capture_output=True,
46 | text=True,
47 | encoding="utf-8",
48 | errors="replace",
49 | )
50 | else:
51 | ls = subprocess.run(
52 | ["ls", "-la", work_dir],
53 | capture_output=True,
54 | text=True,
55 | encoding="utf-8",
56 | errors="replace",
57 | )
58 | return ls.stdout.strip()
59 |
60 |
61 | class AgentGlobals(NamedTuple):
62 | """Agent globals."""
63 |
64 | config: Config
65 | llm: LLM | None
66 | session: Session
67 | builtin_args: BuiltinSystemPromptArgs
68 | denwa_renji: DenwaRenji
69 | approval: Approval
70 |
71 | @classmethod
72 | async def create(
73 | cls, config: Config, llm: LLM | None, session: Session, yolo: bool
74 | ) -> "AgentGlobals":
75 | work_dir = Path(session.work_dir.path)
76 | # FIXME: do these asynchronously
77 | ls_output = _list_work_dir(work_dir)
78 | agents_md = load_agents_md(work_dir) or ""
79 |
80 | return cls(
81 | config=config,
82 | llm=llm,
83 | session=session,
84 | builtin_args=BuiltinSystemPromptArgs(
85 | KIMI_NOW=datetime.now().astimezone().isoformat(),
86 | KIMI_WORK_DIR=work_dir,
87 | KIMI_WORK_DIR_LS=ls_output,
88 | KIMI_AGENTS_MD=agents_md,
89 | ),
90 | denwa_renji=DenwaRenji(),
91 | approval=Approval(yolo=yolo),
92 | )
93 |
--------------------------------------------------------------------------------
/src/kimi_cli/soul/message.py:
--------------------------------------------------------------------------------
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"{message}")
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: list[ContentPart] = [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: 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)
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: list[ContentPart] = []
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(output)
74 | if not content:
75 | content.append(system("Tool output is empty."))
76 | return content
77 |
--------------------------------------------------------------------------------
/src/kimi_cli/soul/toolset.py:
--------------------------------------------------------------------------------
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)
26 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/__init__.py:
--------------------------------------------------------------------------------
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
86 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/bash/__init__.py:
--------------------------------------------------------------------------------
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 | self.name,
42 | "run shell command",
43 | f"Run command `{params.command}`",
44 | ):
45 | return ToolRejectedError()
46 |
47 | def stdout_cb(line: bytes):
48 | line_str = line.decode(errors="replace")
49 | builder.write(line_str)
50 |
51 | def stderr_cb(line: bytes):
52 | line_str = line.decode(errors="replace")
53 | builder.write(line_str)
54 |
55 | try:
56 | exitcode = await _stream_subprocess(
57 | params.command, stdout_cb, stderr_cb, params.timeout
58 | )
59 |
60 | if exitcode == 0:
61 | return builder.ok("Command executed successfully.")
62 | else:
63 | return builder.error(
64 | f"Command failed with exit code: {exitcode}.",
65 | brief=f"Failed with exit code: {exitcode}",
66 | )
67 | except TimeoutError:
68 | return builder.error(
69 | f"Command killed by timeout ({params.timeout}s)",
70 | brief=f"Killed by timeout ({params.timeout}s)",
71 | )
72 |
73 |
74 | async def _stream_subprocess(command: str, stdout_cb, stderr_cb, timeout: int) -> int:
75 | async def _read_stream(stream, cb):
76 | while True:
77 | line = await stream.readline()
78 | if line:
79 | cb(line)
80 | else:
81 | break
82 |
83 | # FIXME: if the event loop is cancelled, an exception may be raised when the process finishes
84 | process = await asyncio.create_subprocess_shell(
85 | command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
86 | )
87 |
88 | try:
89 | await asyncio.wait_for(
90 | asyncio.gather(
91 | _read_stream(process.stdout, stdout_cb),
92 | _read_stream(process.stderr, stderr_cb),
93 | ),
94 | timeout,
95 | )
96 | return await process.wait()
97 | except TimeoutError:
98 | process.kill()
99 | raise
100 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/bash/bash.md:
--------------------------------------------------------------------------------
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 ` before using it.
32 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/dmail/__init__.py:
--------------------------------------------------------------------------------
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(encoding="utf-8")
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 | )
39 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/dmail/dmail.md:
--------------------------------------------------------------------------------
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 `` 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.
16 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/__init__.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class FileOpsWindow:
5 | """Maintains a window of file operations."""
6 |
7 | pass
8 |
9 |
10 | class FileActions(str, Enum):
11 | READ = "read file"
12 | EDIT = "edit file"
13 |
14 |
15 | from .glob import Glob # noqa: E402
16 | from .grep import Grep # noqa: E402
17 | from .patch import PatchFile # noqa: E402
18 | from .read import ReadFile # noqa: E402
19 | from .replace import StrReplaceFile # noqa: E402
20 | from .write import WriteFile # noqa: E402
21 |
22 | __all__ = (
23 | "ReadFile",
24 | "Glob",
25 | "Grep",
26 | "WriteFile",
27 | "StrReplaceFile",
28 | "PatchFile",
29 | )
30 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/glob.md:
--------------------------------------------------------------------------------
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 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.
18 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/glob.py:
--------------------------------------------------------------------------------
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.soul.globals 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 f"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 | )
150 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/grep.md:
--------------------------------------------------------------------------------
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 `{`.
6 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/patch.md:
--------------------------------------------------------------------------------
1 | Apply a unified diff patch to a file.
2 |
3 | **Tips:**
4 | - The patch must be in unified diff format, the format used by `diff -u` and `git diff`.
5 | - Only use this tool on text files.
6 | - The tool will fail with error returned if the patch doesn't apply cleanly.
7 | - The file must exist before applying the patch.
8 | - You should prefer this tool over WriteFile tool and Bash `sed` command when editing an existing file.
9 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/patch.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import override
3 |
4 | import aiofiles
5 | import patch_ng
6 | from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
7 | from pydantic import BaseModel, Field
8 |
9 | from kimi_cli.soul.approval import Approval
10 | from kimi_cli.soul.globals import BuiltinSystemPromptArgs
11 | from kimi_cli.tools.file import FileActions
12 | from kimi_cli.tools.utils import ToolRejectedError
13 |
14 |
15 | class Params(BaseModel):
16 | path: str = Field(description="The absolute path to the file to apply the patch to.")
17 | diff: str = Field(description="The diff content in unified format to apply.")
18 |
19 |
20 | class PatchFile(CallableTool2[Params]):
21 | name: str = "PatchFile"
22 | description: str = (Path(__file__).parent / "patch.md").read_text(encoding="utf-8")
23 | params: type[Params] = Params
24 |
25 | def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
26 | super().__init__(**kwargs)
27 | self._work_dir = builtin_args.KIMI_WORK_DIR
28 | self._approval = approval
29 |
30 | def _validate_path(self, path: Path) -> ToolError | None:
31 | """Validate that the path is safe to patch."""
32 | # Check for path traversal attempts
33 | resolved_path = path.resolve()
34 | resolved_work_dir = Path(self._work_dir).resolve()
35 |
36 | # Ensure the path is within work directory
37 | if not str(resolved_path).startswith(str(resolved_work_dir)):
38 | return ToolError(
39 | message=(
40 | f"`{path}` is outside the working directory. "
41 | "You can only patch files within the working directory."
42 | ),
43 | brief="Path outside working directory",
44 | )
45 | return None
46 |
47 | @override
48 | async def __call__(self, params: Params) -> ToolReturnType:
49 | try:
50 | p = Path(params.path)
51 |
52 | if not p.is_absolute():
53 | return ToolError(
54 | message=(
55 | f"`{params.path}` is not an absolute path. "
56 | "You must provide an absolute path to patch a file."
57 | ),
58 | brief="Invalid path",
59 | )
60 |
61 | # Validate path safety
62 | path_error = self._validate_path(p)
63 | if path_error:
64 | return path_error
65 |
66 | if not p.exists():
67 | return ToolError(
68 | message=f"`{params.path}` does not exist.",
69 | brief="File not found",
70 | )
71 | if not p.is_file():
72 | return ToolError(
73 | message=f"`{params.path}` is not a file.",
74 | brief="Invalid path",
75 | )
76 |
77 | # Request approval
78 | if not await self._approval.request(
79 | self.name,
80 | FileActions.EDIT,
81 | f"Patch file `{params.path}`",
82 | ):
83 | return ToolRejectedError()
84 |
85 | # Read the file content
86 | async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
87 | original_content = await f.read()
88 |
89 | # Create patch object directly from string (no temporary file needed!)
90 | patch_set = patch_ng.fromstring(params.diff.encode("utf-8"))
91 |
92 | # Handle case where patch_ng.fromstring returns False on parse errors
93 | if not patch_set or patch_set is True:
94 | return ToolError(
95 | message=(
96 | "Failed to parse diff content: invalid patch format or no valid hunks found"
97 | ),
98 | brief="Invalid diff format",
99 | )
100 |
101 | # Count total hunks across all items
102 | total_hunks = sum(len(item.hunks) for item in patch_set.items)
103 |
104 | if total_hunks == 0:
105 | return ToolError(
106 | message="No valid hunks found in the diff content",
107 | brief="No hunks found",
108 | )
109 |
110 | # Apply the patch
111 | success = patch_set.apply(root=str(p.parent))
112 |
113 | if not success:
114 | return ToolError(
115 | message=(
116 | "Failed to apply patch - patch may not be compatible with the file content"
117 | ),
118 | brief="Patch application failed",
119 | )
120 |
121 | # Read the modified content to check if changes were made
122 | async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
123 | modified_content = await f.read()
124 |
125 | # Check if any changes were made
126 | if modified_content == original_content:
127 | return ToolError(
128 | message="No changes were made. The patch does not apply to the file.",
129 | brief="No changes made",
130 | )
131 |
132 | return ToolOk(
133 | output="",
134 | message=(
135 | f"File successfully patched. Applied {total_hunks} hunk(s) to {params.path}."
136 | ),
137 | )
138 |
139 | except Exception as e:
140 | return ToolError(
141 | message=f"Failed to patch file. Error: {e}",
142 | brief="Failed to patch file",
143 | )
144 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/read.md:
--------------------------------------------------------------------------------
1 | Read content from a file.
2 |
3 | **Tips:**
4 | - Make sure you follow the description of each tool parameter.
5 | - A `` tag will be given before the read file content.
6 | - Content will be returned with a line number before each line like `cat -n` format.
7 | - Use `line_offset` and `n_lines` parameters when you only need to read a part of the file.
8 | - The maximum number of lines that can be read at once is ${MAX_LINES}.
9 | - Any lines longer than ${MAX_LINE_LENGTH} characters will be truncated, ending with "...".
10 | - The system will notify you when there is any limitation hit when reading the file.
11 | - This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.
12 | - This tool can only read text files. To list directories, you must use the Glob tool or `ls` command via the Bash tool. To read other file types, use appropriate commands via the Bash tool.
13 | - If the file doesn't exist or path is invalid, an error will be returned.
14 | - If you want to search for a certain content/pattern, prefer Grep tool over ReadFile.
15 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/read.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import override
3 |
4 | import aiofiles
5 | from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6 | from pydantic import BaseModel, Field
7 |
8 | from kimi_cli.soul.globals import BuiltinSystemPromptArgs
9 | from kimi_cli.tools.utils import load_desc, truncate_line
10 |
11 | MAX_LINES = 1000
12 | MAX_LINE_LENGTH = 2000
13 | MAX_BYTES = 100 << 10 # 100KB
14 |
15 |
16 | class Params(BaseModel):
17 | path: str = Field(description="The absolute path to the file to read")
18 | line_offset: int = Field(
19 | description=(
20 | "The line number to start reading from. "
21 | "By default read from the beginning of the file. "
22 | "Set this when the file is too large to read at once."
23 | ),
24 | default=1,
25 | ge=1,
26 | )
27 | n_lines: int = Field(
28 | description=(
29 | "The number of lines to read. "
30 | f"By default read up to {MAX_LINES} lines, which is the max allowed value. "
31 | "Set this value when the file is too large to read at once."
32 | ),
33 | default=MAX_LINES,
34 | ge=1,
35 | )
36 |
37 |
38 | class ReadFile(CallableTool2[Params]):
39 | name: str = "ReadFile"
40 | description: str = load_desc(
41 | Path(__file__).parent / "read.md",
42 | {
43 | "MAX_LINES": str(MAX_LINES),
44 | "MAX_LINE_LENGTH": str(MAX_LINE_LENGTH),
45 | "MAX_BYTES": str(MAX_BYTES),
46 | },
47 | )
48 | params: type[Params] = Params
49 |
50 | def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
51 | super().__init__(**kwargs)
52 | self._work_dir = builtin_args.KIMI_WORK_DIR
53 |
54 | @override
55 | async def __call__(self, params: Params) -> ToolReturnType:
56 | # TODO: checks:
57 | # - check if the path may contain secrets
58 | # - check if the file format is readable
59 | try:
60 | p = Path(params.path)
61 |
62 | if not p.is_absolute():
63 | return ToolError(
64 | message=(
65 | f"`{params.path}` is not an absolute path. "
66 | "You must provide an absolute path to read a file."
67 | ),
68 | brief="Invalid path",
69 | )
70 |
71 | if not p.exists():
72 | return ToolError(
73 | message=f"`{params.path}` does not exist.",
74 | brief="File not found",
75 | )
76 | if not p.is_file():
77 | return ToolError(
78 | message=f"`{params.path}` is not a file.",
79 | brief="Invalid path",
80 | )
81 |
82 | assert params.line_offset >= 1
83 | assert params.n_lines >= 1
84 |
85 | lines: list[str] = []
86 | n_bytes = 0
87 | truncated_line_numbers = []
88 | max_lines_reached = False
89 | max_bytes_reached = False
90 | async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
91 | current_line_no = 0
92 | async for line in f:
93 | current_line_no += 1
94 | if current_line_no < params.line_offset:
95 | continue
96 | truncated = truncate_line(line, MAX_LINE_LENGTH)
97 | if truncated != line:
98 | truncated_line_numbers.append(current_line_no)
99 | lines.append(truncated)
100 | n_bytes += len(truncated.encode("utf-8"))
101 | if len(lines) >= params.n_lines:
102 | break
103 | if len(lines) >= MAX_LINES:
104 | max_lines_reached = True
105 | break
106 | if n_bytes >= MAX_BYTES:
107 | max_bytes_reached = True
108 | break
109 |
110 | # Format output with line numbers like `cat -n`
111 | lines_with_no = []
112 | for line_num, line in zip(
113 | range(params.line_offset, params.line_offset + len(lines)), lines, strict=True
114 | ):
115 | # Use 6-digit line number width, right-aligned, with tab separator
116 | lines_with_no.append(f"{line_num:6d}\t{line}")
117 |
118 | message = (
119 | f"{len(lines)} lines read from file starting from line {params.line_offset}."
120 | if len(lines) > 0
121 | else "No lines read from file."
122 | )
123 | if max_lines_reached:
124 | message += f" Max {MAX_LINES} lines reached."
125 | elif max_bytes_reached:
126 | message += f" Max {MAX_BYTES} bytes reached."
127 | elif len(lines) < params.n_lines:
128 | message += " End of file reached."
129 | if truncated_line_numbers:
130 | message += f" Lines {truncated_line_numbers} were truncated."
131 | return ToolOk(
132 | output="".join(lines_with_no), # lines already contain \n, just join them
133 | message=message,
134 | )
135 | except Exception as e:
136 | return ToolError(
137 | message=f"Failed to read {params.path}. Error: {e}",
138 | brief="Failed to read file",
139 | )
140 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/replace.md:
--------------------------------------------------------------------------------
1 | Replace specific strings within a specified file.
2 |
3 | **Tips:**
4 | - Only use this tool on text files.
5 | - Multi-line strings are supported.
6 | - Can specify a single edit or a list of edits in one call.
7 | - You should prefer this tool over WriteFile tool and Bash `sed` command.
8 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/replace.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import override
3 |
4 | import aiofiles
5 | from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6 | from pydantic import BaseModel, Field
7 |
8 | from kimi_cli.soul.approval import Approval
9 | from kimi_cli.soul.globals import BuiltinSystemPromptArgs
10 | from kimi_cli.tools.file import FileActions
11 | from kimi_cli.tools.utils import ToolRejectedError
12 |
13 |
14 | class Edit(BaseModel):
15 | old: str = Field(description="The old string to replace. Can be multi-line.")
16 | new: str = Field(description="The new string to replace with. Can be multi-line.")
17 | replace_all: bool = Field(description="Whether to replace all occurrences.", default=False)
18 |
19 |
20 | class Params(BaseModel):
21 | path: str = Field(description="The absolute path to the file to edit.")
22 | edit: Edit | list[Edit] = Field(
23 | description=(
24 | "The edit(s) to apply to the file. "
25 | "You can provide a single edit or a list of edits here."
26 | )
27 | )
28 |
29 |
30 | class StrReplaceFile(CallableTool2[Params]):
31 | name: str = "StrReplaceFile"
32 | description: str = (Path(__file__).parent / "replace.md").read_text(encoding="utf-8")
33 | params: type[Params] = Params
34 |
35 | def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
36 | super().__init__(**kwargs)
37 | self._work_dir = builtin_args.KIMI_WORK_DIR
38 | self._approval = approval
39 |
40 | def _validate_path(self, path: Path) -> ToolError | None:
41 | """Validate that the path is safe to edit."""
42 | # Check for path traversal attempts
43 | resolved_path = path.resolve()
44 | resolved_work_dir = self._work_dir.resolve()
45 |
46 | # Ensure the path is within work directory
47 | if not str(resolved_path).startswith(str(resolved_work_dir)):
48 | return ToolError(
49 | message=(
50 | f"`{path}` is outside the working directory. "
51 | "You can only edit files within the working directory."
52 | ),
53 | brief="Path outside working directory",
54 | )
55 | return None
56 |
57 | def _apply_edit(self, content: str, edit: Edit) -> str:
58 | """Apply a single edit to the content."""
59 | if edit.replace_all:
60 | return content.replace(edit.old, edit.new)
61 | else:
62 | return content.replace(edit.old, edit.new, 1)
63 |
64 | @override
65 | async def __call__(self, params: Params) -> ToolReturnType:
66 | try:
67 | p = Path(params.path)
68 |
69 | if not p.is_absolute():
70 | return ToolError(
71 | message=(
72 | f"`{params.path}` is not an absolute path. "
73 | "You must provide an absolute path to edit a file."
74 | ),
75 | brief="Invalid path",
76 | )
77 |
78 | # Validate path safety
79 | path_error = self._validate_path(p)
80 | if path_error:
81 | return path_error
82 |
83 | if not p.exists():
84 | return ToolError(
85 | message=f"`{params.path}` does not exist.",
86 | brief="File not found",
87 | )
88 | if not p.is_file():
89 | return ToolError(
90 | message=f"`{params.path}` is not a file.",
91 | brief="Invalid path",
92 | )
93 |
94 | # Request approval
95 | if not await self._approval.request(
96 | self.name,
97 | FileActions.EDIT,
98 | f"Edit file `{params.path}`",
99 | ):
100 | return ToolRejectedError()
101 |
102 | # Read the file content
103 | async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
104 | content = await f.read()
105 |
106 | original_content = content
107 | edits = [params.edit] if isinstance(params.edit, Edit) else params.edit
108 |
109 | # Apply all edits
110 | for edit in edits:
111 | content = self._apply_edit(content, edit)
112 |
113 | # Check if any changes were made
114 | if content == original_content:
115 | return ToolError(
116 | message="No replacements were made. The old string was not found in the file.",
117 | brief="No replacements made",
118 | )
119 |
120 | # Write the modified content back to the file
121 | async with aiofiles.open(p, mode="w", encoding="utf-8") as f:
122 | await f.write(content)
123 |
124 | # Count changes for success message
125 | total_replacements = 0
126 | for edit in edits:
127 | if edit.replace_all:
128 | total_replacements += original_content.count(edit.old)
129 | else:
130 | total_replacements += 1 if edit.old in original_content else 0
131 |
132 | return ToolOk(
133 | output="",
134 | message=(
135 | f"File successfully edited. "
136 | f"Applied {len(edits)} edit(s) with {total_replacements} total replacement(s)."
137 | ),
138 | )
139 |
140 | except Exception as e:
141 | return ToolError(
142 | message=f"Failed to edit. Error: {e}",
143 | brief="Failed to edit file",
144 | )
145 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/write.md:
--------------------------------------------------------------------------------
1 | Write content to a file.
2 |
3 | **Tips:**
4 | - When `mode` is not specified, it defaults to `overwrite`. Always write with caution.
5 | - When the content to write is too long (e.g. > 100 lines), use this tool multiple times instead of a single call. Use `overwrite` mode at the first time, then use `append` mode after the first write.
6 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/file/write.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import Literal, override
3 |
4 | import aiofiles
5 | from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6 | from pydantic import BaseModel, Field
7 |
8 | from kimi_cli.soul.approval import Approval
9 | from kimi_cli.soul.globals import BuiltinSystemPromptArgs
10 | from kimi_cli.tools.file import FileActions
11 | from kimi_cli.tools.utils import ToolRejectedError
12 |
13 |
14 | class Params(BaseModel):
15 | path: str = Field(description="The absolute path to the file to write")
16 | content: str = Field(description="The content to write to the file")
17 | mode: Literal["overwrite", "append"] = Field(
18 | description=(
19 | "The mode to use to write to the file. "
20 | "Two modes are supported: `overwrite` for overwriting the whole file and "
21 | "`append` for appending to the end of an existing file."
22 | ),
23 | default="overwrite",
24 | )
25 |
26 |
27 | class WriteFile(CallableTool2[Params]):
28 | name: str = "WriteFile"
29 | description: str = (Path(__file__).parent / "write.md").read_text(encoding="utf-8")
30 | params: type[Params] = Params
31 |
32 | def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
33 | super().__init__(**kwargs)
34 | self._work_dir = builtin_args.KIMI_WORK_DIR
35 | self._approval = approval
36 |
37 | def _validate_path(self, path: Path) -> ToolError | None:
38 | """Validate that the path is safe to write."""
39 | # Check for path traversal attempts
40 | resolved_path = path.resolve()
41 | resolved_work_dir = self._work_dir.resolve()
42 |
43 | # Ensure the path is within work directory
44 | if not str(resolved_path).startswith(str(resolved_work_dir)):
45 | return ToolError(
46 | message=(
47 | f"`{path}` is outside the working directory. "
48 | "You can only write files within the working directory."
49 | ),
50 | brief="Path outside working directory",
51 | )
52 | return None
53 |
54 | @override
55 | async def __call__(self, params: Params) -> ToolReturnType:
56 | # TODO: checks:
57 | # - check if the path may contain secrets
58 | # - check if the file format is writable
59 | try:
60 | p = Path(params.path)
61 |
62 | if not p.is_absolute():
63 | return ToolError(
64 | message=(
65 | f"`{params.path}` is not an absolute path. "
66 | "You must provide an absolute path to write a file."
67 | ),
68 | brief="Invalid path",
69 | )
70 |
71 | # Validate path safety
72 | path_error = self._validate_path(p)
73 | if path_error:
74 | return path_error
75 |
76 | if not p.parent.exists():
77 | return ToolError(
78 | message=f"`{params.path}` parent directory does not exist.",
79 | brief="Parent directory not found",
80 | )
81 |
82 | # Validate mode parameter
83 | if params.mode not in ["overwrite", "append"]:
84 | return ToolError(
85 | message=(
86 | f"Invalid write mode: `{params.mode}`. "
87 | "Mode must be either `overwrite` or `append`."
88 | ),
89 | brief="Invalid write mode",
90 | )
91 |
92 | # Request approval
93 | if not await self._approval.request(
94 | self.name,
95 | FileActions.EDIT,
96 | f"Write file `{params.path}`",
97 | ):
98 | return ToolRejectedError()
99 |
100 | # Determine file mode for aiofiles
101 | file_mode = "w" if params.mode == "overwrite" else "a"
102 |
103 | # Write content to file
104 | async with aiofiles.open(p, mode=file_mode, encoding="utf-8") as f:
105 | await f.write(params.content)
106 |
107 | # Get file info for success message
108 | file_size = p.stat().st_size
109 | action = "overwritten" if params.mode == "overwrite" else "appended to"
110 | return ToolOk(
111 | output="",
112 | message=(f"File successfully {action}. Current size: {file_size} bytes."),
113 | )
114 |
115 | except Exception as e:
116 | return ToolError(
117 | message=f"Failed to write to {params.path}. Error: {e}",
118 | brief="Failed to write file",
119 | )
120 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/mcp.py:
--------------------------------------------------------------------------------
1 | import fastmcp
2 | import mcp
3 | from fastmcp.client.client import CallToolResult
4 | from kosong.base.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
5 | from kosong.tooling import CallableTool, ToolOk, ToolReturnType
6 |
7 |
8 | class MCPTool(CallableTool):
9 | def __init__(self, mcp_tool: mcp.Tool, client: fastmcp.Client, **kwargs):
10 | super().__init__(
11 | name=mcp_tool.name,
12 | description=mcp_tool.description or "",
13 | parameters=mcp_tool.inputSchema,
14 | **kwargs,
15 | )
16 | self._mcp_tool = mcp_tool
17 | self._client = client
18 |
19 | async def __call__(self, *args, **kwargs) -> ToolReturnType:
20 | async with self._client as client:
21 | result = await client.call_tool(self._mcp_tool.name, kwargs, timeout=20)
22 | return convert_tool_result(result)
23 |
24 |
25 | def convert_tool_result(result: CallToolResult) -> ToolReturnType:
26 | content: list[ContentPart] = []
27 | for part in result.content:
28 | match part:
29 | case mcp.types.TextContent(text=text):
30 | content.append(TextPart(text=text))
31 | case mcp.types.ImageContent(data=data, mimeType=mimeType):
32 | content.append(
33 | ImageURLPart(
34 | image_url=ImageURLPart.ImageURL(url=f"data:{mimeType};base64,{data}")
35 | )
36 | )
37 | case mcp.types.AudioContent(data=data, mimeType=mimeType):
38 | content.append(
39 | AudioURLPart(
40 | audio_url=AudioURLPart.AudioURL(url=f"data:{mimeType};base64,{data}")
41 | )
42 | )
43 | case mcp.types.EmbeddedResource(
44 | resource=mcp.types.BlobResourceContents(uri=_uri, mimeType=mimeType, blob=blob)
45 | ):
46 | mimeType = mimeType or "application/octet-stream"
47 | if mimeType.startswith("image/"):
48 | content.append(
49 | ImageURLPart(
50 | type="image_url",
51 | image_url=ImageURLPart.ImageURL(
52 | url=f"data:{mimeType};base64,{blob}",
53 | ),
54 | )
55 | )
56 | elif mimeType.startswith("audio/"):
57 | content.append(
58 | AudioURLPart(
59 | type="audio_url",
60 | audio_url=AudioURLPart.AudioURL(url=f"data:{mimeType};base64,{blob}"),
61 | )
62 | )
63 | else:
64 | raise ValueError(f"Unsupported mime type: {mimeType}")
65 | case mcp.types.ResourceLink(uri=uri, mimeType=mimeType, description=_description):
66 | mimeType = mimeType or "application/octet-stream"
67 | if mimeType.startswith("image/"):
68 | content.append(
69 | ImageURLPart(
70 | type="image_url",
71 | image_url=ImageURLPart.ImageURL(url=str(uri)),
72 | )
73 | )
74 | elif mimeType.startswith("audio/"):
75 | content.append(
76 | AudioURLPart(
77 | type="audio_url",
78 | audio_url=AudioURLPart.AudioURL(url=str(uri)),
79 | )
80 | )
81 | else:
82 | raise ValueError(f"Unsupported mime type: {mimeType}")
83 | case _:
84 | raise ValueError(f"Unsupported MCP tool result part: {part}")
85 | return ToolOk(output=content)
86 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/task/task.md:
--------------------------------------------------------------------------------
1 | Spawn a subagent to perform a specific task. Subagent will be spawned with a fresh context without any history of yours.
2 |
3 | **Context Isolation**
4 |
5 | Context isolation is one of the key benefits of using subagents. By delegating tasks to subagents, you can keep your main context clean and focused on the main goal requested by the user.
6 |
7 | Here are some scenarios you may want this tool for context isolation:
8 |
9 | - You wrote some code and it did not work as expected. In this case you can spawn a subagent to fix the code, asking the subagent to return how it is fixed. This can potentially benefit because the detailed process of fixing the code may not be relevant to your main goal, and may clutter your context.
10 | - When you need some latest knowledge of a specific library, framework or technology to proceed with your task, you can spawn a subagent to search on the internet for the needed information and return to you the gathered relevant information, for example code examples, API references, etc. This can avoid ton of irrelevant search results in your own context.
11 |
12 | DO NOT directly forward the user prompt to Task tool. DO NOT simply spawn Task tool for each todo item. This will cause the user confused because the user cannot see what the subagent do. Only you can see the response from the subagent. So, only spawn subagents for very specific and narrow tasks like fixing a compilation error, or searching for a specific solution.
13 |
14 | **Parallel Multi-Tasking**
15 |
16 | Parallel multi-tasking is another key benefit of this tool. When the user request involves multiple subtasks that are independent of each other, you can use Task tool multiple times in a single response to let subagents work in parallel for you.
17 |
18 | Examples:
19 |
20 | - User requests to code, refactor or fix multiple modules/files in a project, and they can be tested independently. In this case you can spawn multiple subagents each working on a different module/file.
21 | - When you need to analyze a huge codebase (> hundreds of thousands of lines), you can spawn multiple subagents each exploring on a different part of the codebase and gather the summarized results.
22 | - When you need to search the web for multiple queries, you can spawn multiple subagents for better efficiency.
23 |
24 | **Available Subagents:**
25 |
26 | ${SUBAGENTS_MD}
27 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/test.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from typing import override
3 |
4 | from kosong.tooling import CallableTool2, ToolOk, ToolReturnType
5 | from pydantic import BaseModel
6 |
7 |
8 | class PlusParams(BaseModel):
9 | a: float
10 | b: float
11 |
12 |
13 | class Plus(CallableTool2[PlusParams]):
14 | name: str = "plus"
15 | description: str = "Add two numbers"
16 | params: type[PlusParams] = PlusParams
17 |
18 | @override
19 | async def __call__(self, params: PlusParams) -> ToolReturnType:
20 | return ToolOk(output=str(params.a + params.b))
21 |
22 |
23 | class CompareParams(BaseModel):
24 | a: float
25 | b: float
26 |
27 |
28 | class Compare(CallableTool2[CompareParams]):
29 | name: str = "compare"
30 | description: str = "Compare two numbers"
31 | params: type[CompareParams] = CompareParams
32 |
33 | @override
34 | async def __call__(self, params: CompareParams) -> ToolReturnType:
35 | if params.a > params.b:
36 | return ToolOk(output="greater")
37 | elif params.a < params.b:
38 | return ToolOk(output="less")
39 | else:
40 | return ToolOk(output="equal")
41 |
42 |
43 | class PanicParams(BaseModel):
44 | message: str
45 |
46 |
47 | class Panic(CallableTool2[PanicParams]):
48 | name: str = "panic"
49 | description: str = "Raise an exception to cause the tool call to fail."
50 | params: type[PanicParams] = PanicParams
51 |
52 | @override
53 | async def __call__(self, params: PanicParams) -> ToolReturnType:
54 | await asyncio.sleep(2)
55 | raise Exception(f"panicked with a message with {len(params.message)} characters")
56 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/think/__init__.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import override
3 |
4 | from kosong.tooling import CallableTool2, ToolOk, ToolReturnType
5 | from pydantic import BaseModel, Field
6 |
7 | from kimi_cli.tools.utils import load_desc
8 |
9 |
10 | class Params(BaseModel):
11 | thought: str = Field(description=("A thought to think about."))
12 |
13 |
14 | class Think(CallableTool2[Params]):
15 | name: str = "Think"
16 | description: str = load_desc(Path(__file__).parent / "think.md", {})
17 | params: type[Params] = Params
18 |
19 | @override
20 | async def __call__(self, params: Params) -> ToolReturnType:
21 | return ToolOk(output="", message="Thought logged")
22 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/think/think.md:
--------------------------------------------------------------------------------
1 | Use the tool to think about something. It will not obtain new information or change the database, but just append the thought to the log. Use it when complex reasoning or some cache memory is needed.
2 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/todo/__init__.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import Literal, override
3 |
4 | from kosong.tooling import CallableTool2, ToolOk, ToolReturnType
5 | from pydantic import BaseModel, Field
6 |
7 |
8 | class Todo(BaseModel):
9 | title: str = Field(description="The title of the todo", min_length=1)
10 | status: Literal["Pending", "In Progress", "Done"] = Field(description="The status of the todo")
11 |
12 |
13 | class Params(BaseModel):
14 | todos: list[Todo] = Field(description="The updated todo list")
15 |
16 |
17 | class SetTodoList(CallableTool2[Params]):
18 | name: str = "SetTodoList"
19 | description: str = (Path(__file__).parent / "set_todo_list.md").read_text(encoding="utf-8")
20 | params: type[Params] = Params
21 |
22 | @override
23 | async def __call__(self, params: Params) -> ToolReturnType:
24 | rendered = ""
25 | for todo in params.todos:
26 | rendered += f"- {todo.title} [{todo.status}]\n"
27 | return ToolOk(output=rendered)
28 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/todo/set_todo_list.md:
--------------------------------------------------------------------------------
1 | Update the whole todo list.
2 |
3 | Todo list is a simple yet powerful tool to help you get things done. You typically want to use this tool when the given task involves multiple subtasks/milestones, or, multiple tasks are given in a single request. This tool can help you to break down the task and track the progress.
4 |
5 | This is the only todo list tool available to you. That said, each time you want to operate on the todo list, you need to update the whole. Make sure to maintain the todo items and their statuses properly.
6 |
7 | Once you finished a subtask/milestone, remember to update the todo list to reflect the progress. Also, you can give yourself a self-encouragement to keep you motivated.
8 |
9 | Abusing this tool to track too small steps will just waste your time and make your context messy. For example, here are some cases you should not use this tool:
10 |
11 | - When the user just simply ask you a question. E.g. "What language and framework is used in the project?", "What is the best practice for x?"
12 | - When it only takes a few steps/tool calls to complete the task. E.g. "Fix the unit test function 'test_xxx'", "Refactor the function 'xxx' to make it more solid."
13 | - When the user prompt is very specific and the only thing you need to do is brainlessly following the instructions. E.g. "Replace xxx to yyy in the file zzz", "Create a file xxx with content yyy."
14 |
15 | However, do not get stuck in a rut. Be flexible. Sometimes, you may try to use todo list at first, then realize the task is too simple and you can simply stop using it; or, sometimes, you may realize the task is complex after a few steps and then you can start using todo list to break it down.
16 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/utils.py:
--------------------------------------------------------------------------------
1 | import re
2 | import string
3 | from pathlib import Path
4 |
5 | from kosong.tooling import ToolError, ToolOk
6 |
7 |
8 | def load_desc(path: Path, substitutions: dict[str, str] | None = None) -> str:
9 | """Load a tool description from a file, with optional substitutions."""
10 | description = path.read_text(encoding="utf-8")
11 | if substitutions:
12 | description = string.Template(description).substitute(substitutions)
13 | return description
14 |
15 |
16 | def truncate_line(line: str, max_length: int, marker: str = "...") -> str:
17 | """
18 | Truncate a line if it exceeds `max_length`, preserving the beginning and the line break.
19 | The output may be longer than `max_length` if it is too short to fit the marker.
20 | """
21 | if len(line) <= max_length:
22 | return line
23 |
24 | # Find line breaks at the end of the line
25 | m = re.search(r"[\r\n]+$", line)
26 | linebreak = m.group(0) if m else ""
27 | end = marker + linebreak
28 | max_length = max(max_length, len(end))
29 | return line[: max_length - len(end)] + end
30 |
31 |
32 | # Default output limits
33 | DEFAULT_MAX_CHARS = 50_000
34 | DEFAULT_MAX_LINE_LENGTH = 2000
35 |
36 |
37 | class ToolResultBuilder:
38 | """
39 | Builder for tool results with character and line limits.
40 | """
41 |
42 | def __init__(
43 | self,
44 | max_chars: int = DEFAULT_MAX_CHARS,
45 | max_line_length: int | None = DEFAULT_MAX_LINE_LENGTH,
46 | ):
47 | self.max_chars = max_chars
48 | self.max_line_length = max_line_length
49 | self._marker = "[...truncated]"
50 | if max_line_length is not None:
51 | assert max_line_length > len(self._marker)
52 | self._buffer: list[str] = []
53 | self._n_chars = 0
54 | self._n_lines = 0
55 | self._truncation_happened = False
56 |
57 | def write(self, text: str) -> int:
58 | """
59 | Write text to the output buffer.
60 |
61 | Returns:
62 | int: Number of characters actually written
63 | """
64 | if self.is_full:
65 | return 0
66 |
67 | lines = text.splitlines(keepends=True)
68 | if not lines:
69 | return 0
70 |
71 | chars_written = 0
72 |
73 | for line in lines:
74 | if self.is_full:
75 | break
76 |
77 | original_line = line
78 | remaining_chars = self.max_chars - self._n_chars
79 | limit = (
80 | min(remaining_chars, self.max_line_length)
81 | if self.max_line_length is not None
82 | else remaining_chars
83 | )
84 | line = truncate_line(line, limit, self._marker)
85 | if line != original_line:
86 | self._truncation_happened = True
87 |
88 | self._buffer.append(line)
89 | chars_written += len(line)
90 | self._n_chars += len(line)
91 | if line.endswith("\n"):
92 | self._n_lines += 1
93 |
94 | return chars_written
95 |
96 | def ok(self, message: str = "", *, brief: str = "") -> ToolOk:
97 | """Create a ToolOk result with the current output."""
98 | output = "".join(self._buffer)
99 |
100 | final_message = message
101 | if final_message and not final_message.endswith("."):
102 | final_message += "."
103 | truncation_msg = "Output is truncated to fit in the message."
104 | if self._truncation_happened:
105 | if final_message:
106 | final_message += f" {truncation_msg}"
107 | else:
108 | final_message = truncation_msg
109 |
110 | return ToolOk(output=output, message=final_message, brief=brief)
111 |
112 | def error(self, message: str, *, brief: str) -> ToolError:
113 | """Create a ToolError result with the current output."""
114 | output = "".join(self._buffer)
115 |
116 | final_message = message
117 | if self._truncation_happened:
118 | truncation_msg = "Output is truncated to fit in the message."
119 | if final_message:
120 | final_message += f" {truncation_msg}"
121 | else:
122 | final_message = truncation_msg
123 |
124 | return ToolError(output=output, message=final_message, brief=brief)
125 |
126 | @property
127 | def is_full(self) -> bool:
128 | """Check if output buffer is full due to character limit."""
129 | return self._n_chars >= self.max_chars
130 |
131 | @property
132 | def n_chars(self) -> int:
133 | """Get current character count."""
134 | return self._n_chars
135 |
136 | @property
137 | def n_lines(self) -> int:
138 | """Get current line count."""
139 | return self._n_lines
140 |
141 |
142 | class ToolRejectedError(ToolError):
143 | def __init__(self):
144 | super().__init__(
145 | message=(
146 | "The tool call is rejected by the user. "
147 | "Please follow the new instructions from the user."
148 | ),
149 | brief="Rejected by user",
150 | )
151 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/web/__init__.py:
--------------------------------------------------------------------------------
1 | from .fetch import FetchURL
2 | from .search import SearchWeb
3 |
4 | __all__ = ("SearchWeb", "FetchURL")
5 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/web/fetch.md:
--------------------------------------------------------------------------------
1 | Fetch a web page from a URL and extract main text content from it.
2 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/web/fetch.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import override
3 |
4 | import aiohttp
5 | import trafilatura
6 | from kosong.tooling import CallableTool2, ToolReturnType
7 | from pydantic import BaseModel, Field
8 |
9 | from kimi_cli.tools.utils import ToolResultBuilder, load_desc
10 | from kimi_cli.utils.aiohttp import new_client_session
11 |
12 |
13 | class Params(BaseModel):
14 | url: str = Field(description="The URL to fetch content from.")
15 |
16 |
17 | class FetchURL(CallableTool2[Params]):
18 | name: str = "FetchURL"
19 | description: str = load_desc(Path(__file__).parent / "fetch.md", {})
20 | params: type[Params] = Params
21 |
22 | @override
23 | async def __call__(self, params: Params) -> ToolReturnType:
24 | builder = ToolResultBuilder(max_line_length=None)
25 |
26 | try:
27 | async with (
28 | new_client_session() as session,
29 | session.get(
30 | params.url,
31 | headers={
32 | "User-Agent": (
33 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
34 | "(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
35 | ),
36 | },
37 | ) as response,
38 | ):
39 | if response.status >= 400:
40 | return builder.error(
41 | (
42 | f"Failed to fetch URL. Status: {response.status}. "
43 | f"This may indicate the page is not accessible or the server is down."
44 | ),
45 | brief=f"HTTP {response.status} error",
46 | )
47 |
48 | html = await response.text()
49 | except aiohttp.ClientError as e:
50 | return builder.error(
51 | (
52 | f"Failed to fetch URL due to network error: {str(e)}. "
53 | "This may indicate the URL is invalid or the server is unreachable."
54 | ),
55 | brief="Network error",
56 | )
57 |
58 | if not html:
59 | return builder.ok(
60 | "The response body is empty.",
61 | brief="Empty response body",
62 | )
63 |
64 | extracted_text = trafilatura.extract(
65 | html,
66 | include_comments=True,
67 | include_tables=True,
68 | include_formatting=False,
69 | output_format="txt",
70 | with_metadata=True,
71 | )
72 |
73 | if not extracted_text:
74 | return builder.error(
75 | (
76 | "Failed to extract meaningful content from the page. "
77 | "This may indicate the page content is not suitable for text extraction, "
78 | "or the page requires JavaScript to render its content."
79 | ),
80 | brief="No content extracted",
81 | )
82 |
83 | builder.write(extracted_text)
84 | return builder.ok("The returned content is the main text content extracted from the page.")
85 |
86 |
87 | if __name__ == "__main__":
88 | import asyncio
89 |
90 | async def main():
91 | fetch_url_tool = FetchURL()
92 | result = await fetch_url_tool(Params(url="https://trafilatura.readthedocs.io/en/latest/"))
93 | print(result)
94 |
95 | asyncio.run(main())
96 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/web/search.md:
--------------------------------------------------------------------------------
1 | WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc.
2 |
--------------------------------------------------------------------------------
/src/kimi_cli/tools/web/search.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import override
3 |
4 | from kosong.tooling import CallableTool2, ToolReturnType
5 | from pydantic import BaseModel, Field, ValidationError
6 |
7 | from kimi_cli.config import Config
8 | from kimi_cli.constant import USER_AGENT
9 | from kimi_cli.soul.toolset import get_current_tool_call_or_none
10 | from kimi_cli.tools.utils import ToolResultBuilder, load_desc
11 | from kimi_cli.utils.aiohttp import new_client_session
12 |
13 |
14 | class Params(BaseModel):
15 | query: str = Field(description="The query text to search for.")
16 | limit: int = Field(
17 | description=(
18 | "The number of results to return. "
19 | "Typically you do not need to set this value. "
20 | "When the results do not contain what you need, "
21 | "you probably want to give a more concrete query."
22 | ),
23 | default=5,
24 | ge=1,
25 | le=20,
26 | )
27 | include_content: bool = Field(
28 | description=(
29 | "Whether to include the content of the web pages in the results. "
30 | "It can consume a large amount of tokens when this is set to True. "
31 | "You should avoid enabling this when `limit` is set to a large value."
32 | ),
33 | default=False,
34 | )
35 |
36 |
37 | class SearchWeb(CallableTool2[Params]):
38 | name: str = "SearchWeb"
39 | description: str = load_desc(Path(__file__).parent / "search.md", {})
40 | params: type[Params] = Params
41 |
42 | def __init__(self, config: Config, **kwargs):
43 | super().__init__(**kwargs)
44 | if config.services.moonshot_search is not None:
45 | self._base_url = config.services.moonshot_search.base_url
46 | self._api_key = config.services.moonshot_search.api_key.get_secret_value()
47 | else:
48 | self._base_url = ""
49 | self._api_key = ""
50 |
51 | @override
52 | async def __call__(self, params: Params) -> ToolReturnType:
53 | builder = ToolResultBuilder(max_line_length=None)
54 |
55 | if not self._base_url or not self._api_key:
56 | return builder.error(
57 | "Search service is not configured. You may want to try other methods to search.",
58 | brief="Search service not configured",
59 | )
60 |
61 | tool_call = get_current_tool_call_or_none()
62 | assert tool_call is not None, "Tool call is expected to be set"
63 |
64 | async with (
65 | new_client_session() as session,
66 | session.post(
67 | self._base_url,
68 | headers={
69 | "User-Agent": USER_AGENT,
70 | "Authorization": f"Bearer {self._api_key}",
71 | "X-Msh-Tool-Call-Id": tool_call.id,
72 | },
73 | json={
74 | "text_query": params.query,
75 | "limit": params.limit,
76 | "enable_page_crawling": params.include_content,
77 | "timeout_seconds": 30,
78 | },
79 | ) as response,
80 | ):
81 | if response.status != 200:
82 | return builder.error(
83 | (
84 | f"Failed to search. Status: {response.status}. "
85 | "This may indicates that the search service is currently unavailable."
86 | ),
87 | brief="Failed to search",
88 | )
89 |
90 | try:
91 | results = Response(**await response.json()).search_results
92 | except ValidationError as e:
93 | return builder.error(
94 | (
95 | f"Failed to parse search results. Error: {e}. "
96 | "This may indicates that the search service is currently unavailable."
97 | ),
98 | brief="Failed to parse search results",
99 | )
100 |
101 | for i, result in enumerate(results):
102 | if i > 0:
103 | builder.write("---\n\n")
104 | builder.write(
105 | f"Title: {result.title}\nDate: {result.date}\n"
106 | f"URL: {result.url}\nSummary: {result.snippet}\n\n"
107 | )
108 | if result.content:
109 | builder.write(f"{result.content}\n\n")
110 |
111 | return builder.ok()
112 |
113 |
114 | class SearchResult(BaseModel):
115 | site_name: str
116 | title: str
117 | url: str
118 | snippet: str
119 | content: str = ""
120 | date: str = ""
121 | icon: str = ""
122 | mime: str = ""
123 |
124 |
125 | class Response(BaseModel):
126 | search_results: list[SearchResult]
127 |
--------------------------------------------------------------------------------
/src/kimi_cli/ui/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonshotAI/kimi-cli/78aae8e00b3848a0af66d8138450ba78091ab3c6/src/kimi_cli/ui/__init__.py
--------------------------------------------------------------------------------
/src/kimi_cli/ui/shell/console.py:
--------------------------------------------------------------------------------
1 | from rich.console import Console
2 | from rich.theme import Theme
3 |
4 | _NEUTRAL_MARKDOWN_THEME = Theme(
5 | {
6 | "markdown.paragraph": "none",
7 | "markdown.block_quote": "none",
8 | "markdown.hr": "none",
9 | "markdown.item": "none",
10 | "markdown.item.bullet": "none",
11 | "markdown.item.number": "none",
12 | "markdown.link": "none",
13 | "markdown.link_url": "none",
14 | "markdown.h1": "none",
15 | "markdown.h1.border": "none",
16 | "markdown.h2": "none",
17 | "markdown.h3": "none",
18 | "markdown.h4": "none",
19 | "markdown.h5": "none",
20 | "markdown.h6": "none",
21 | "markdown.em": "none",
22 | "markdown.strong": "none",
23 | "markdown.s": "none",
24 | "status.spinner": "none",
25 | },
26 | inherit=True,
27 | )
28 |
29 | console = Console(highlight=False, theme=_NEUTRAL_MARKDOWN_THEME)
30 |
--------------------------------------------------------------------------------
/src/kimi_cli/ui/shell/keyboard.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import sys
3 | import termios
4 | import threading
5 | import time
6 | from collections.abc import AsyncGenerator, Callable
7 | from enum import Enum, auto
8 |
9 |
10 | class KeyEvent(Enum):
11 | UP = auto()
12 | DOWN = auto()
13 | LEFT = auto()
14 | RIGHT = auto()
15 | ENTER = auto()
16 | ESCAPE = auto()
17 | TAB = auto()
18 |
19 |
20 | async def listen_for_keyboard() -> AsyncGenerator[KeyEvent]:
21 | loop = asyncio.get_running_loop()
22 | queue = asyncio.Queue[KeyEvent]()
23 | cancel_event = threading.Event()
24 |
25 | def emit(event: KeyEvent) -> None:
26 | # print(f"emit: {event}")
27 | loop.call_soon_threadsafe(queue.put_nowait, event)
28 |
29 | listener = threading.Thread(
30 | target=_listen_for_keyboard_thread,
31 | args=(cancel_event, emit),
32 | name="kimi-cli-keyboard-listener",
33 | daemon=True,
34 | )
35 | listener.start()
36 |
37 | try:
38 | while True:
39 | yield await queue.get()
40 | finally:
41 | cancel_event.set()
42 | if listener.is_alive():
43 | await asyncio.to_thread(listener.join)
44 |
45 |
46 | def _listen_for_keyboard_thread(
47 | cancel: threading.Event,
48 | emit: Callable[[KeyEvent], None],
49 | ) -> None:
50 | # make stdin raw and non-blocking
51 | fd = sys.stdin.fileno()
52 | oldterm = termios.tcgetattr(fd)
53 | newattr = termios.tcgetattr(fd)
54 | newattr[3] = newattr[3] & ~termios.ICANON & ~termios.ECHO
55 | newattr[6][termios.VMIN] = 0
56 | newattr[6][termios.VTIME] = 0
57 | termios.tcsetattr(fd, termios.TCSANOW, newattr)
58 |
59 | try:
60 | while not cancel.is_set():
61 | try:
62 | c = sys.stdin.read(1)
63 | except (OSError, ValueError):
64 | c = ""
65 |
66 | if not c:
67 | if cancel.is_set():
68 | break
69 | time.sleep(0.01)
70 | continue
71 |
72 | if c == "\x1b":
73 | sequence = c
74 | for _ in range(2):
75 | if cancel.is_set():
76 | break
77 | try:
78 | fragment = sys.stdin.read(1)
79 | except (OSError, ValueError):
80 | fragment = ""
81 | if not fragment:
82 | break
83 | sequence += fragment
84 | if sequence in _ARROW_KEY_MAP:
85 | break
86 |
87 | event = _ARROW_KEY_MAP.get(sequence)
88 | if event is not None:
89 | emit(event)
90 | elif sequence == "\x1b":
91 | emit(KeyEvent.ESCAPE)
92 | elif c in ("\r", "\n"):
93 | emit(KeyEvent.ENTER)
94 | elif c == "\t":
95 | emit(KeyEvent.TAB)
96 | finally:
97 | # restore the terminal settings
98 | termios.tcsetattr(fd, termios.TCSAFLUSH, oldterm)
99 |
100 |
101 | _ARROW_KEY_MAP: dict[str, KeyEvent] = {
102 | "\x1b[A": KeyEvent.UP,
103 | "\x1b[B": KeyEvent.DOWN,
104 | "\x1b[C": KeyEvent.RIGHT,
105 | "\x1b[D": KeyEvent.LEFT,
106 | }
107 |
108 |
109 | if __name__ == "__main__":
110 |
111 | async def dev_main():
112 | async for event in listen_for_keyboard():
113 | print(event)
114 |
115 | asyncio.run(dev_main())
116 |
--------------------------------------------------------------------------------
/src/kimi_cli/ui/shell/setup.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from typing import TYPE_CHECKING, NamedTuple
3 |
4 | import aiohttp
5 | from prompt_toolkit import PromptSession
6 | from prompt_toolkit.shortcuts.choice_input import ChoiceInput
7 | from pydantic import SecretStr
8 |
9 | from kimi_cli.config import LLMModel, LLMProvider, MoonshotSearchConfig, load_config, save_config
10 | from kimi_cli.ui.shell.console import console
11 | from kimi_cli.ui.shell.metacmd import meta_command
12 | from kimi_cli.utils.aiohttp import new_client_session
13 |
14 | if TYPE_CHECKING:
15 | from kimi_cli.ui.shell import ShellApp
16 |
17 |
18 | class _Platform(NamedTuple):
19 | id: str
20 | name: str
21 | base_url: str
22 | search_url: str | None = None
23 | allowed_models: list[str] | None = None
24 |
25 |
26 | _PLATFORMS = [
27 | _Platform(
28 | id="kimi-for-coding",
29 | name="Kimi For Coding (CN)",
30 | base_url="https://api.kimi.com/coding/v1",
31 | search_url="https://api.kimi.com/coding/v1/search",
32 | ),
33 | _Platform(
34 | id="moonshot-cn",
35 | name="Moonshot AI 开放平台 (moonshot.cn)",
36 | base_url="https://api.moonshot.cn/v1",
37 | allowed_models=["kimi-k2-turbo-preview", "kimi-k2-0905-preview", "kimi-k2-0711-preview"],
38 | ),
39 | _Platform(
40 | id="moonshot-ai",
41 | name="Moonshot AI Open Platform (moonshot.ai)",
42 | base_url="https://api.moonshot.ai/v1",
43 | allowed_models=["kimi-k2-turbo-preview", "kimi-k2-0905-preview", "kimi-k2-0711-preview"],
44 | ),
45 | ]
46 |
47 |
48 | @meta_command
49 | async def setup(app: "ShellApp", args: list[str]):
50 | """Setup Kimi CLI"""
51 | result = await _setup()
52 | if not result:
53 | # error message already printed
54 | return
55 |
56 | config = load_config()
57 | config.providers[result.platform.id] = LLMProvider(
58 | type="kimi",
59 | base_url=result.platform.base_url,
60 | api_key=result.api_key,
61 | )
62 | config.models[result.model_id] = LLMModel(
63 | provider=result.platform.id,
64 | model=result.model_id,
65 | max_context_size=result.max_context_size,
66 | )
67 | config.default_model = result.model_id
68 |
69 | if result.platform.search_url:
70 | config.services.moonshot_search = MoonshotSearchConfig(
71 | base_url=result.platform.search_url,
72 | api_key=result.api_key,
73 | )
74 |
75 | save_config(config)
76 | console.print("[green]✓[/green] Kimi CLI has been setup! Reloading...")
77 | await asyncio.sleep(1)
78 | console.clear()
79 |
80 | from kimi_cli.cli import Reload
81 |
82 | raise Reload
83 |
84 |
85 | class _SetupResult(NamedTuple):
86 | platform: _Platform
87 | api_key: SecretStr
88 | model_id: str
89 | max_context_size: int
90 |
91 |
92 | async def _setup() -> _SetupResult | None:
93 | # select the API platform
94 | platform_name = await _prompt_choice(
95 | header="Select the API platform",
96 | choices=[platform.name for platform in _PLATFORMS],
97 | )
98 | if not platform_name:
99 | console.print("[red]No platform selected[/red]")
100 | return None
101 |
102 | platform = next(platform for platform in _PLATFORMS if platform.name == platform_name)
103 |
104 | # enter the API key
105 | api_key = await _prompt_text("Enter your API key", is_password=True)
106 | if not api_key:
107 | return None
108 |
109 | # list models
110 | models_url = f"{platform.base_url}/models"
111 | try:
112 | async with (
113 | new_client_session() as session,
114 | session.get(
115 | models_url,
116 | headers={
117 | "Authorization": f"Bearer {api_key}",
118 | },
119 | raise_for_status=True,
120 | ) as response,
121 | ):
122 | resp_json = await response.json()
123 | except aiohttp.ClientError as e:
124 | console.print(f"[red]Failed to get models: {e}[/red]")
125 | return None
126 |
127 | model_dict = {model["id"]: model for model in resp_json["data"]}
128 |
129 | # select the model
130 | if platform.allowed_models is None:
131 | model_ids = [model["id"] for model in resp_json["data"]]
132 | else:
133 | id_set = set(model["id"] for model in resp_json["data"])
134 | model_ids = [model_id for model_id in platform.allowed_models if model_id in id_set]
135 |
136 | if not model_ids:
137 | console.print("[red]No models available for the selected platform[/red]")
138 | return None
139 |
140 | model_id = await _prompt_choice(
141 | header="Select the model",
142 | choices=model_ids,
143 | )
144 | if not model_id:
145 | console.print("[red]No model selected[/red]")
146 | return None
147 |
148 | model = model_dict[model_id]
149 |
150 | return _SetupResult(
151 | platform=platform,
152 | api_key=SecretStr(api_key),
153 | model_id=model_id,
154 | max_context_size=model["context_length"],
155 | )
156 |
157 |
158 | async def _prompt_choice(*, header: str, choices: list[str]) -> str | None:
159 | if not choices:
160 | return None
161 |
162 | try:
163 | return await ChoiceInput(
164 | message=header,
165 | options=[(choice, choice) for choice in choices],
166 | default=choices[0],
167 | ).prompt_async()
168 | except (EOFError, KeyboardInterrupt):
169 | return None
170 |
171 |
172 | async def _prompt_text(prompt: str, *, is_password: bool = False) -> str | None:
173 | session = PromptSession()
174 | try:
175 | return str(
176 | await session.prompt_async(
177 | f" {prompt}: ",
178 | is_password=is_password,
179 | )
180 | ).strip()
181 | except (EOFError, KeyboardInterrupt):
182 | return None
183 |
184 |
185 | @meta_command
186 | def reload(app: "ShellApp", args: list[str]):
187 | """Reload configuration"""
188 | from kimi_cli.cli import Reload
189 |
190 | raise Reload
191 |
--------------------------------------------------------------------------------
/src/kimi_cli/ui/shell/visualize.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from contextlib import asynccontextmanager, suppress
3 |
4 | from kosong.base.message import ContentPart, TextPart, ToolCall, ToolCallPart
5 | from kosong.tooling import ToolResult
6 |
7 | from kimi_cli.soul import StatusSnapshot
8 | from kimi_cli.ui.shell.console import console
9 | from kimi_cli.ui.shell.keyboard import listen_for_keyboard
10 | from kimi_cli.ui.shell.liveview import StepLiveView, StepLiveViewWithMarkdown
11 | from kimi_cli.utils.logging import logger
12 | from kimi_cli.wire import WireUISide
13 | from kimi_cli.wire.message import (
14 | ApprovalRequest,
15 | CompactionBegin,
16 | CompactionEnd,
17 | StatusUpdate,
18 | StepBegin,
19 | StepInterrupted,
20 | )
21 |
22 |
23 | @asynccontextmanager
24 | async def _keyboard_listener(step: StepLiveView):
25 | async def _keyboard():
26 | try:
27 | async for event in listen_for_keyboard():
28 | step.handle_keyboard_event(event)
29 | except asyncio.CancelledError:
30 | return
31 |
32 | task = asyncio.create_task(_keyboard())
33 | try:
34 | yield
35 | finally:
36 | task.cancel()
37 | with suppress(asyncio.CancelledError):
38 | await task
39 |
40 |
41 | async def visualize(
42 | wire: WireUISide,
43 | *,
44 | initial_status: StatusSnapshot,
45 | cancel_event: asyncio.Event | None = None,
46 | ):
47 | """
48 | A loop to consume agent events and visualize the agent behavior.
49 | This loop never raise any exception except asyncio.CancelledError.
50 |
51 | Args:
52 | wire: Communication channel with the agent
53 | initial_status: Initial status snapshot
54 | cancel_event: Event that can be set (e.g., by ESC key) to cancel the run
55 | """
56 | latest_status = initial_status
57 | try:
58 | # expect a StepBegin
59 | assert isinstance(await wire.receive(), StepBegin)
60 |
61 | while True:
62 | # TODO: Maybe we can always have a StepLiveView here.
63 | # No need to recreate for each step.
64 | with StepLiveViewWithMarkdown(latest_status, cancel_event) as step:
65 | async with _keyboard_listener(step):
66 | # spin the moon at the beginning of each step
67 | with console.status("", spinner="moon"):
68 | msg = await wire.receive()
69 |
70 | if isinstance(msg, CompactionBegin):
71 | with console.status("[cyan]Compacting...[/cyan]"):
72 | msg = await wire.receive()
73 | if isinstance(msg, StepInterrupted):
74 | break
75 | assert isinstance(msg, CompactionEnd)
76 | continue
77 |
78 | # visualization loop for one step
79 | while True:
80 | match msg:
81 | case TextPart(text=text):
82 | step.append_text(text)
83 | case ContentPart():
84 | # TODO: support more content parts
85 | step.append_text(f"[{msg.__class__.__name__}]")
86 | case ToolCall():
87 | step.append_tool_call(msg)
88 | case ToolCallPart():
89 | step.append_tool_call_part(msg)
90 | case ToolResult():
91 | step.append_tool_result(msg)
92 | case ApprovalRequest():
93 | step.request_approval(msg)
94 | case StatusUpdate(status=status):
95 | latest_status = status
96 | step.update_status(latest_status)
97 | case _:
98 | break # break the step loop
99 | msg = await wire.receive()
100 |
101 | # cleanup the step live view
102 | if isinstance(msg, StepInterrupted):
103 | step.interrupt()
104 | else:
105 | step.finish()
106 |
107 | if isinstance(msg, StepInterrupted):
108 | # for StepInterrupted, the visualization loop should end immediately
109 | break
110 |
111 | assert isinstance(msg, StepBegin), "expect a StepBegin"
112 | # start a new step
113 | except asyncio.QueueShutDown:
114 | logger.debug("Visualization loop shutting down")
115 |
--------------------------------------------------------------------------------
/src/kimi_cli/utils/aiohttp.py:
--------------------------------------------------------------------------------
1 | import ssl
2 |
3 | import aiohttp
4 | import certifi
5 |
6 | _ssl_context = ssl.create_default_context(cafile=certifi.where())
7 |
8 |
9 | def new_client_session() -> aiohttp.ClientSession:
10 | return aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=_ssl_context))
11 |
--------------------------------------------------------------------------------
/src/kimi_cli/utils/changelog.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import NamedTuple
3 |
4 |
5 | class ReleaseEntry(NamedTuple):
6 | description: str
7 | entries: list[str]
8 |
9 |
10 | def parse_changelog(md_text: str) -> dict[str, ReleaseEntry]:
11 | """Parse a subset of Keep a Changelog-style markdown into a map:
12 | version -> (description, entries)
13 |
14 | Parsing rules:
15 | - Versions are denoted by level-2 headings starting with '## ['
16 | Example: `## [v0.10.1] - 2025-09-18` or `## [Unreleased]`
17 | - For each version section, description is the first contiguous block of
18 | non-empty lines that do not start with '-' or '#'.
19 | - Entries are all markdown list items starting with '- ' under that version
20 | (across any subheadings like '### Added').
21 | """
22 | lines = md_text.splitlines()
23 | result: dict[str, ReleaseEntry] = {}
24 |
25 | current_ver: str | None = None
26 | collecting_desc = False
27 | desc_lines: list[str] = []
28 | bullet_lines: list[str] = []
29 | seen_content_after_header = False
30 |
31 | def commit():
32 | nonlocal current_ver, desc_lines, bullet_lines, result
33 | if current_ver is None:
34 | return
35 | description = "\n".join([line.strip() for line in desc_lines]).strip()
36 | # Deduplicate and normalize entries
37 | norm_entries = [
38 | line.strip()[2:].strip() for line in bullet_lines if line.strip().startswith("- ")
39 | ]
40 | result[current_ver] = ReleaseEntry(description=description, entries=norm_entries)
41 |
42 | for raw in lines:
43 | line = raw.rstrip()
44 | if line.startswith("## ["):
45 | # New version section, flush previous
46 | commit()
47 | # Extract version token inside brackets
48 | end = line.find("]")
49 | ver = line[4:end] if end != -1 else line[3:].strip()
50 | current_ver = ver.strip()
51 | desc_lines = []
52 | bullet_lines = []
53 | collecting_desc = True
54 | seen_content_after_header = False
55 | continue
56 |
57 | if current_ver is None:
58 | # Skip until first version section
59 | continue
60 |
61 | if not line.strip():
62 | # blank line ends initial description block only after we've seen content
63 | if collecting_desc and seen_content_after_header:
64 | collecting_desc = False
65 | continue
66 |
67 | seen_content_after_header = True
68 |
69 | if line.lstrip().startswith("### "):
70 | collecting_desc = False
71 | continue
72 |
73 | if line.lstrip().startswith("- "):
74 | collecting_desc = False
75 | bullet_lines.append(line.strip())
76 | continue
77 |
78 | if collecting_desc:
79 | # Accumulate description until a blank line or bullets/subheadings
80 | desc_lines.append(line.strip())
81 | # else: ignore any other free-form text after description block
82 |
83 | # Final flush
84 | commit()
85 | return result
86 |
87 |
88 | def format_release_notes(changelog: dict[str, ReleaseEntry]) -> str:
89 | parts: list[str] = []
90 | for ver, entry in changelog.items():
91 | s = f"[bold]{ver}[/bold]"
92 | if entry.description:
93 | s += f": {entry.description}"
94 | if entry.entries:
95 | for it in entry.entries:
96 | s += "\n[markdown.item.bullet]• [/]" + it
97 | parts.append(s + "\n")
98 | return "\n".join(parts).strip()
99 |
100 |
101 | CHANGELOG = parse_changelog(
102 | (Path(__file__).parent.parent / "CHANGELOG.md").read_text(encoding="utf-8")
103 | )
104 |
--------------------------------------------------------------------------------
/src/kimi_cli/utils/logging.py:
--------------------------------------------------------------------------------
1 | from typing import IO
2 |
3 | from loguru import logger
4 |
5 | logger.remove()
6 |
7 |
8 | class StreamToLogger(IO[str]):
9 | def __init__(self, level: str = "ERROR"):
10 | self._level = level
11 |
12 | def write(self, buffer: str) -> int:
13 | for line in buffer.rstrip().splitlines():
14 | logger.opt(depth=1).log(self._level, line.rstrip())
15 | return len(buffer)
16 |
17 | def flush(self) -> None:
18 | pass
19 |
--------------------------------------------------------------------------------
/src/kimi_cli/utils/message.py:
--------------------------------------------------------------------------------
1 | from kosong.base.message import Message, TextPart
2 |
3 |
4 | def message_extract_text(message: Message) -> str:
5 | """Extract text from a message."""
6 | if isinstance(message.content, str):
7 | return message.content
8 | return "\n".join(part.text for part in message.content if isinstance(part, TextPart))
9 |
--------------------------------------------------------------------------------
/src/kimi_cli/utils/path.py:
--------------------------------------------------------------------------------
1 | import re
2 | from pathlib import Path
3 |
4 | import aiofiles.os
5 |
6 |
7 | async def next_available_rotation(path: Path) -> Path | None:
8 | """
9 | Find the next available rotation path for a given path.
10 | """
11 | if not path.parent.exists():
12 | return None
13 | base_name = path.stem
14 | suffix = path.suffix
15 | pattern = re.compile(rf"^{re.escape(base_name)}_(\d+){re.escape(suffix)}$")
16 | max_num = 0
17 | # FIXME: protect from race condition
18 | for p in await aiofiles.os.listdir(path.parent):
19 | if m := pattern.match(p):
20 | max_num = max(max_num, int(m.group(1)))
21 | next_num = max_num + 1
22 | next_path = path.parent / f"{base_name}_{next_num}{suffix}"
23 | return next_path
24 |
--------------------------------------------------------------------------------
/src/kimi_cli/utils/pyinstaller.py:
--------------------------------------------------------------------------------
1 | from PyInstaller.utils.hooks import collect_data_files, collect_submodules
2 |
3 | hiddenimports = collect_submodules("kimi_cli.tools")
4 | datas = (
5 | collect_data_files(
6 | "kimi_cli",
7 | includes=[
8 | "agents/**/*.yaml",
9 | "agents/**/*.md",
10 | "deps/bin/**",
11 | "prompts/**/*.md",
12 | "tools/**/*.md",
13 | "CHANGELOG.md",
14 | ],
15 | )
16 | + collect_data_files(
17 | "dateparser",
18 | includes=["**/*.pkl"],
19 | )
20 | + collect_data_files(
21 | "fastmcp",
22 | includes=["../fastmcp-*.dist-info/*"],
23 | )
24 | )
25 |
--------------------------------------------------------------------------------
/src/kimi_cli/utils/string.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | _NEWLINE_RE = re.compile(r"[\r\n]+")
4 |
5 |
6 | def shorten_middle(text: str, width: int, remove_newline: bool = True) -> str:
7 | """Shorten the text by inserting ellipsis in the middle."""
8 | if len(text) <= width:
9 | return text
10 | if remove_newline:
11 | text = _NEWLINE_RE.sub(" ", text)
12 | return text[: width // 2] + "..." + text[-width // 2 :]
13 |
--------------------------------------------------------------------------------
/src/kimi_cli/wire/__init__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from kosong.base.message import ContentPart, ToolCallPart
4 |
5 | from kimi_cli.utils.logging import logger
6 | from kimi_cli.wire.message import WireMessage
7 |
8 |
9 | class Wire:
10 | """
11 | A channel for communication between the soul and the UI during a soul run.
12 | """
13 |
14 | def __init__(self):
15 | self._queue = asyncio.Queue[WireMessage]()
16 | self._soul_side = WireSoulSide(self._queue)
17 | self._ui_side = WireUISide(self._queue)
18 |
19 | @property
20 | def soul_side(self) -> "WireSoulSide":
21 | return self._soul_side
22 |
23 | @property
24 | def ui_side(self) -> "WireUISide":
25 | return self._ui_side
26 |
27 | def shutdown(self) -> None:
28 | self._queue.shutdown()
29 |
30 |
31 | class WireSoulSide:
32 | """
33 | The soul side of a wire.
34 | """
35 |
36 | def __init__(self, queue: asyncio.Queue[WireMessage]):
37 | self._queue = queue
38 |
39 | def send(self, msg: WireMessage) -> None:
40 | if not isinstance(msg, ContentPart | ToolCallPart):
41 | logger.debug("Sending wire message: {msg}", msg=msg)
42 | self._queue.put_nowait(msg)
43 |
44 |
45 | class WireUISide:
46 | """
47 | The UI side of a wire.
48 | """
49 |
50 | def __init__(self, queue: asyncio.Queue[WireMessage]):
51 | self._queue = queue
52 |
53 | async def receive(self) -> WireMessage:
54 | msg = await self._queue.get()
55 | if not isinstance(msg, ContentPart | ToolCallPart):
56 | logger.debug("Receiving wire message: {msg}", msg=msg)
57 | return msg
58 |
59 | def receive_nowait(self) -> WireMessage | None:
60 | """
61 | Try receive a message without waiting. If no message is available, return None.
62 | """
63 | try:
64 | msg = self._queue.get_nowait()
65 | except asyncio.QueueEmpty:
66 | return None
67 | if not isinstance(msg, ContentPart | ToolCallPart):
68 | logger.debug("Receiving wire message: {msg}", msg=msg)
69 | return msg
70 |
--------------------------------------------------------------------------------
/src/kimi_cli/wire/message.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import uuid
3 | from enum import Enum
4 | from typing import TYPE_CHECKING, NamedTuple
5 |
6 | from kosong.base.message import ContentPart, ToolCall, ToolCallPart
7 | from kosong.tooling import ToolResult
8 |
9 | if TYPE_CHECKING:
10 | from kimi_cli.soul import StatusSnapshot
11 |
12 |
13 | class StepBegin(NamedTuple):
14 | n: int
15 |
16 |
17 | class StepInterrupted:
18 | pass
19 |
20 |
21 | class CompactionBegin:
22 | """
23 | Indicates that a compaction just began.
24 | This event must be sent during a step, which means, between `StepBegin` and `StepInterrupted`.
25 | And, there must be a `CompactionEnd` directly following this event.
26 | """
27 |
28 | pass
29 |
30 |
31 | class CompactionEnd:
32 | """
33 | Indicates that a compaction just ended.
34 | This event must be sent directly after a `CompactionBegin` event.
35 | """
36 |
37 | pass
38 |
39 |
40 | class StatusUpdate(NamedTuple):
41 | status: "StatusSnapshot"
42 |
43 |
44 | type ControlFlowEvent = StepBegin | StepInterrupted | CompactionBegin | CompactionEnd | StatusUpdate
45 | type Event = ControlFlowEvent | ContentPart | ToolCall | ToolCallPart | ToolResult
46 |
47 |
48 | class ApprovalResponse(Enum):
49 | APPROVE = "approve"
50 | APPROVE_FOR_SESSION = "approve_for_session"
51 | REJECT = "reject"
52 |
53 |
54 | class ApprovalRequest:
55 | def __init__(self, tool_call_id: str, sender: str, action: str, description: str):
56 | self.id = str(uuid.uuid4())
57 | self.tool_call_id = tool_call_id
58 | self.sender = sender
59 | self.action = action
60 | self.description = description
61 | self._future = asyncio.Future[ApprovalResponse]()
62 |
63 | def __repr__(self) -> str:
64 | return (
65 | f"ApprovalRequest(id={self.id}, tool_call_id={self.tool_call_id}, "
66 | f"sender={self.sender}, action={self.action}, description={self.description})"
67 | )
68 |
69 | async def wait(self) -> ApprovalResponse:
70 | """
71 | Wait for the request to be resolved or cancelled.
72 |
73 | Returns:
74 | ApprovalResponse: The response to the approval request.
75 | """
76 | return await self._future
77 |
78 | def resolve(self, response: ApprovalResponse) -> None:
79 | """
80 | Resolve the approval request with the given response.
81 | This will cause the `wait()` method to return the response.
82 | """
83 | self._future.set_result(response)
84 |
85 | @property
86 | def resolved(self) -> bool:
87 | """Whether the request is resolved."""
88 | return self._future.done()
89 |
90 |
91 | type WireMessage = Event | ApprovalRequest
92 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonshotAI/kimi-cli/78aae8e00b3848a0af66d8138450ba78091ab3c6/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_changelog.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from kimi_cli.utils.changelog import ReleaseEntry, parse_changelog
4 |
5 |
6 | def test_changelog_parser():
7 | changelog = """
8 | # Changelog
9 |
10 |
18 |
19 | ## [Unreleased]
20 |
21 | ### Added
22 | - Added /release-notes command
23 |
24 | ### Fixed
25 | - Fixed a bug
26 |
27 | ## [v0.10.1] - 2025-09-18
28 |
29 | We now have release notes!
30 | - Made slash commands look slightly better
31 | """
32 | assert {
33 | "Unreleased": ReleaseEntry(
34 | description="", entries=["Added /release-notes command", "Fixed a bug"]
35 | ),
36 | "v0.10.1": ReleaseEntry(
37 | description="We now have release notes!",
38 | entries=["Made slash commands look slightly better"],
39 | ),
40 | } == parse_changelog(changelog)
41 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 |
3 | from kimi_cli.config import (
4 | Config,
5 | Services,
6 | get_default_config,
7 | )
8 |
9 |
10 | def test_default_config():
11 | config = get_default_config()
12 | assert config == snapshot(
13 | Config(
14 | default_model="",
15 | models={},
16 | providers={},
17 | services=Services(),
18 | )
19 | )
20 |
21 |
22 | def test_default_config_dump():
23 | config = get_default_config()
24 | assert config.model_dump_json(indent=2, exclude_none=True) == snapshot(
25 | """\
26 | {
27 | "default_model": "",
28 | "models": {},
29 | "providers": {},
30 | "loop_control": {
31 | "max_steps_per_run": 100,
32 | "max_retries_per_step": 3
33 | },
34 | "services": {}
35 | }\
36 | """
37 | )
38 |
--------------------------------------------------------------------------------
/tests/test_fetch_url.py:
--------------------------------------------------------------------------------
1 | # ruff: noqa
2 |
3 | """Tests for WebFetch tool."""
4 |
5 | import pytest
6 | from inline_snapshot import snapshot
7 | from kosong.tooling import ToolError, ToolOk
8 |
9 | from kimi_cli.tools.web.fetch import FetchURL, Params
10 |
11 |
12 | @pytest.mark.asyncio
13 | async def test_fetch_url_basic_functionality(fetch_url_tool: FetchURL) -> None:
14 | """Test basic WebFetch functionality."""
15 | # Test with a reliable website that has content
16 | test_url = "https://github.com/MoonshotAI/Moonlight/issues/4"
17 |
18 | result = await fetch_url_tool(Params(url=test_url))
19 |
20 | assert isinstance(result, ToolOk)
21 | assert result.output == snapshot(
22 | """\
23 | ---
24 | title: Typo: adamw vs adamW · Issue #4 · MoonshotAI/Moonlight
25 | author: MoonshotAI
26 | url: https://github.com/MoonshotAI/Moonlight/issues/4
27 | hostname: github.com
28 | description: The default parameter value for optimizer should probably be adamw instead of adamW according to how get_optimizer is written.
29 | sitename: GitHub
30 | date: 2025-02-23
31 | categories: ['issue:2873381615']
32 | ---
33 | The default parameter value for `optimizer` should probably be `adamw` instead of `adamW` according to how `get_optimizer` is written.\
34 | """
35 | )
36 |
37 |
38 | @pytest.mark.asyncio
39 | async def test_fetch_url_invalid_url(fetch_url_tool: FetchURL) -> None:
40 | """Test fetching from an invalid URL."""
41 | result = await fetch_url_tool(
42 | Params(url="https://this-domain-definitely-does-not-exist-12345.com/")
43 | )
44 |
45 | # Should fail with network error
46 | assert isinstance(result, ToolError)
47 | assert "Failed to fetch URL due to network error:" in result.message
48 |
49 |
50 | @pytest.mark.asyncio
51 | async def test_fetch_url_404_url(fetch_url_tool: FetchURL) -> None:
52 | """Test fetching from a URL that returns 404."""
53 | result = await fetch_url_tool(
54 | Params(url="https://github.com/MoonshotAI/non-existing-repo/issues/1")
55 | )
56 |
57 | # Should fail with HTTP error
58 | assert isinstance(result, ToolError)
59 | assert result.message == snapshot(
60 | "Failed to fetch URL. Status: 404. This may indicate the page is not accessible or the server is down."
61 | )
62 |
63 |
64 | @pytest.mark.asyncio
65 | async def test_fetch_url_malformed_url(fetch_url_tool: FetchURL) -> None:
66 | """Test fetching from a malformed URL."""
67 | result = await fetch_url_tool(Params(url="not-a-valid-url"))
68 |
69 | # Should fail
70 | assert isinstance(result, ToolError)
71 | assert result.message == snapshot(
72 | "Failed to fetch URL due to network error: not-a-valid-url. This may indicate the URL is invalid or the server is unreachable."
73 | )
74 |
75 |
76 | @pytest.mark.asyncio
77 | async def test_fetch_url_empty_url(fetch_url_tool: FetchURL) -> None:
78 | """Test fetching with empty URL."""
79 | result = await fetch_url_tool(Params(url=""))
80 |
81 | # Should fail
82 | assert isinstance(result, ToolError)
83 | assert result.message == snapshot(
84 | "Failed to fetch URL due to network error: . This may indicate the URL is invalid or the server is unreachable."
85 | )
86 |
87 |
88 | @pytest.mark.asyncio
89 | async def test_fetch_url_javascript_driven_site(fetch_url_tool: FetchURL) -> None:
90 | """Test fetching from a JavaScript-driven site that may not work with trafilatura."""
91 | result = await fetch_url_tool(Params(url="https://www.moonshot.ai/"))
92 |
93 | # This may fail due to JavaScript rendering requirements
94 | # If it fails, should indicate extraction issues
95 | if isinstance(result, ToolError):
96 | assert "failed to extract meaningful content" in result.message.lower()
97 |
--------------------------------------------------------------------------------
/tests/test_file_completer.py:
--------------------------------------------------------------------------------
1 | """Tests for the shell file mention completer."""
2 |
3 | from pathlib import Path
4 |
5 | from prompt_toolkit.completion import CompleteEvent
6 | from prompt_toolkit.document import Document
7 |
8 | from kimi_cli.ui.shell.prompt import FileMentionCompleter
9 |
10 |
11 | def _completion_texts(completer: FileMentionCompleter, text: str) -> list[str]:
12 | document = Document(text=text, cursor_position=len(text))
13 | event = CompleteEvent(completion_requested=True)
14 | return [completion.text for completion in completer.get_completions(document, event)]
15 |
16 |
17 | def test_top_level_paths_skip_ignored_names(tmp_path: Path):
18 | """Only surface non-ignored entries when completing the top level."""
19 | (tmp_path / "src").mkdir()
20 | (tmp_path / "node_modules").mkdir()
21 | (tmp_path / ".DS_Store").write_text("")
22 | (tmp_path / "README.md").write_text("hello")
23 |
24 | completer = FileMentionCompleter(tmp_path)
25 |
26 | texts = _completion_texts(completer, "@")
27 |
28 | assert "src/" in texts
29 | assert "README.md" in texts
30 | assert "node_modules/" not in texts
31 | assert ".DS_Store" not in texts
32 |
33 |
34 | def test_directory_completion_continues_after_slash(tmp_path: Path):
35 | """Continue descending when the fragment ends with a slash."""
36 | src = tmp_path / "src"
37 | src.mkdir()
38 | nested = src / "module.py"
39 | nested.write_text("print('hi')\n")
40 |
41 | completer = FileMentionCompleter(tmp_path)
42 |
43 | texts = _completion_texts(completer, "@src/")
44 |
45 | assert "src/" in texts
46 | assert "src/module.py" in texts
47 |
48 |
49 | def test_completed_file_short_circuits_completions(tmp_path: Path):
50 | """Stop offering fuzzy matches once the fragment resolves to an existing file."""
51 | agents = tmp_path / "AGENTS.md"
52 | agents.write_text("# Agents\n")
53 |
54 | nested_dir = tmp_path / "src" / "kimi_cli" / "agents"
55 | nested_dir.mkdir(parents=True)
56 | (nested_dir / "README.md").write_text("nested\n")
57 |
58 | completer = FileMentionCompleter(tmp_path)
59 |
60 | texts = _completion_texts(completer, "@AGENTS.md")
61 |
62 | assert not texts
63 |
64 |
65 | def test_limit_is_enforced(tmp_path: Path):
66 | """Respect the configured limit when building top-level candidates."""
67 | for index in range(10):
68 | (tmp_path / f"dir{index}").mkdir()
69 | for index in range(10):
70 | (tmp_path / f"file{index}.txt").write_text("x")
71 |
72 | limit = 8
73 | completer = FileMentionCompleter(tmp_path, limit=limit)
74 |
75 | texts = _completion_texts(completer, "@")
76 |
77 | assert len(set(texts)) == limit
78 |
79 |
80 | def test_at_guard_prevents_email_like_fragments(tmp_path: Path):
81 | """Ignore `@` that are embedded inside identifiers (e.g. emails)."""
82 | (tmp_path / "example.py").write_text("")
83 |
84 | completer = FileMentionCompleter(tmp_path)
85 |
86 | texts = _completion_texts(completer, "email@example.com")
87 |
88 | assert not texts
89 |
--------------------------------------------------------------------------------
/tests/test_load_agent.py:
--------------------------------------------------------------------------------
1 | """Tests for agent loading functionality."""
2 |
3 | import tempfile
4 | from collections.abc import Generator
5 | from pathlib import Path
6 | from typing import Any
7 |
8 | import pytest
9 |
10 | from kimi_cli.config import Config
11 | from kimi_cli.metadata import Session
12 | from kimi_cli.soul.agent import _load_system_prompt, _load_tools, load_agent
13 | from kimi_cli.soul.approval import Approval
14 | from kimi_cli.soul.denwarenji import DenwaRenji
15 | from kimi_cli.soul.globals import AgentGlobals, BuiltinSystemPromptArgs
16 | from kimi_cli.soul.toolset import CustomToolset
17 |
18 |
19 | def test_load_system_prompt(system_prompt_file: Path, builtin_args: BuiltinSystemPromptArgs):
20 | """Test loading system prompt with template substitution."""
21 | prompt = _load_system_prompt(system_prompt_file, {"CUSTOM_ARG": "test_value"}, builtin_args)
22 |
23 | assert "Test system prompt with " in prompt
24 | assert "1970-01-01" in prompt # Should contain the actual timestamp
25 | assert builtin_args.KIMI_NOW in prompt
26 | assert "test_value" in prompt
27 |
28 |
29 | def test_load_tools_valid(agent_globals: AgentGlobals):
30 | """Test loading valid tools."""
31 | tool_paths = ["kimi_cli.tools.think:Think", "kimi_cli.tools.bash:Bash"]
32 | toolset = CustomToolset()
33 | bad_tools = _load_tools(
34 | toolset,
35 | tool_paths,
36 | {
37 | AgentGlobals: agent_globals,
38 | Config: agent_globals.config,
39 | BuiltinSystemPromptArgs: agent_globals.builtin_args,
40 | Session: agent_globals.session,
41 | DenwaRenji: agent_globals.denwa_renji,
42 | Approval: agent_globals.approval,
43 | },
44 | )
45 |
46 | assert len(bad_tools) == 0
47 | assert toolset is not None
48 |
49 |
50 | def test_load_tools_invalid(agent_globals: AgentGlobals):
51 | """Test loading with invalid tool paths."""
52 | tool_paths = ["kimi_cli.tools.nonexistent:Tool", "kimi_cli.tools.think:Think"]
53 | toolset = CustomToolset()
54 | bad_tools = _load_tools(
55 | toolset,
56 | tool_paths,
57 | {
58 | AgentGlobals: agent_globals,
59 | Config: agent_globals.config,
60 | BuiltinSystemPromptArgs: agent_globals.builtin_args,
61 | Session: agent_globals.session,
62 | DenwaRenji: agent_globals.denwa_renji,
63 | Approval: agent_globals.approval,
64 | },
65 | )
66 |
67 | assert len(bad_tools) == 1
68 | assert "kimi_cli.tools.nonexistent:Tool" in bad_tools
69 |
70 |
71 | def test_load_agent_invalid_tools(agent_file_invalid_tools: Path, agent_globals: AgentGlobals):
72 | """Test loading agent with invalid tools raises ValueError."""
73 | with pytest.raises(ValueError, match="Invalid tools"):
74 | load_agent(agent_file_invalid_tools, agent_globals)
75 |
76 |
77 | @pytest.fixture
78 | def agent_file_invalid_tools() -> Generator[Path, Any, Any]:
79 | """Create an agent configuration file with invalid tools."""
80 | with tempfile.TemporaryDirectory() as tmpdir:
81 | tmpdir = Path(tmpdir)
82 |
83 | # Create system.md
84 | system_md = tmpdir / "system.md"
85 | system_md.write_text("You are a test agent")
86 |
87 | # Create agent.yaml with invalid tools
88 | agent_yaml = tmpdir / "agent.yaml"
89 | agent_yaml.write_text("""
90 | version: 1
91 | agent:
92 | name: "Test Agent"
93 | system_prompt_path: ./system.md
94 | tools: ["kimi_cli.tools.nonexistent:Tool"]
95 | """)
96 |
97 | yield agent_yaml
98 |
99 |
100 | @pytest.fixture
101 | def system_prompt_file() -> Generator[Path, Any, Any]:
102 | """Create a system prompt file with template variables."""
103 | with tempfile.TemporaryDirectory() as tmpdir:
104 | tmpdir = Path(tmpdir)
105 |
106 | system_md = tmpdir / "system.md"
107 | system_md.write_text("Test system prompt with ${KIMI_NOW} and ${CUSTOM_ARG}")
108 |
109 | yield system_md
110 |
--------------------------------------------------------------------------------
/tests/test_load_agents_md.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from kimi_cli.soul.globals import load_agents_md
4 |
5 |
6 | def test_load_agents_md_found(temp_work_dir: Path):
7 | """Test loading AGENTS.md when it exists."""
8 | agents_md = temp_work_dir / "AGENTS.md"
9 | agents_md.write_text("Test agents content")
10 |
11 | content = load_agents_md(temp_work_dir)
12 |
13 | assert content == "Test agents content"
14 |
15 |
16 | def test_load_agents_md_not_found(temp_work_dir: Path):
17 | """Test loading AGENTS.md when it doesn't exist."""
18 | content = load_agents_md(temp_work_dir)
19 |
20 | assert content is None
21 |
22 |
23 | def test_load_agents_md_lowercase(temp_work_dir: Path):
24 | """Test loading agents.md (lowercase)."""
25 | agents_md = temp_work_dir / "agents.md"
26 | agents_md.write_text("Lowercase agents content")
27 |
28 | content = load_agents_md(temp_work_dir)
29 |
30 | assert content == "Lowercase agents content"
31 |
--------------------------------------------------------------------------------
/tests/test_message_utils.py:
--------------------------------------------------------------------------------
1 | """Tests for message utility functions."""
2 |
3 | from kosong.base.message import ImageURLPart, Message, TextPart
4 |
5 | from kimi_cli.utils.message import message_extract_text
6 |
7 |
8 | def test_extract_text_from_string_content():
9 | """Test extracting text from message with string content."""
10 | message = Message(role="user", content="Simple text")
11 | result = message_extract_text(message)
12 |
13 | assert result == "Simple text"
14 |
15 |
16 | def test_extract_text_from_content_parts():
17 | """Test extracting text from message with content parts."""
18 | text_part1 = TextPart(text="Hello")
19 | text_part2 = TextPart(text="World")
20 | image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg"))
21 |
22 | message = Message(role="user", content=[text_part1, image_part, text_part2])
23 | result = message_extract_text(message)
24 |
25 | assert result == "Hello\nWorld"
26 |
27 |
28 | def test_extract_text_from_empty_content_parts():
29 | """Test extracting text from message with no text parts."""
30 | image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg"))
31 | message = Message(role="user", content=[image_part])
32 | result = message_extract_text(message)
33 |
34 | assert result == ""
35 |
36 |
37 | def test_extract_text_from_empty_string():
38 | """Test extracting text from empty string content."""
39 | message = Message(role="user", content="")
40 | result = message_extract_text(message)
41 |
42 | assert result == ""
43 |
--------------------------------------------------------------------------------
/tests/test_metacmd.py:
--------------------------------------------------------------------------------
1 | """Tests for meta command functionality using inline-snapshot."""
2 |
3 | from typing import Any
4 |
5 | import pytest
6 | from inline_snapshot import snapshot
7 |
8 | from kimi_cli.ui.shell.metacmd import _meta_command_aliases, _meta_commands, meta_command
9 |
10 |
11 | def check_meta_commands(snapshot: Any):
12 | """Usage: check_meta_commands(snapshot()), then `uv run pytest` will update the snapshot."""
13 |
14 | import json
15 |
16 | pretty_meta_commands = json.dumps(
17 | {
18 | alias: f"{cmd.slash_name()}: {cmd.description}"
19 | for (alias, cmd) in _meta_command_aliases.items()
20 | },
21 | indent=2,
22 | sort_keys=True,
23 | )
24 | assert pretty_meta_commands == snapshot
25 |
26 |
27 | @pytest.fixture(autouse=True)
28 | def clear_meta_commands():
29 | """Clear meta commands before and after each test."""
30 | original = _meta_commands.copy()
31 | original_aliases = _meta_command_aliases.copy()
32 | _meta_commands.clear()
33 | _meta_command_aliases.clear()
34 | yield
35 | _meta_commands.clear()
36 | _meta_commands.update(original)
37 | _meta_command_aliases.clear()
38 | _meta_command_aliases.update(original_aliases)
39 |
40 |
41 | def test_meta_command_registration():
42 | """Test all meta command registration scenarios."""
43 |
44 | # Basic registration
45 | @meta_command
46 | def basic(app, args):
47 | """Basic command."""
48 | pass
49 |
50 | # Custom name, original name should be ignored
51 | @meta_command(name="run")
52 | def start(app, args):
53 | """Run something."""
54 | pass
55 |
56 | # Aliases only, original name should be kept
57 | @meta_command(aliases=["h", "?"])
58 | def help(app, args):
59 | """Show help."""
60 | pass
61 |
62 | # Custom name with aliases
63 | @meta_command(name="search", aliases=["s", "find"])
64 | def query(app, args):
65 | """Search items."""
66 | pass
67 |
68 | # Edge cases: no doc, whitespace doc, duplicate aliases
69 | @meta_command
70 | def no_doc(app, args):
71 | pass
72 |
73 | @meta_command
74 | def whitespace_doc(app, args):
75 | """\n\t"""
76 | pass
77 |
78 | @meta_command(aliases=["dup", "dup"])
79 | def dedup_test(app, args):
80 | """Test deduplication."""
81 | pass
82 |
83 | check_meta_commands(
84 | snapshot("""\
85 | {
86 | "?": "/help (h, ?): Show help.",
87 | "basic": "/basic: Basic command.",
88 | "dedup_test": "/dedup_test (dup, dup): Test deduplication.",
89 | "dup": "/dedup_test (dup, dup): Test deduplication.",
90 | "find": "/search (s, find): Search items.",
91 | "h": "/help (h, ?): Show help.",
92 | "help": "/help (h, ?): Show help.",
93 | "no_doc": "/no_doc: ",
94 | "run": "/run: Run something.",
95 | "s": "/search (s, find): Search items.",
96 | "search": "/search (s, find): Search items.",
97 | "whitespace_doc": "/whitespace_doc: "
98 | }\
99 | """)
100 | )
101 |
102 |
103 | def test_meta_command_overwriting():
104 | """Test command overwriting behavior."""
105 |
106 | @meta_command
107 | def test_cmd(app, args):
108 | """First version."""
109 | pass
110 |
111 | check_meta_commands(
112 | snapshot("""\
113 | {
114 | "test_cmd": "/test_cmd: First version."
115 | }\
116 | """)
117 | )
118 |
119 | @meta_command(name="test_cmd")
120 | def _test_cmd(app, args): # Same name, different function
121 | """Second version."""
122 | pass
123 |
124 | check_meta_commands(
125 | snapshot("""\
126 | {
127 | "test_cmd": "/test_cmd: Second version."
128 | }\
129 | """)
130 | )
131 |
--------------------------------------------------------------------------------
/tests/test_pyinstaller_utils.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from inline_snapshot import snapshot
4 |
5 |
6 | def test_pyinstaller_datas():
7 | from kimi_cli.utils.pyinstaller import datas
8 |
9 | project_root = Path(__file__).parent.parent
10 | datas = [(str(Path(path).relative_to(project_root)), dst) for path, dst in datas]
11 |
12 | assert sorted(datas) == snapshot(
13 | [
14 | (
15 | ".venv/lib/python3.13/site-packages/dateparser/data/dateparser_tz_cache.pkl",
16 | "dateparser/data",
17 | ),
18 | (
19 | ".venv/lib/python3.13/site-packages/fastmcp/../fastmcp-2.12.5.dist-info/INSTALLER",
20 | "fastmcp/../fastmcp-2.12.5.dist-info",
21 | ),
22 | (
23 | ".venv/lib/python3.13/site-packages/fastmcp/../fastmcp-2.12.5.dist-info/METADATA",
24 | "fastmcp/../fastmcp-2.12.5.dist-info",
25 | ),
26 | (
27 | ".venv/lib/python3.13/site-packages/fastmcp/../fastmcp-2.12.5.dist-info/RECORD",
28 | "fastmcp/../fastmcp-2.12.5.dist-info",
29 | ),
30 | (
31 | ".venv/lib/python3.13/site-packages/fastmcp/../fastmcp-2.12.5.dist-info/REQUESTED",
32 | "fastmcp/../fastmcp-2.12.5.dist-info",
33 | ),
34 | (
35 | ".venv/lib/python3.13/site-packages/fastmcp/../fastmcp-2.12.5.dist-info/WHEEL",
36 | "fastmcp/../fastmcp-2.12.5.dist-info",
37 | ),
38 | (
39 | ".venv/lib/python3.13/site-packages/fastmcp/../fastmcp-2.12.5.dist-info/entry_points.txt",
40 | "fastmcp/../fastmcp-2.12.5.dist-info",
41 | ),
42 | (
43 | ".venv/lib/python3.13/site-packages/fastmcp/../fastmcp-2.12.5.dist-info/licenses/LICENSE",
44 | "fastmcp/../fastmcp-2.12.5.dist-info/licenses",
45 | ),
46 | (
47 | "src/kimi_cli/CHANGELOG.md",
48 | "kimi_cli",
49 | ),
50 | ("src/kimi_cli/agents/koder/README.md", "kimi_cli/agents/koder"),
51 | (
52 | "src/kimi_cli/agents/koder/agent.yaml",
53 | "kimi_cli/agents/koder",
54 | ),
55 | (
56 | "src/kimi_cli/agents/koder/sub.yaml",
57 | "kimi_cli/agents/koder",
58 | ),
59 | ("src/kimi_cli/agents/koder/system.md", "kimi_cli/agents/koder"),
60 | ("src/kimi_cli/deps/bin/rg", "kimi_cli/deps/bin"),
61 | ("src/kimi_cli/prompts/compact.md", "kimi_cli/prompts"),
62 | ("src/kimi_cli/prompts/init.md", "kimi_cli/prompts"),
63 | (
64 | "src/kimi_cli/tools/bash/bash.md",
65 | "kimi_cli/tools/bash",
66 | ),
67 | (
68 | "src/kimi_cli/tools/dmail/dmail.md",
69 | "kimi_cli/tools/dmail",
70 | ),
71 | (
72 | "src/kimi_cli/tools/file/glob.md",
73 | "kimi_cli/tools/file",
74 | ),
75 | (
76 | "src/kimi_cli/tools/file/grep.md",
77 | "kimi_cli/tools/file",
78 | ),
79 | (
80 | "src/kimi_cli/tools/file/patch.md",
81 | "kimi_cli/tools/file",
82 | ),
83 | (
84 | "src/kimi_cli/tools/file/read.md",
85 | "kimi_cli/tools/file",
86 | ),
87 | (
88 | "src/kimi_cli/tools/file/replace.md",
89 | "kimi_cli/tools/file",
90 | ),
91 | (
92 | "src/kimi_cli/tools/file/write.md",
93 | "kimi_cli/tools/file",
94 | ),
95 | (
96 | "src/kimi_cli/tools/task/task.md",
97 | "kimi_cli/tools/task",
98 | ),
99 | (
100 | "src/kimi_cli/tools/think/think.md",
101 | "kimi_cli/tools/think",
102 | ),
103 | (
104 | "src/kimi_cli/tools/todo/set_todo_list.md",
105 | "kimi_cli/tools/todo",
106 | ),
107 | (
108 | "src/kimi_cli/tools/web/fetch.md",
109 | "kimi_cli/tools/web",
110 | ),
111 | (
112 | "src/kimi_cli/tools/web/search.md",
113 | "kimi_cli/tools/web",
114 | ),
115 | ]
116 | )
117 |
118 |
119 | def test_pyinstaller_hiddenimports():
120 | from kimi_cli.utils.pyinstaller import hiddenimports
121 |
122 | assert sorted(hiddenimports) == snapshot(
123 | [
124 | "kimi_cli.tools",
125 | "kimi_cli.tools.bash",
126 | "kimi_cli.tools.dmail",
127 | "kimi_cli.tools.file",
128 | "kimi_cli.tools.file.glob",
129 | "kimi_cli.tools.file.grep",
130 | "kimi_cli.tools.file.patch",
131 | "kimi_cli.tools.file.read",
132 | "kimi_cli.tools.file.replace",
133 | "kimi_cli.tools.file.write",
134 | "kimi_cli.tools.mcp",
135 | "kimi_cli.tools.task",
136 | "kimi_cli.tools.test",
137 | "kimi_cli.tools.think",
138 | "kimi_cli.tools.todo",
139 | "kimi_cli.tools.utils",
140 | "kimi_cli.tools.web",
141 | "kimi_cli.tools.web.fetch",
142 | "kimi_cli.tools.web.search",
143 | ]
144 | )
145 |
--------------------------------------------------------------------------------
/tests/test_result_builder.py:
--------------------------------------------------------------------------------
1 | """Tests for ToolResultBuilder."""
2 |
3 | from kimi_cli.tools.utils import ToolResultBuilder
4 |
5 |
6 | def test_basic_functionality():
7 | """Test basic functionality without limits."""
8 | builder = ToolResultBuilder(max_chars=50)
9 |
10 | written1 = builder.write("Hello")
11 | written2 = builder.write(" world")
12 |
13 | assert written1 == 5
14 | assert written2 == 6
15 |
16 | result = builder.ok("Operation completed")
17 | assert result.output == "Hello world"
18 | assert result.message == "Operation completed."
19 | assert not builder.is_full
20 |
21 |
22 | def test_char_limit_truncation():
23 | """Test character limit truncation."""
24 | builder = ToolResultBuilder(max_chars=10)
25 |
26 | written1 = builder.write("Hello")
27 | written2 = builder.write(" world!") # This should trigger truncation
28 |
29 | assert written1 == 5
30 | assert written2 == 14 # "[...truncated]" marker was added
31 | assert builder.is_full
32 |
33 | result = builder.ok("Operation completed")
34 | assert result.output == "Hello[...truncated]"
35 | assert "Operation completed." in result.message
36 | assert "Output is truncated" in result.message
37 |
38 |
39 | def test_line_length_limit():
40 | """Test line length limit functionality."""
41 | builder = ToolResultBuilder(max_chars=100, max_line_length=20)
42 |
43 | written = builder.write("This is a very long line that should be truncated\n")
44 |
45 | assert written == 20 # Line was truncated to fit marker
46 |
47 | result = builder.ok()
48 | assert isinstance(result.output, str)
49 | assert "[...truncated]" in result.output
50 | assert "Output is truncated" in result.message
51 |
52 |
53 | def test_both_limits():
54 | """Test both character and line limits together."""
55 | builder = ToolResultBuilder(max_chars=40, max_line_length=20)
56 |
57 | w1 = builder.write("Line 1\n") # 7 chars
58 | w2 = builder.write("This is a very long line that exceeds limit\n") # 20 chars (truncated)
59 | w3 = builder.write("This would exceed char limit") # 14 chars (truncated)
60 |
61 | assert w1 == 7
62 | assert w2 == 20 # Line truncated to fit limit
63 | assert w3 == 14 # Line truncated due to char limit
64 | assert builder.is_full
65 | # Total might exceed 40 due to truncation markers
66 |
67 | result = builder.ok()
68 | assert isinstance(result.output, str)
69 | assert "[...truncated]" in result.output
70 | assert "Output is truncated" in result.message
71 |
72 |
73 | def test_error_result():
74 | """Test error result creation."""
75 | builder = ToolResultBuilder(max_chars=20)
76 |
77 | builder.write("Some output")
78 | result = builder.error("Something went wrong", brief="Error occurred")
79 |
80 | assert result.output == "Some output"
81 | assert result.message == "Something went wrong"
82 | assert result.brief == "Error occurred"
83 |
84 |
85 | def test_error_with_truncation():
86 | """Test error result with truncated output."""
87 | builder = ToolResultBuilder(max_chars=10)
88 |
89 | builder.write("Very long output that exceeds limit")
90 | result = builder.error("Command failed", brief="Failed")
91 |
92 | assert "[...truncated]" in result.output
93 | assert "Command failed" in result.message
94 | assert "Output is truncated" in result.message
95 | assert result.brief == "Failed"
96 |
97 |
98 | def test_properties():
99 | """Test builder properties."""
100 | builder = ToolResultBuilder(max_chars=20, max_line_length=30)
101 |
102 | assert builder.n_chars == 0
103 | assert builder.n_lines == 0
104 | assert not builder.is_full
105 |
106 | builder.write("Short\n")
107 | assert builder.n_chars == 6
108 | assert builder.n_lines == 1
109 |
110 | builder.write("1\n2\n")
111 | assert builder.n_chars == 10
112 | assert builder.n_lines == 3
113 |
114 | builder.write("More text that exceeds") # Will trigger char truncation
115 | assert builder.is_full
116 |
117 |
118 | def test_write_when_full():
119 | """Test writing when buffer is already full."""
120 | builder = ToolResultBuilder(max_chars=5)
121 |
122 | written1 = builder.write("Hello") # Fills buffer exactly
123 | written2 = builder.write(" world") # Should write nothing
124 |
125 | assert written1 == 5
126 | assert written2 == 0
127 | assert builder.is_full
128 |
129 | result = builder.ok()
130 | assert result.output == "Hello"
131 |
132 |
133 | def test_multiline_handling():
134 | """Test proper multiline text handling."""
135 | builder = ToolResultBuilder(max_chars=100)
136 |
137 | written = builder.write("Line 1\nLine 2\nLine 3")
138 |
139 | assert written == 20
140 | assert builder.n_lines == 2 # Two newlines
141 |
142 | result = builder.ok()
143 | assert result.output == "Line 1\nLine 2\nLine 3"
144 |
145 |
146 | def test_empty_write():
147 | """Test writing empty string."""
148 | builder = ToolResultBuilder(max_chars=50)
149 |
150 | written = builder.write("")
151 |
152 | assert written == 0
153 | assert builder.n_chars == 0
154 | assert not builder.is_full
155 |
--------------------------------------------------------------------------------
/tests/test_write_file.py:
--------------------------------------------------------------------------------
1 | """Tests for the write_file tool."""
2 |
3 | from pathlib import Path
4 |
5 | import pytest
6 | from kosong.tooling import ToolError, ToolOk
7 | from pydantic import ValidationError
8 |
9 | from kimi_cli.tools.file.write import Params, WriteFile
10 |
11 |
12 | @pytest.mark.asyncio
13 | async def test_write_new_file(write_file_tool: WriteFile, temp_work_dir: Path):
14 | """Test writing a new file."""
15 | file_path = temp_work_dir / "new_file.txt"
16 | content = "Hello, World!"
17 |
18 | result = await write_file_tool(Params(path=str(file_path), content=content))
19 |
20 | assert isinstance(result, ToolOk)
21 | assert "successfully overwritten" in result.message
22 | assert file_path.exists()
23 | assert file_path.read_text() == content
24 |
25 |
26 | @pytest.mark.asyncio
27 | async def test_overwrite_existing_file(write_file_tool: WriteFile, temp_work_dir: Path):
28 | """Test overwriting an existing file."""
29 | file_path = temp_work_dir / "existing.txt"
30 | original_content = "Original content"
31 | file_path.write_text(original_content)
32 |
33 | new_content = "New content"
34 | result = await write_file_tool(Params(path=str(file_path), content=new_content))
35 |
36 | assert isinstance(result, ToolOk)
37 | assert "successfully overwritten" in result.message
38 | assert file_path.read_text() == new_content
39 |
40 |
41 | @pytest.mark.asyncio
42 | async def test_append_to_file(write_file_tool: WriteFile, temp_work_dir: Path):
43 | """Test appending to an existing file."""
44 | file_path = temp_work_dir / "append_test.txt"
45 | original_content = "First line\n"
46 | file_path.write_text(original_content)
47 |
48 | append_content = "Second line\n"
49 | result = await write_file_tool(
50 | Params(path=str(file_path), content=append_content, mode="append")
51 | )
52 |
53 | assert isinstance(result, ToolOk)
54 | assert "successfully appended to" in result.message
55 | expected_content = original_content + append_content
56 | assert file_path.read_text() == expected_content
57 |
58 |
59 | @pytest.mark.asyncio
60 | async def test_write_unicode_content(write_file_tool: WriteFile, temp_work_dir: Path):
61 | """Test writing unicode content."""
62 | file_path = temp_work_dir / "unicode.txt"
63 | content = "Hello 世界 🌍\nUnicode: café, naïve, résumé"
64 |
65 | result = await write_file_tool(Params(path=str(file_path), content=content))
66 |
67 | assert isinstance(result, ToolOk)
68 | assert file_path.exists()
69 | assert file_path.read_text(encoding="utf-8") == content
70 |
71 |
72 | @pytest.mark.asyncio
73 | async def test_write_empty_content(write_file_tool: WriteFile, temp_work_dir: Path):
74 | """Test writing empty content."""
75 | file_path = temp_work_dir / "empty.txt"
76 | content = ""
77 |
78 | result = await write_file_tool(Params(path=str(file_path), content=content))
79 |
80 | assert isinstance(result, ToolOk)
81 | assert file_path.exists()
82 | assert file_path.read_text() == content
83 |
84 |
85 | @pytest.mark.asyncio
86 | async def test_write_multiline_content(write_file_tool: WriteFile, temp_work_dir: Path):
87 | """Test writing multiline content."""
88 | file_path = temp_work_dir / "multiline.txt"
89 | content = "Line 1\nLine 2\nLine 3\n"
90 |
91 | result = await write_file_tool(Params(path=str(file_path), content=content))
92 |
93 | assert isinstance(result, ToolOk)
94 | assert file_path.read_text() == content
95 |
96 |
97 | @pytest.mark.asyncio
98 | async def test_write_with_relative_path(write_file_tool: WriteFile):
99 | """Test writing with a relative path (should fail)."""
100 | result = await write_file_tool(Params(path="relative/path/file.txt", content="content"))
101 |
102 | assert isinstance(result, ToolError)
103 | assert "not an absolute path" in result.message
104 |
105 |
106 | @pytest.mark.asyncio
107 | async def test_write_outside_work_directory(write_file_tool: WriteFile):
108 | """Test writing outside the working directory (should fail)."""
109 | result = await write_file_tool(Params(path="/tmp/outside.txt", content="content"))
110 |
111 | assert isinstance(result, ToolError)
112 | assert "outside the working directory" in result.message
113 |
114 |
115 | @pytest.mark.asyncio
116 | async def test_write_to_nonexistent_directory(write_file_tool: WriteFile, temp_work_dir: Path):
117 | """Test writing to a non-existent directory."""
118 | file_path = temp_work_dir / "nonexistent" / "file.txt"
119 |
120 | result = await write_file_tool(Params(path=str(file_path), content="content"))
121 |
122 | assert isinstance(result, ToolError)
123 | assert "parent directory does not exist" in result.message
124 |
125 |
126 | @pytest.mark.asyncio
127 | async def test_write_with_invalid_mode(write_file_tool: WriteFile, temp_work_dir: Path):
128 | """Test writing with an invalid mode."""
129 | file_path = temp_work_dir / "test.txt"
130 |
131 | with pytest.raises(ValidationError):
132 | await write_file_tool(Params(path=str(file_path), content="content", mode="invalid")) # pyright: ignore[reportArgumentType]
133 |
134 |
135 | @pytest.mark.asyncio
136 | async def test_append_to_nonexistent_file(write_file_tool: WriteFile, temp_work_dir: Path):
137 | """Test appending to a non-existent file (should create it)."""
138 | file_path = temp_work_dir / "new_append.txt"
139 | content = "New content\n"
140 |
141 | result = await write_file_tool(Params(path=str(file_path), content=content, mode="append"))
142 |
143 | assert isinstance(result, ToolOk)
144 | assert "successfully appended to" in result.message
145 | assert file_path.exists()
146 | assert file_path.read_text() == content
147 |
148 |
149 | @pytest.mark.asyncio
150 | async def test_write_large_content(write_file_tool: WriteFile, temp_work_dir: Path):
151 | """Test writing large content."""
152 | file_path = temp_work_dir / "large.txt"
153 | content = "Large content line\n" * 1000
154 |
155 | result = await write_file_tool(Params(path=str(file_path), content=content))
156 |
157 | assert isinstance(result, ToolOk)
158 | assert file_path.exists()
159 | assert file_path.read_text() == content
160 |
--------------------------------------------------------------------------------