├── .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 | [![Commit Activity](https://img.shields.io/github/commit-activity/w/MoonshotAI/kimi-cli)](https://github.com/MoonshotAI/kimi-cli/graphs/commit-activity) 4 | [![Checks](https://img.shields.io/github/check-runs/MoonshotAI/kimi-cli/main)](https://github.com/MoonshotAI/kimi-cli/actions) 5 | [![Version](https://img.shields.io/pypi/v/kimi-cli)](https://pypi.org/project/kimi-cli/) 6 | [![Downloads](https://img.shields.io/pypi/dw/kimi-cli)](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 | ![](./docs/images/setup.png) 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 | --------------------------------------------------------------------------------