Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,13 @@ command = "prettier --write"
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `event` | `string` | Yes | Event type, e.g., `PreToolUse`, `Stop`, etc. |
| `command` | `string` | Yes | Shell command to execute |
| `command` | `string` | Cond* | Shell command to execute |
| `inject_prompt` | `string` | Cond* | Static prompt content or file path to inject |
| `matcher` | `string` | No | Regex filter condition |
| `timeout` | `integer` | No | Timeout in seconds, default 30 |

*Either `command` or `inject_prompt` must be specified, but not both. When using `inject_prompt`, the content is injected into the conversation context as a system reminder without executing a shell command.

## JSON configuration migration

If `~/.kimi/config.toml` doesn't exist but `~/.kimi/config.json` exists, Kimi Code CLI will automatically migrate the JSON configuration to TOML format and backup the original file as `config.json.bak`.
Expand Down
47 changes: 46 additions & 1 deletion docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,43 @@ command = "osascript -e 'display notification \"Kimi needs attention\" with titl
[[hooks]]
event = "Stop"
command = ".kimi/hooks/check-complete.sh"

# Inject context on every user prompt (simpler alternative to shell command)
[[hooks]]
event = "UserPromptSubmit"
inject_prompt = "Always write tests first."
timeout = 5

# Or load from file
[[hooks]]
event = "UserPromptSubmit"
inject_prompt = "~/.kimi/prompts/coding-guidelines.md"
timeout = 5
```

### The `inject_prompt` Field

For simple use cases where you just want to inject static text or file content into the conversation context, use `inject_prompt` instead of `command`:

- **Static text**: Write the content directly as a string
- **File path**: Specify a path ending with `.md` or `.txt`, or containing `/` or `\`. The file content will be read and injected
- **Path expansion**: `~` is expanded to the user's home directory
- **Relative paths**: Resolved relative to current working directory

The content is injected as a system reminder message before processing each matching user prompt.

### Configuration Fields

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `event` | Yes | — | Event type, must be one of the 13 supported events |
| `command` | Yes | — | Shell command to execute, receives JSON via stdin |
| `command` | Yes* | — | Shell command to execute, receives JSON via stdin |
| `inject_prompt` | Yes* | — | Static prompt content or file path to inject as context |
| `matcher` | No | `""` | Regex filter, empty string matches all |
| `timeout` | No | `30` | Timeout in seconds, fail-open on timeout |

*Either `command` or `inject_prompt` must be specified, but not both.

## Communication Protocol

### Input (Standard Input)
Expand Down Expand Up @@ -117,6 +143,25 @@ When exiting with code 0, you can output structured JSON for more detailed infor

When `permissionDecision` is `deny`, the operation is blocked and `permissionDecisionReason` is fed back to the LLM.

### Injecting Additional Context

Hooks can also return `additionalContext` to inject content into the conversation as a system reminder:

```json
{
"hookSpecificOutput": {
"additionalContext": "Remember to check the documentation before making changes."
}
}
```

This is useful for:
- Dynamically loading skill content based on the current task
- Injecting project-specific guidelines
- Reminding the AI of coding standards

When using `inject_prompt` (instead of `command`), the content is automatically injected without needing to return JSON.

## Hook Script Examples

### Protect Sensitive Files
Expand Down
5 changes: 4 additions & 1 deletion docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,13 @@ command = "prettier --write"
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `event` | `string` | 是 | 事件类型,如 `PreToolUse`、`Stop` 等 |
| `command` | `string` | 是 | 要执行的 shell 命令 |
| `command` | `string` | 条件* | 要执行的 shell 命令 |
| `inject_prompt` | `string` | 条件* | 要注入的静态提示内容或文件路径 |
| `matcher` | `string` | 否 | 正则表达式过滤条件 |
| `timeout` | `integer` | 否 | 超时时间(秒),默认 30 |

*必须指定 `command` 或 `inject_prompt` 其中之一,但不能同时指定。使用 `inject_prompt` 时,内容会作为系统提醒注入对话上下文,无需执行 shell 命令。

## JSON 配置迁移

如果 `~/.kimi/config.toml` 不存在但 `~/.kimi/config.json` 存在,Kimi Code CLI 会自动将 JSON 配置迁移到 TOML 格式,并将原文件备份为 `config.json.bak`。
Expand Down
47 changes: 46 additions & 1 deletion docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,43 @@ command = "osascript -e 'display notification \"Kimi needs attention\" with titl
[[hooks]]
event = "Stop"
command = ".kimi/hooks/check-complete.sh"

# 每次用户提示时注入上下文(无需 shell 命令的简单替代方案)
[[hooks]]
event = "UserPromptSubmit"
inject_prompt = "Always write tests first."
timeout = 5

# 或从文件加载
[[hooks]]
event = "UserPromptSubmit"
inject_prompt = "~/.kimi/prompts/coding-guidelines.md"
timeout = 5
```

