diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index afb3451cd..473ede1c3 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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`. diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 548120550..9eeb0577f 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -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) @@ -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 diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 8cc34f4eb..a9271ee78 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -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`。 diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index e191614d0..4470194f9 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -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` 其中之一,但不能同时指定两者。 + ## 通信协议 ### 输入(标准输入) @@ -117,6 +143,25 @@ Hook 命令从标准输入接收 JSON 格式的上下文信息,包含通用字 当 `permissionDecision` 为 `deny` 时,会阻止操作并将 `permissionDecisionReason` 反馈给 LLM。 +### 注入额外上下文 + +Hooks 还可以返回 `additionalContext` 来将内容作为系统提醒注入对话: + +```json +{ + "hookSpecificOutput": { + "additionalContext": "修改前请先查看相关文档。" + } +} +``` + +这在以下场景很有用: +- 根据当前任务动态加载 Skill 内容 +- 注入项目特定的指导原则 +- 提醒 AI 遵循代码规范 + +使用 `inject_prompt`(而非 `command`)时,内容会自动注入,无需返回 JSON。 + ## Hook 脚本示例 ### 保护敏感文件 diff --git a/src/kimi_cli/hooks/config.py b/src/kimi_cli/hooks/config.py index ea6963371..88aac0abc 100644 --- a/src/kimi_cli/hooks/config.py +++ b/src/kimi_cli/hooks/config.py @@ -1,6 +1,6 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator HookEventType = Literal[ "PreToolUse", @@ -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 diff --git a/src/kimi_cli/hooks/engine.py b/src/kimi_cli/hooks/engine.py index 20202485c..064f6ba89 100644 --- a/src/kimi_cli/hooks/engine.py +++ b/src/kimi_cli/hooks/engine.py @@ -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 @@ -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(): @@ -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 --- @@ -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: @@ -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 diff --git a/src/kimi_cli/hooks/runner.py b/src/kimi_cli/hooks/runner.py index 3c81d6d27..0f30eb526 100644 --- a/src/kimi_cli/hooks/runner.py +++ b/src/kimi_cli/hooks/runner.py @@ -18,6 +18,7 @@ class HookResult: stderr: str = "" exit_code: int = 0 timed_out: bool = False + additional_context: str = "" async def run_hook( @@ -75,6 +76,9 @@ 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", @@ -82,7 +86,15 @@ async def run_hook( 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 diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 7a5dd8c60..4f06616e8 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -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) diff --git a/tests/hooks/test_config.py b/tests/hooks/test_config.py index 92961b6ea..4bd1064e6 100644 --- a/tests/hooks/test_config.py +++ b/tests/hooks/test_config.py @@ -52,3 +52,36 @@ def test_config_with_hooks(): def test_config_without_hooks(): config = Config.model_validate({"default_model": ""}) assert config.hooks == [] + + +def test_inject_prompt_only(): + h = HookDef(event="UserPromptSubmit", inject_prompt="use TDD") + assert h.inject_prompt == "use TDD" + assert h.command == "" + + +def test_command_or_inject_required(): + with pytest.raises(ValidationError): + HookDef(event="UserPromptSubmit") + + +def test_command_and_inject_mutually_exclusive(): + with pytest.raises(ValidationError): + HookDef(event="UserPromptSubmit", command="echo test", inject_prompt="test") + + +def test_config_with_inject_prompt(): + toml_str = """ +default_model = "" + +[[hooks]] +event = "UserPromptSubmit" +inject_prompt = "use TDD" +timeout = 10 +""" + data = tomlkit.parse(toml_str) + config = Config.model_validate(data) + assert len(config.hooks) == 1 + assert config.hooks[0].event == "UserPromptSubmit" + assert config.hooks[0].inject_prompt == "use TDD" + assert config.hooks[0].command == "" diff --git a/tests/hooks/test_integration.py b/tests/hooks/test_integration.py index c91e7514d..c97e7d137 100644 --- a/tests/hooks/test_integration.py +++ b/tests/hooks/test_integration.py @@ -1,3 +1,4 @@ +import os import tempfile from pathlib import Path @@ -8,6 +9,60 @@ from kimi_cli.hooks.engine import HookEngine +@pytest.mark.asyncio +async def test_inject_prompt_text(): + """inject_prompt with text content returns additional_context.""" + hooks = [HookDef(event="UserPromptSubmit", inject_prompt="use TDD")] + engine = HookEngine(hooks) + results = await engine.trigger("UserPromptSubmit", input_data={"prompt": "hello"}) + assert len(results) == 1 + assert results[0].action == "allow" + assert results[0].additional_context == "use TDD" + + +@pytest.mark.asyncio +async def test_inject_prompt_file(): + """inject_prompt with file path reads content from file.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write("skill content from file") + temp_path = f.name + try: + hooks = [HookDef(event="UserPromptSubmit", inject_prompt=temp_path)] + engine = HookEngine(hooks) + results = await engine.trigger("UserPromptSubmit", input_data={"prompt": "hello"}) + assert len(results) == 1 + assert results[0].action == "allow" + assert results[0].additional_context == "skill content from file" + finally: + os.unlink(temp_path) + + +@pytest.mark.asyncio +async def test_inject_prompt_file_not_found(): + """inject_prompt with non-existent file logs warning but allows.""" + hooks = [HookDef(event="UserPromptSubmit", inject_prompt="/nonexistent/path/prompt.md")] + engine = HookEngine(hooks) + results = await engine.trigger("UserPromptSubmit", input_data={"prompt": "hello"}) + assert len(results) == 1 + assert results[0].action == "allow" + assert results[0].additional_context == "" + + +@pytest.mark.asyncio +async def test_multiple_hooks_inject_prompt(): + """Multiple inject_prompt hooks return combined contexts.""" + hooks = [ + HookDef(event="UserPromptSubmit", inject_prompt="context one"), + HookDef(event="UserPromptSubmit", inject_prompt="context two"), + ] + engine = HookEngine(hooks) + results = await engine.trigger("UserPromptSubmit", input_data={"prompt": "hello"}) + assert len(results) == 2 + contexts = [r.additional_context for r in results] + assert "context one" in contexts + assert "context two" in contexts + + @pytest.mark.asyncio async def test_pre_tool_use_block_flow(): """Full flow: hook blocks a dangerous command.""" diff --git a/tests/hooks/test_runner.py b/tests/hooks/test_runner.py index cd03a0c0b..b9c69c988 100644 --- a/tests/hooks/test_runner.py +++ b/tests/hooks/test_runner.py @@ -43,3 +43,33 @@ async def test_stdin_receives_json(): cmd = """python3 -c "import sys,json; d=json.load(sys.stdin); print(d['tool_name'])" """ result = await run_hook(cmd, {"tool_name": "WriteFile"}, timeout=5) assert result.stdout.strip() == "WriteFile" + + +@pytest.mark.asyncio +async def test_additional_context_default_empty(): + result = await run_hook("echo ok", {"tool_name": "Shell"}, timeout=5) + assert result.additional_context == "" + + +@pytest.mark.asyncio +async def test_additional_context_from_json(): + cmd = """echo '{"hookSpecificOutput": {"additionalContext": "skill content"}}' """ + result = await run_hook(cmd, {"tool_name": "Shell"}, timeout=5) + assert result.action == "allow" + assert result.additional_context == "skill content" + + +@pytest.mark.asyncio +async def test_deny_clears_additional_context(): + cmd = """echo '{"hookSpecificOutput": {"permissionDecision": "deny", "permissionDecisionReason": "blocked", "additionalContext": "should be ignored"}}' """ + result = await run_hook(cmd, {"tool_name": "Shell"}, timeout=5) + assert result.action == "block" + assert result.additional_context == "" + + +@pytest.mark.asyncio +async def test_additional_context_with_deny(): + cmd = """echo '{"hookSpecificOutput": {"additionalContext": "skill content", "permissionDecision": "deny"}}' """ + result = await run_hook(cmd, {"tool_name": "Shell"}, timeout=5) + assert result.action == "block" + assert result.additional_context == ""