kimi-cli 0.44__py3-none-any.whl → 0.78__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kimi-cli might be problematic. Click here for more details.
- kimi_cli/CHANGELOG.md +349 -40
- kimi_cli/__init__.py +6 -0
- kimi_cli/acp/AGENTS.md +91 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +111 -0
- kimi_cli/acp/kaos.py +270 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +335 -0
- kimi_cli/acp/session.py +445 -0
- kimi_cli/acp/tools.py +158 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/agents/default/agent.yaml +4 -4
- kimi_cli/agents/default/sub.yaml +2 -1
- kimi_cli/agents/default/system.md +79 -21
- kimi_cli/agents/okabe/agent.yaml +17 -0
- kimi_cli/agentspec.py +53 -25
- kimi_cli/app.py +180 -52
- kimi_cli/cli/__init__.py +595 -0
- kimi_cli/cli/__main__.py +8 -0
- kimi_cli/cli/info.py +63 -0
- kimi_cli/cli/mcp.py +349 -0
- kimi_cli/config.py +153 -17
- kimi_cli/constant.py +3 -0
- kimi_cli/exception.py +23 -2
- kimi_cli/flow/__init__.py +117 -0
- kimi_cli/flow/d2.py +376 -0
- kimi_cli/flow/mermaid.py +218 -0
- kimi_cli/llm.py +129 -23
- kimi_cli/metadata.py +32 -7
- kimi_cli/platforms.py +262 -0
- kimi_cli/prompts/__init__.py +2 -0
- kimi_cli/prompts/compact.md +4 -5
- kimi_cli/session.py +223 -31
- kimi_cli/share.py +2 -0
- kimi_cli/skill.py +145 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +351 -0
- kimi_cli/soul/__init__.py +51 -20
- kimi_cli/soul/agent.py +213 -85
- kimi_cli/soul/approval.py +86 -17
- kimi_cli/soul/compaction.py +64 -53
- kimi_cli/soul/context.py +38 -5
- kimi_cli/soul/denwarenji.py +2 -0
- kimi_cli/soul/kimisoul.py +442 -60
- kimi_cli/soul/message.py +54 -54
- kimi_cli/soul/slash.py +72 -0
- kimi_cli/soul/toolset.py +387 -6
- kimi_cli/toad.py +74 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +42 -34
- kimi_cli/tools/display.py +25 -0
- kimi_cli/tools/dmail/__init__.py +10 -10
- kimi_cli/tools/dmail/dmail.md +11 -9
- kimi_cli/tools/file/__init__.py +1 -3
- kimi_cli/tools/file/glob.py +20 -23
- kimi_cli/tools/file/grep.md +1 -1
- kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
- kimi_cli/tools/file/read.md +24 -6
- kimi_cli/tools/file/read.py +134 -50
- kimi_cli/tools/file/replace.md +1 -1
- kimi_cli/tools/file/replace.py +36 -29
- kimi_cli/tools/file/utils.py +282 -0
- kimi_cli/tools/file/write.py +43 -22
- kimi_cli/tools/multiagent/__init__.py +7 -0
- kimi_cli/tools/multiagent/create.md +11 -0
- kimi_cli/tools/multiagent/create.py +50 -0
- kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
- kimi_cli/tools/shell/__init__.py +120 -0
- kimi_cli/tools/{bash → shell}/bash.md +1 -2
- kimi_cli/tools/shell/powershell.md +25 -0
- kimi_cli/tools/test.py +4 -4
- kimi_cli/tools/think/__init__.py +2 -2
- kimi_cli/tools/todo/__init__.py +14 -8
- kimi_cli/tools/utils.py +64 -24
- kimi_cli/tools/web/fetch.py +68 -13
- kimi_cli/tools/web/search.py +10 -12
- kimi_cli/ui/acp/__init__.py +65 -412
- kimi_cli/ui/print/__init__.py +37 -49
- kimi_cli/ui/print/visualize.py +179 -0
- kimi_cli/ui/shell/__init__.py +141 -84
- kimi_cli/ui/shell/console.py +2 -0
- kimi_cli/ui/shell/debug.py +28 -23
- kimi_cli/ui/shell/keyboard.py +5 -1
- kimi_cli/ui/shell/prompt.py +220 -194
- kimi_cli/ui/shell/replay.py +111 -46
- kimi_cli/ui/shell/setup.py +89 -82
- kimi_cli/ui/shell/slash.py +422 -0
- kimi_cli/ui/shell/update.py +4 -2
- kimi_cli/ui/shell/usage.py +271 -0
- kimi_cli/ui/shell/visualize.py +574 -72
- kimi_cli/ui/wire/__init__.py +267 -0
- kimi_cli/ui/wire/jsonrpc.py +142 -0
- kimi_cli/ui/wire/protocol.py +1 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +2 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +12 -7
- kimi_cli/utils/clipboard.py +12 -0
- kimi_cli/utils/datetime.py +37 -0
- kimi_cli/utils/environment.py +58 -0
- kimi_cli/utils/envvar.py +12 -0
- kimi_cli/utils/frontmatter.py +44 -0
- kimi_cli/utils/logging.py +7 -6
- kimi_cli/utils/message.py +9 -14
- kimi_cli/utils/path.py +99 -9
- kimi_cli/utils/pyinstaller.py +6 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/markdown.py +961 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/signals.py +2 -0
- kimi_cli/utils/slashcmd.py +124 -0
- kimi_cli/utils/string.py +2 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/wire/__init__.py +98 -29
- kimi_cli/wire/serde.py +45 -0
- kimi_cli/wire/types.py +299 -0
- kimi_cli-0.78.dist-info/METADATA +200 -0
- kimi_cli-0.78.dist-info/RECORD +135 -0
- kimi_cli-0.78.dist-info/entry_points.txt +4 -0
- kimi_cli/cli.py +0 -250
- kimi_cli/soul/runtime.py +0 -96
- kimi_cli/tools/bash/__init__.py +0 -99
- kimi_cli/tools/file/patch.md +0 -8
- kimi_cli/tools/file/patch.py +0 -143
- kimi_cli/tools/mcp.py +0 -85
- kimi_cli/ui/shell/liveview.py +0 -386
- kimi_cli/ui/shell/metacmd.py +0 -262
- kimi_cli/wire/message.py +0 -91
- kimi_cli-0.44.dist-info/METADATA +0 -188
- kimi_cli-0.44.dist-info/RECORD +0 -89
- kimi_cli-0.44.dist-info/entry_points.txt +0 -3
- /kimi_cli/tools/{task → multiagent}/task.md +0 -0
- {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
kimi_cli/acp/AGENTS.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# ACP Integration Notes (kimi-cli)
|
|
2
|
+
|
|
3
|
+
## Protocol summary (ACP overview)
|
|
4
|
+
- ACP is JSON-RPC 2.0 with request/response methods plus one-way notifications.
|
|
5
|
+
- Typical flow: `initialize` -> optional `authenticate` -> `session/new` or `session/load`
|
|
6
|
+
-> `session/prompt`
|
|
7
|
+
with `session/update` notifications and optional `session/cancel`.
|
|
8
|
+
- Clients provide `session/request_permission` and optional terminal/filesystem methods.
|
|
9
|
+
- All ACP file paths must be absolute; line numbers are 1-based.
|
|
10
|
+
|
|
11
|
+
## Entry points and server modes
|
|
12
|
+
- **Single-session server**: `KimiCLI.run_acp()` uses `ACP` -> `ACPServerSingleSession`.
|
|
13
|
+
- Code: `src/kimi_cli/app.py`, `src/kimi_cli/ui/acp/__init__.py`.
|
|
14
|
+
- Used when running CLI with `--acp` UI mode.
|
|
15
|
+
- **Multi-session server**: `acp_main()` runs `ACPServer` with `use_unstable_protocol=True`.
|
|
16
|
+
- Code: `src/kimi_cli/acp/__init__.py`, `src/kimi_cli/acp/server.py`.
|
|
17
|
+
- Exposed via the `kimi acp` command in `src/kimi_cli/cli/__init__.py`.
|
|
18
|
+
|
|
19
|
+
## Capabilities advertised
|
|
20
|
+
- `prompt_capabilities`: `embedded_context=False`, `image=True`, `audio=False`.
|
|
21
|
+
- `mcp_capabilities`: `http=True`, `sse=False`.
|
|
22
|
+
- Single-session: `load_session=False`, no session list capabilities.
|
|
23
|
+
- Multi-session: `load_session=True`, `session_capabilities.list` supported.
|
|
24
|
+
- `auth_methods=[]` (no authentication methods advertised).
|
|
25
|
+
|
|
26
|
+
## Session lifecycle (implemented behavior)
|
|
27
|
+
- `session/new`
|
|
28
|
+
- Multi-session: creates a persisted `Session`, builds `KimiCLI`, stores `ACPSession`.
|
|
29
|
+
- Single-session: wraps the existing `Soul` into a `Wire` loop and creates `ACPSession`.
|
|
30
|
+
- Both send `AvailableCommandsUpdate` for slash commands on session creation.
|
|
31
|
+
- MCP servers passed by ACP are converted via `acp_mcp_servers_to_mcp_config`.
|
|
32
|
+
- `session/load`
|
|
33
|
+
- Multi-session only: loads by `Session.find`, then builds `KimiCLI` and `ACPSession`.
|
|
34
|
+
- No history replay yet (TODO).
|
|
35
|
+
- Single-session: not implemented.
|
|
36
|
+
- `session/list`
|
|
37
|
+
- Multi-session only: lists sessions via `Session.list`, no pagination.
|
|
38
|
+
- Single-session: not implemented.
|
|
39
|
+
- `session/prompt`
|
|
40
|
+
- Uses `ACPSession.prompt()` to stream updates and produce a `stop_reason`.
|
|
41
|
+
- Stop reasons: `end_turn`, `max_turn_requests`, `cancelled`.
|
|
42
|
+
- `session/cancel`
|
|
43
|
+
- Sets the per-turn cancel event to stop the prompt.
|
|
44
|
+
|
|
45
|
+
## Streaming updates and content mapping
|
|
46
|
+
- Text chunks -> `AgentMessageChunk`.
|
|
47
|
+
- Think chunks -> `AgentThoughtChunk`.
|
|
48
|
+
- Tool calls:
|
|
49
|
+
- Start -> `ToolCallStart` with JSON args as text content.
|
|
50
|
+
- Streaming args -> `ToolCallProgress` with updated title/args.
|
|
51
|
+
- Results -> `ToolCallProgress` with `completed` or `failed`.
|
|
52
|
+
- Tool call IDs are prefixed with turn ID to avoid collisions across turns.
|
|
53
|
+
- Plan updates:
|
|
54
|
+
- `TodoDisplayBlock` is converted into `AgentPlanUpdate`.
|
|
55
|
+
- Available commands:
|
|
56
|
+
- `AvailableCommandsUpdate` is sent right after session creation.
|
|
57
|
+
|
|
58
|
+
## Prompt/content conversion
|
|
59
|
+
- Incoming prompt blocks:
|
|
60
|
+
- Supported: `TextContentBlock`, `ImageContentBlock` (converted to data URL).
|
|
61
|
+
- Unsupported types are logged and ignored.
|
|
62
|
+
- Tool result display blocks:
|
|
63
|
+
- `DiffDisplayBlock` -> `FileEditToolCallContent`.
|
|
64
|
+
- `HideOutputDisplayBlock` suppresses tool output in ACP (used by terminal tool).
|
|
65
|
+
|
|
66
|
+
## Tool integration and permission flow
|
|
67
|
+
- ACP sessions use `ACPKaos` to route filesystem reads/writes through ACP clients.
|
|
68
|
+
- If the client advertises `terminal` capability, the `Shell` tool is replaced by an
|
|
69
|
+
ACP-backed `Terminal` tool.
|
|
70
|
+
- Uses ACP `terminal/create`, waits for exit, streams `TerminalToolCallContent`,
|
|
71
|
+
then releases the terminal handle.
|
|
72
|
+
- Approval requests in the core tool system are bridged to ACP
|
|
73
|
+
`session/request_permission` with allow-once/allow-always/reject options.
|
|
74
|
+
|
|
75
|
+
## Current gaps / not implemented
|
|
76
|
+
- `authenticate` method (not used by current Zed ACP client).
|
|
77
|
+
- `session/set_mode` and `session/set_model` (no multi-mode/model switching in kimi-cli).
|
|
78
|
+
- `ext_method` / `ext_notification` for custom ACP extensions are stubbed.
|
|
79
|
+
- Single-session server does not implement `session/load` or `session/list`.
|
|
80
|
+
|
|
81
|
+
## Filesystem (ACP client-backed)
|
|
82
|
+
- When the client advertises `fs.readTextFile` / `fs.writeTextFile`, `ACPKaos` routes
|
|
83
|
+
reads and writes through ACP `fs/*` methods.
|
|
84
|
+
- `ReadFile` uses `KaosPath.read_lines`, which `ACPKaos` implements via ACP reads.
|
|
85
|
+
- `WriteFile` uses `KaosPath.read_text/write_text/append_text` and still generates diffs
|
|
86
|
+
and approvals in the tool layer.
|
|
87
|
+
|
|
88
|
+
## Zed-specific notes (as of current integration)
|
|
89
|
+
- Zed does not currently call `authenticate`.
|
|
90
|
+
- Zed’s external agent server session management is not yet available, so
|
|
91
|
+
`session/load` is not exercised in practice.
|
kimi_cli/acp/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
def acp_main() -> None:
|
|
2
|
+
"""Entry point for the multi-session ACP server."""
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import acp
|
|
6
|
+
|
|
7
|
+
from kimi_cli.acp.server import ACPServer
|
|
8
|
+
from kimi_cli.app import enable_logging
|
|
9
|
+
from kimi_cli.utils.logging import logger
|
|
10
|
+
|
|
11
|
+
enable_logging()
|
|
12
|
+
logger.info("Starting ACP server on stdio")
|
|
13
|
+
asyncio.run(acp.run_agent(ACPServer(), use_unstable_protocol=True))
|
kimi_cli/acp/convert.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import acp
|
|
4
|
+
|
|
5
|
+
from kimi_cli.acp.types import ACPContentBlock
|
|
6
|
+
from kimi_cli.utils.logging import logger
|
|
7
|
+
from kimi_cli.wire.types import (
|
|
8
|
+
ContentPart,
|
|
9
|
+
DiffDisplayBlock,
|
|
10
|
+
DisplayBlock,
|
|
11
|
+
ImageURLPart,
|
|
12
|
+
TextPart,
|
|
13
|
+
ToolReturnValue,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def acp_blocks_to_content_parts(prompt: list[ACPContentBlock]) -> list[ContentPart]:
|
|
18
|
+
content: list[ContentPart] = []
|
|
19
|
+
for block in prompt:
|
|
20
|
+
match block:
|
|
21
|
+
case acp.schema.TextContentBlock():
|
|
22
|
+
content.append(TextPart(text=block.text))
|
|
23
|
+
case acp.schema.ImageContentBlock():
|
|
24
|
+
content.append(
|
|
25
|
+
ImageURLPart(
|
|
26
|
+
image_url=ImageURLPart.ImageURL(
|
|
27
|
+
url=f"data:{block.mime_type};base64,{block.data}"
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
case _:
|
|
32
|
+
logger.warning("Unsupported prompt content block: {block}", block=block)
|
|
33
|
+
return content
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def display_block_to_acp_content(
|
|
37
|
+
block: DisplayBlock,
|
|
38
|
+
) -> acp.schema.FileEditToolCallContent | None:
|
|
39
|
+
if isinstance(block, DiffDisplayBlock):
|
|
40
|
+
return acp.schema.FileEditToolCallContent(
|
|
41
|
+
type="diff",
|
|
42
|
+
path=block.path,
|
|
43
|
+
old_text=block.old_text,
|
|
44
|
+
new_text=block.new_text,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def tool_result_to_acp_content(
|
|
51
|
+
tool_ret: ToolReturnValue,
|
|
52
|
+
) -> list[
|
|
53
|
+
acp.schema.ContentToolCallContent
|
|
54
|
+
| acp.schema.FileEditToolCallContent
|
|
55
|
+
| acp.schema.TerminalToolCallContent
|
|
56
|
+
]:
|
|
57
|
+
from kimi_cli.acp.tools import HideOutputDisplayBlock
|
|
58
|
+
|
|
59
|
+
def _to_acp_content(
|
|
60
|
+
part: ContentPart,
|
|
61
|
+
) -> (
|
|
62
|
+
acp.schema.ContentToolCallContent
|
|
63
|
+
| acp.schema.FileEditToolCallContent
|
|
64
|
+
| acp.schema.TerminalToolCallContent
|
|
65
|
+
):
|
|
66
|
+
if isinstance(part, TextPart):
|
|
67
|
+
return acp.schema.ContentToolCallContent(
|
|
68
|
+
type="content", content=acp.schema.TextContentBlock(type="text", text=part.text)
|
|
69
|
+
)
|
|
70
|
+
logger.warning("Unsupported content part in tool result: {part}", part=part)
|
|
71
|
+
return acp.schema.ContentToolCallContent(
|
|
72
|
+
type="content",
|
|
73
|
+
content=acp.schema.TextContentBlock(type="text", text=f"[{part.__class__.__name__}]"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _to_text_block(text: str) -> acp.schema.ContentToolCallContent:
|
|
77
|
+
return acp.schema.ContentToolCallContent(
|
|
78
|
+
type="content", content=acp.schema.TextContentBlock(type="text", text=text)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
contents: list[
|
|
82
|
+
acp.schema.ContentToolCallContent
|
|
83
|
+
| acp.schema.FileEditToolCallContent
|
|
84
|
+
| acp.schema.TerminalToolCallContent
|
|
85
|
+
] = []
|
|
86
|
+
|
|
87
|
+
for block in tool_ret.display:
|
|
88
|
+
if isinstance(block, HideOutputDisplayBlock):
|
|
89
|
+
# return early to indicate no output should be shown
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
content = display_block_to_acp_content(block)
|
|
93
|
+
if content is not None:
|
|
94
|
+
contents.append(content)
|
|
95
|
+
# TODO: better concatenation of `display` blocks and `output`?
|
|
96
|
+
|
|
97
|
+
output = tool_ret.output
|
|
98
|
+
if isinstance(output, str):
|
|
99
|
+
if output:
|
|
100
|
+
contents.append(_to_text_block(output))
|
|
101
|
+
else:
|
|
102
|
+
# NOTE: At the moment, ToolReturnValue.output is either a string or a
|
|
103
|
+
# list of ContentPart. We avoid an unnecessary isinstance() check here
|
|
104
|
+
# to keep pyright happy while still handling list outputs.
|
|
105
|
+
contents.extend(_to_acp_content(part) for part in output)
|
|
106
|
+
|
|
107
|
+
if not contents and tool_ret.message:
|
|
108
|
+
# Fallback to the `message` for LLM if there's no other content
|
|
109
|
+
contents.append(_to_text_block(tool_ret.message))
|
|
110
|
+
|
|
111
|
+
return contents
|
kimi_cli/acp/kaos.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator, Iterable
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import acp
|
|
9
|
+
from kaos import AsyncReadable, AsyncWritable, Kaos, KaosProcess, StatResult, StrOrKaosPath
|
|
10
|
+
from kaos.local import local_kaos
|
|
11
|
+
from kaos.path import KaosPath
|
|
12
|
+
|
|
13
|
+
_DEFAULT_TERMINAL_OUTPUT_LIMIT = 50_000
|
|
14
|
+
_DEFAULT_POLL_INTERVAL = 0.2
|
|
15
|
+
_TRUNCATION_NOTICE = "[acp output truncated]\n"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _NullWritable:
|
|
19
|
+
def can_write_eof(self) -> bool:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
def close(self) -> None:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
async def drain(self) -> None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def is_closing(self) -> bool:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
async def wait_closed(self) -> None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def write(self, data: bytes) -> None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def writelines(self, data: Iterable[bytes], /) -> None:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def write_eof(self) -> None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ACPProcess:
|
|
45
|
+
"""KAOS process adapter for ACP terminal execution."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
terminal: acp.TerminalHandle,
|
|
50
|
+
*,
|
|
51
|
+
poll_interval: float = _DEFAULT_POLL_INTERVAL,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._terminal = terminal
|
|
54
|
+
self._poll_interval = poll_interval
|
|
55
|
+
self._stdin = _NullWritable()
|
|
56
|
+
self._stdout = asyncio.StreamReader()
|
|
57
|
+
self._stderr = asyncio.StreamReader()
|
|
58
|
+
self.stdin: AsyncWritable = self._stdin
|
|
59
|
+
self.stdout: AsyncReadable = self._stdout
|
|
60
|
+
# ACP does not expose stderr separately; keep stderr empty.
|
|
61
|
+
self.stderr: AsyncReadable = self._stderr
|
|
62
|
+
self._returncode: int | None = None
|
|
63
|
+
self._last_output = ""
|
|
64
|
+
self._truncation_noted = False
|
|
65
|
+
self._exit_future: asyncio.Future[int] = asyncio.get_running_loop().create_future()
|
|
66
|
+
self._poll_task = asyncio.create_task(self._poll_output())
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def pid(self) -> int:
|
|
70
|
+
return -1
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def returncode(self) -> int | None:
|
|
74
|
+
return self._returncode
|
|
75
|
+
|
|
76
|
+
async def wait(self) -> int:
|
|
77
|
+
return await self._exit_future
|
|
78
|
+
|
|
79
|
+
async def kill(self) -> None:
|
|
80
|
+
await self._terminal.kill()
|
|
81
|
+
|
|
82
|
+
def _feed_output(self, output_response: acp.schema.TerminalOutputResponse) -> None:
|
|
83
|
+
output = output_response.output
|
|
84
|
+
reset = output_response.truncated or (
|
|
85
|
+
self._last_output and not output.startswith(self._last_output)
|
|
86
|
+
)
|
|
87
|
+
if reset and self._last_output and not self._truncation_noted:
|
|
88
|
+
self._stdout.feed_data(_TRUNCATION_NOTICE.encode("utf-8"))
|
|
89
|
+
self._truncation_noted = True
|
|
90
|
+
|
|
91
|
+
delta = output if reset else output[len(self._last_output) :]
|
|
92
|
+
if delta:
|
|
93
|
+
self._stdout.feed_data(delta.encode("utf-8", "replace"))
|
|
94
|
+
self._last_output = output
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _normalize_exit_code(exit_code: int | None) -> int:
|
|
98
|
+
return 1 if exit_code is None else exit_code
|
|
99
|
+
|
|
100
|
+
async def _poll_output(self) -> None:
|
|
101
|
+
exit_task = asyncio.create_task(self._terminal.wait_for_exit())
|
|
102
|
+
exit_code: int | None = None
|
|
103
|
+
try:
|
|
104
|
+
while True:
|
|
105
|
+
if exit_task.done():
|
|
106
|
+
exit_response = exit_task.result()
|
|
107
|
+
exit_code = exit_response.exit_code
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
output_response = await self._terminal.current_output()
|
|
111
|
+
self._feed_output(output_response)
|
|
112
|
+
if output_response.exit_status:
|
|
113
|
+
exit_code = output_response.exit_status.exit_code
|
|
114
|
+
try:
|
|
115
|
+
exit_response = await exit_task
|
|
116
|
+
exit_code = exit_response.exit_code or exit_code
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
await asyncio.sleep(self._poll_interval)
|
|
122
|
+
|
|
123
|
+
final_output = await self._terminal.current_output()
|
|
124
|
+
self._feed_output(final_output)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
error_note = f"[acp terminal error] {exc}\n"
|
|
127
|
+
self._stdout.feed_data(error_note.encode("utf-8", "replace"))
|
|
128
|
+
if exit_code is None:
|
|
129
|
+
exit_code = 1
|
|
130
|
+
finally:
|
|
131
|
+
if not exit_task.done():
|
|
132
|
+
exit_task.cancel()
|
|
133
|
+
with suppress(Exception):
|
|
134
|
+
await exit_task
|
|
135
|
+
self._returncode = self._normalize_exit_code(exit_code)
|
|
136
|
+
self._stdout.feed_eof()
|
|
137
|
+
self._stderr.feed_eof()
|
|
138
|
+
if not self._exit_future.done():
|
|
139
|
+
self._exit_future.set_result(self._returncode)
|
|
140
|
+
with suppress(Exception):
|
|
141
|
+
await self._terminal.release()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ACPKaos:
|
|
145
|
+
"""KAOS backend that routes supported operations through ACP."""
|
|
146
|
+
|
|
147
|
+
name: str = "acp"
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
client: acp.Client,
|
|
152
|
+
session_id: str,
|
|
153
|
+
client_capabilities: acp.schema.ClientCapabilities | None,
|
|
154
|
+
fallback: Kaos | None = None,
|
|
155
|
+
*,
|
|
156
|
+
output_byte_limit: int | None = _DEFAULT_TERMINAL_OUTPUT_LIMIT,
|
|
157
|
+
poll_interval: float = _DEFAULT_POLL_INTERVAL,
|
|
158
|
+
) -> None:
|
|
159
|
+
self._client = client
|
|
160
|
+
self._session_id = session_id
|
|
161
|
+
self._fallback = fallback or local_kaos
|
|
162
|
+
fs = client_capabilities.fs if client_capabilities else None
|
|
163
|
+
self._supports_read = bool(fs and fs.read_text_file)
|
|
164
|
+
self._supports_write = bool(fs and fs.write_text_file)
|
|
165
|
+
self._supports_terminal = bool(client_capabilities and client_capabilities.terminal)
|
|
166
|
+
self._output_byte_limit = output_byte_limit
|
|
167
|
+
self._poll_interval = poll_interval
|
|
168
|
+
|
|
169
|
+
def pathclass(self):
|
|
170
|
+
return self._fallback.pathclass()
|
|
171
|
+
|
|
172
|
+
def normpath(self, path: StrOrKaosPath) -> KaosPath:
|
|
173
|
+
return self._fallback.normpath(path)
|
|
174
|
+
|
|
175
|
+
def gethome(self) -> KaosPath:
|
|
176
|
+
return self._fallback.gethome()
|
|
177
|
+
|
|
178
|
+
def getcwd(self) -> KaosPath:
|
|
179
|
+
return self._fallback.getcwd()
|
|
180
|
+
|
|
181
|
+
async def chdir(self, path: StrOrKaosPath) -> None:
|
|
182
|
+
await self._fallback.chdir(path)
|
|
183
|
+
|
|
184
|
+
async def stat(self, path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult:
|
|
185
|
+
return await self._fallback.stat(path, follow_symlinks=follow_symlinks)
|
|
186
|
+
|
|
187
|
+
def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]:
|
|
188
|
+
return self._fallback.iterdir(path)
|
|
189
|
+
|
|
190
|
+
def glob(
|
|
191
|
+
self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True
|
|
192
|
+
) -> AsyncGenerator[KaosPath]:
|
|
193
|
+
return self._fallback.glob(path, pattern, case_sensitive=case_sensitive)
|
|
194
|
+
|
|
195
|
+
async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes:
|
|
196
|
+
return await self._fallback.readbytes(path, n=n)
|
|
197
|
+
|
|
198
|
+
async def readtext(
|
|
199
|
+
self,
|
|
200
|
+
path: StrOrKaosPath,
|
|
201
|
+
*,
|
|
202
|
+
encoding: str = "utf-8",
|
|
203
|
+
errors: Literal["strict", "ignore", "replace"] = "strict",
|
|
204
|
+
) -> str:
|
|
205
|
+
abs_path = self._abs_path(path)
|
|
206
|
+
if not self._supports_read:
|
|
207
|
+
return await self._fallback.readtext(abs_path, encoding=encoding, errors=errors)
|
|
208
|
+
response = await self._client.read_text_file(path=abs_path, session_id=self._session_id)
|
|
209
|
+
return response.content
|
|
210
|
+
|
|
211
|
+
async def readlines(
|
|
212
|
+
self,
|
|
213
|
+
path: StrOrKaosPath,
|
|
214
|
+
*,
|
|
215
|
+
encoding: str = "utf-8",
|
|
216
|
+
errors: Literal["strict", "ignore", "replace"] = "strict",
|
|
217
|
+
) -> AsyncGenerator[str]:
|
|
218
|
+
text = await self.readtext(path, encoding=encoding, errors=errors)
|
|
219
|
+
for line in text.splitlines(keepends=True):
|
|
220
|
+
yield line
|
|
221
|
+
|
|
222
|
+
async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int:
|
|
223
|
+
return await self._fallback.writebytes(path, data)
|
|
224
|
+
|
|
225
|
+
async def writetext(
|
|
226
|
+
self,
|
|
227
|
+
path: StrOrKaosPath,
|
|
228
|
+
data: str,
|
|
229
|
+
*,
|
|
230
|
+
mode: Literal["w", "a"] = "w",
|
|
231
|
+
encoding: str = "utf-8",
|
|
232
|
+
errors: Literal["strict", "ignore", "replace"] = "strict",
|
|
233
|
+
) -> int:
|
|
234
|
+
abs_path = self._abs_path(path)
|
|
235
|
+
if mode == "a":
|
|
236
|
+
if self._supports_read and self._supports_write:
|
|
237
|
+
existing = await self.readtext(abs_path, encoding=encoding, errors=errors)
|
|
238
|
+
await self._client.write_text_file(
|
|
239
|
+
path=abs_path,
|
|
240
|
+
content=existing + data,
|
|
241
|
+
session_id=self._session_id,
|
|
242
|
+
)
|
|
243
|
+
return len(data)
|
|
244
|
+
return await self._fallback.writetext(
|
|
245
|
+
abs_path, data, mode="a", encoding=encoding, errors=errors
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if not self._supports_write:
|
|
249
|
+
return await self._fallback.writetext(
|
|
250
|
+
abs_path, data, mode=mode, encoding=encoding, errors=errors
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await self._client.write_text_file(
|
|
254
|
+
path=abs_path,
|
|
255
|
+
content=data,
|
|
256
|
+
session_id=self._session_id,
|
|
257
|
+
)
|
|
258
|
+
return len(data)
|
|
259
|
+
|
|
260
|
+
async def mkdir(
|
|
261
|
+
self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False
|
|
262
|
+
) -> None:
|
|
263
|
+
await self._fallback.mkdir(path, parents=parents, exist_ok=exist_ok)
|
|
264
|
+
|
|
265
|
+
async def exec(self, *args: str) -> KaosProcess:
|
|
266
|
+
return await self._fallback.exec(*args)
|
|
267
|
+
|
|
268
|
+
def _abs_path(self, path: StrOrKaosPath) -> str:
|
|
269
|
+
kaos_path = path if isinstance(path, KaosPath) else KaosPath(path)
|
|
270
|
+
return str(kaos_path.canonical())
|
kimi_cli/acp/mcp.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import acp.schema
|
|
6
|
+
from fastmcp.mcp_config import MCPConfig
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from kimi_cli.acp.types import MCPServer
|
|
10
|
+
from kimi_cli.exception import MCPConfigError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def acp_mcp_servers_to_mcp_config(mcp_servers: list[MCPServer]) -> MCPConfig:
|
|
14
|
+
if not mcp_servers:
|
|
15
|
+
return MCPConfig()
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
return MCPConfig.model_validate(
|
|
19
|
+
{"mcpServers": {server.name: _convert_acp_mcp_server(server) for server in mcp_servers}}
|
|
20
|
+
)
|
|
21
|
+
except ValidationError as exc:
|
|
22
|
+
raise MCPConfigError(f"Invalid MCP config from ACP client: {exc}") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _convert_acp_mcp_server(server: MCPServer) -> dict[str, Any]:
|
|
26
|
+
"""Convert an ACP MCP server to a dictionary representation."""
|
|
27
|
+
match server:
|
|
28
|
+
case acp.schema.HttpMcpServer():
|
|
29
|
+
return {
|
|
30
|
+
"url": server.url,
|
|
31
|
+
"transport": "http",
|
|
32
|
+
"headers": {header.name: header.value for header in server.headers},
|
|
33
|
+
}
|
|
34
|
+
case acp.schema.SseMcpServer():
|
|
35
|
+
return {
|
|
36
|
+
"url": server.url,
|
|
37
|
+
"transport": "sse",
|
|
38
|
+
"headers": {header.name: header.value for header in server.headers},
|
|
39
|
+
}
|
|
40
|
+
case acp.schema.McpServerStdio():
|
|
41
|
+
return {
|
|
42
|
+
"command": server.command,
|
|
43
|
+
"args": server.args,
|
|
44
|
+
"env": {item.name: item.value for item in server.env},
|
|
45
|
+
"transport": "stdio",
|
|
46
|
+
}
|