### `inject_prompt` 字段

对于只需要注入静态文本或文件内容到对话上下文的简单场景,可以使用 `inject_prompt` 替代 `command`:

- **静态文本**:直接将内容写为字符串
- **文件路径**:指定以 `.md` 或 `.txt` 结尾的路径,或包含 `/` 或 `\` 的路径,将读取文件内容并注入
- **路径展开**:`~` 会被展开为用户主目录
- **相对路径**:相对于当前工作目录解析

内容会在处理每个匹配的用户提示前作为系统提醒消息注入。

### 配置字段

| 字段 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `event` | 是 | — | 事件类型,必须是上述 13 种之一 |
| `command` | 是 | — | 要执行的 shell 命令,通过 stdin 接收 JSON 上下文 |
| `command` | 是* | — | 要执行的 shell 命令,通过 stdin 接收 JSON 上下文 |
| `inject_prompt` | 是* | — | 要注入为上下文的静态提示内容或文件路径 |
| `matcher` | 否 | `""` | 正则表达式过滤,空字符串匹配所有 |
| `timeout` | 否 | `30` | 超时时间(秒),超时后按 fail-open 处理 |

*必须指定 `command` 或 `inject_prompt` 其中之一,但不能同时指定两者。

## 通信协议

### 输入(标准输入)
Expand Down Expand Up @@ -117,6 +143,25 @@ Hook 命令从标准输入接收 JSON 格式的上下文信息,包含通用字

当 `permissionDecision` 为 `deny` 时,会阻止操作并将 `permissionDecisionReason` 反馈给 LLM。

### 注入额外上下文

Hooks 还可以返回 `additionalContext` 来将内容作为系统提醒注入对话:

```json
{
"hookSpecificOutput": {
"additionalContext": "修改前请先查看相关文档。"
}
}
```

这在以下场景很有用:
- 根据当前任务动态加载 Skill 内容
- 注入项目特定的指导原则
- 提醒 AI 遵循代码规范

使用 `inject_prompt`(而非 `command`)时,内容会自动注入,无需返回 JSON。

## Hook 脚本示例

### 保护敏感文件
Expand Down
14 changes: 12 additions & 2 deletions src/kimi_cli/hooks/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Literal

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator

HookEventType = Literal[
"PreToolUse",
Expand All @@ -26,9 +26,19 @@ class HookDef(BaseModel):

event: HookEventType
"""Which lifecycle event triggers this hook."""
command: str
command: str = ""
"""Shell command to execute. Receives JSON on stdin."""
inject_prompt: str = ""
"""Prompt content or file path to inject as additional context."""
matcher: str = ""
"""Regex pattern to filter. Empty matches everything."""
timeout: int = Field(default=30, ge=1, le=600)
"""Timeout in seconds. Fail-open on timeout."""

@model_validator(mode="after")
def check_command_or_inject(self):
if self.command and self.inject_prompt:
raise ValueError("hook cannot have both 'command' and 'inject_prompt'")
if not self.command and not self.inject_prompt:
raise ValueError("hook must have either 'command' or 'inject_prompt'")
return self
65 changes: 57 additions & 8 deletions src/kimi_cli/hooks/engine.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from __future__ import annotations

import asyncio
import os
import re
import time
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from kimi_cli import logger
Expand Down Expand Up @@ -145,7 +147,7 @@ def details(self) -> dict[str, list[dict[str, str]]]:
{
"matcher": h.matcher or "(all)",
"source": "server",
"command": h.command,
"command": h.command or h.inject_prompt or "(no command)",
}
)
for event, subs in self._wire_by_event.items():
Expand Down Expand Up @@ -180,13 +182,19 @@ async def trigger(

# --- Match server-side hooks ---
seen_commands: set[str] = set()
seen_inject_prompts: set[str] = set()
server_matched: list[HookDef] = []
for h in self._by_event.get(event, []):
if not self._match_regex(h.matcher, matcher_value):
continue
if h.command in seen_commands:
continue
seen_commands.add(h.command)
if h.command:
if h.command in seen_commands:
continue
seen_commands.add(h.command)
elif h.inject_prompt:
if h.inject_prompt in seen_inject_prompts:
continue
seen_inject_prompts.add(h.inject_prompt)
server_matched.append(h)

# --- Match wire subscriptions ---
Expand Down Expand Up @@ -238,11 +246,18 @@ async def _execute_hooks(

# Server-side: run shell commands
for h in server_matched:
tasks.append(
asyncio.create_task(
run_hook(h.command, input_data, timeout=h.timeout, cwd=self._cwd)
if h.inject_prompt:
tasks.append(
asyncio.create_task(
self._run_inject_prompt_hook(h)
)
)
else:
tasks.append(
asyncio.create_task(
run_hook(h.command, input_data, timeout=h.timeout, cwd=self._cwd)
)
)
)

# Wire-side: send request to client, wait for response
for s in wire_matched:
Expand Down Expand Up @@ -308,3 +323,37 @@ async def _dispatch_wire_hook(
hook_task.cancel()
logger.warning("Wire hook failed: {} {}: {}", event, target, e)
return HookResult(action="allow")

async def _run_inject_prompt_hook(self, hook: HookDef) -> HookResult:
try:
content = self._resolve_inject_prompt(hook.inject_prompt)
return HookResult(
action="allow",
stdout="",
stderr="",
exit_code=0,
additional_context=content,
)
except FileNotFoundError as e:
logger.warning(str(e))
return HookResult(action="allow", exit_code=0)
except Exception as e:
logger.warning("inject_prompt hook failed: {}", e)
return HookResult(action="allow", exit_code=0)

def _resolve_inject_prompt(self, inject_prompt: str) -> str:
expanded = os.path.expanduser(inject_prompt)
is_likely_path = (
expanded.endswith((".md", ".txt"))
or "/" in expanded
or (len(expanded) > 2 and expanded[1] == ":" and expanded[2] == "\\")
)
if is_likely_path:
path = Path(expanded)
if not path.is_absolute():
path = Path(self._cwd or ".") / path
if path.exists() and path.is_file():
return path.read_text(encoding="utf-8")
else:
raise FileNotFoundError(f"Hook inject_prompt file not found: {inject_prompt}")
return inject_prompt
12 changes: 12 additions & 0 deletions src/kimi_cli/hooks/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class HookResult:
stderr: str = ""
exit_code: int = 0
timed_out: bool = False
additional_context: str = ""


async def run_hook(
Expand Down Expand Up @@ -75,14 +76,25 @@ async def run_hook(
if isinstance(raw, dict):
parsed = cast(dict[str, Any], raw)
hook_output = cast(dict[str, Any], parsed.get("hookSpecificOutput", {}))
additional_context = ""
if "additionalContext" in hook_output:
additional_context = str(hook_output.get("additionalContext", ""))
if hook_output.get("permissionDecision") == "deny":
return HookResult(
action="block",
reason=str(hook_output.get("permissionDecisionReason", "")),
stdout=stdout,
stderr=stderr,
exit_code=0,
additional_context="",
)
return HookResult(
action="allow",
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
additional_context=additional_context,
)
except (json.JSONDecodeError, TypeError):
pass

Expand Down
10 changes: 10 additions & 0 deletions src/kimi_cli/soul/kimisoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,12 +476,22 @@ async def run(self, user_input: str | list[ContentPart]):
prompt=text_input_for_hook,
),
)
additional_contexts: list[str] = []
for result in hook_results:
if result.action == "block":
wire_send(TurnBegin(user_input=user_input))
wire_send(TextPart(text=result.reason or "Prompt blocked by hook."))
wire_send(TurnEnd())
return
if result.additional_context:
additional_contexts.append(result.additional_context)
if additional_contexts:
combined_context = "\n\n".join(additional_contexts)
context_message = Message(
role="user",
content=[system_reminder(combined_context)],
)
await self._context.append_message(context_message)

wire_send(TurnBegin(user_input=user_input))
user_message = Message(role="user", content=user_input)
Expand Down
Loading