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/cli/mcp.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated, Any, Literal
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
cli = typer.Typer(help="Manage MCP server configurations.")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_global_mcp_config_file() -> Path:
|
|
11
|
+
"""Get the global MCP config file path."""
|
|
12
|
+
from kimi_cli.share import get_share_dir
|
|
13
|
+
|
|
14
|
+
return get_share_dir() / "mcp.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_mcp_config() -> dict[str, Any]:
|
|
18
|
+
"""Load MCP config from global mcp config file."""
|
|
19
|
+
from fastmcp.mcp_config import MCPConfig
|
|
20
|
+
from pydantic import ValidationError
|
|
21
|
+
|
|
22
|
+
mcp_file = get_global_mcp_config_file()
|
|
23
|
+
if not mcp_file.exists():
|
|
24
|
+
return {"mcpServers": {}}
|
|
25
|
+
try:
|
|
26
|
+
config = json.loads(mcp_file.read_text(encoding="utf-8"))
|
|
27
|
+
except json.JSONDecodeError as e:
|
|
28
|
+
raise typer.BadParameter(f"Invalid JSON in MCP config file '{mcp_file}': {e}") from e
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
MCPConfig.model_validate(config)
|
|
32
|
+
except ValidationError as e:
|
|
33
|
+
raise typer.BadParameter(f"Invalid MCP config in '{mcp_file}': {e}") from e
|
|
34
|
+
|
|
35
|
+
return config
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _save_mcp_config(config: dict[str, Any]) -> None:
|
|
39
|
+
"""Save MCP config to default file."""
|
|
40
|
+
mcp_file = get_global_mcp_config_file()
|
|
41
|
+
mcp_file.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_mcp_server(name: str, *, require_remote: bool = False) -> dict[str, Any]:
|
|
45
|
+
"""Get MCP server config by name."""
|
|
46
|
+
config = _load_mcp_config()
|
|
47
|
+
servers = config.get("mcpServers", {})
|
|
48
|
+
if name not in servers:
|
|
49
|
+
typer.echo(f"MCP server '{name}' not found.", err=True)
|
|
50
|
+
raise typer.Exit(code=1)
|
|
51
|
+
server = servers[name]
|
|
52
|
+
if require_remote and "url" not in server:
|
|
53
|
+
typer.echo(f"MCP server '{name}' is not a remote server.", err=True)
|
|
54
|
+
raise typer.Exit(code=1)
|
|
55
|
+
return server
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_key_value_pairs(
|
|
59
|
+
items: list[str], option_name: str, *, separator: str = "=", strip_whitespace: bool = False
|
|
60
|
+
) -> dict[str, str]:
|
|
61
|
+
"""Parse key/value pairs from CLI options."""
|
|
62
|
+
parsed: dict[str, str] = {}
|
|
63
|
+
for item in items:
|
|
64
|
+
if separator not in item:
|
|
65
|
+
typer.echo(
|
|
66
|
+
f"Invalid {option_name} format: {item} (expected KEY{separator}VALUE).",
|
|
67
|
+
err=True,
|
|
68
|
+
)
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
key, value = item.split(separator, 1)
|
|
71
|
+
if strip_whitespace:
|
|
72
|
+
key, value = key.strip(), value.strip()
|
|
73
|
+
if not key:
|
|
74
|
+
typer.echo(f"Invalid {option_name} format: {item} (empty key).", err=True)
|
|
75
|
+
raise typer.Exit(code=1)
|
|
76
|
+
parsed[key] = value
|
|
77
|
+
return parsed
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Transport = Literal["stdio", "http"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@cli.command(
|
|
84
|
+
"add",
|
|
85
|
+
epilog="""
|
|
86
|
+
Examples:\n
|
|
87
|
+
\n
|
|
88
|
+
# Add streamable HTTP server:\n
|
|
89
|
+
kimi mcp add --transport http context7 https://mcp.context7.com/mcp --header \"CONTEXT7_API_KEY: ctx7sk-your-key\"\n
|
|
90
|
+
\n
|
|
91
|
+
# Add streamable HTTP server with OAuth authorization:\n
|
|
92
|
+
kimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp\n
|
|
93
|
+
\n
|
|
94
|
+
# Add stdio server:\n
|
|
95
|
+
kimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest
|
|
96
|
+
""".strip(), # noqa: E501
|
|
97
|
+
)
|
|
98
|
+
def mcp_add(
|
|
99
|
+
name: Annotated[
|
|
100
|
+
str,
|
|
101
|
+
typer.Argument(help="Name of the MCP server to add."),
|
|
102
|
+
],
|
|
103
|
+
server_args: Annotated[
|
|
104
|
+
list[str] | None,
|
|
105
|
+
typer.Argument(
|
|
106
|
+
metavar="TARGET_OR_COMMAND...",
|
|
107
|
+
help="For http: server URL. For stdio: command to run (prefix with `--`).",
|
|
108
|
+
),
|
|
109
|
+
] = None,
|
|
110
|
+
transport: Annotated[
|
|
111
|
+
Transport,
|
|
112
|
+
typer.Option(
|
|
113
|
+
"--transport",
|
|
114
|
+
"-t",
|
|
115
|
+
help="Transport type for the MCP server. Default: stdio.",
|
|
116
|
+
),
|
|
117
|
+
] = "stdio",
|
|
118
|
+
env: Annotated[
|
|
119
|
+
list[str] | None,
|
|
120
|
+
typer.Option(
|
|
121
|
+
"--env",
|
|
122
|
+
"-e",
|
|
123
|
+
help="Environment variables in KEY=VALUE format. Can be specified multiple times.",
|
|
124
|
+
),
|
|
125
|
+
] = None,
|
|
126
|
+
header: Annotated[
|
|
127
|
+
list[str] | None,
|
|
128
|
+
typer.Option(
|
|
129
|
+
"--header",
|
|
130
|
+
"-H",
|
|
131
|
+
help="HTTP headers in KEY:VALUE format. Can be specified multiple times.",
|
|
132
|
+
),
|
|
133
|
+
] = None,
|
|
134
|
+
auth: Annotated[
|
|
135
|
+
str | None,
|
|
136
|
+
typer.Option(
|
|
137
|
+
"--auth",
|
|
138
|
+
"-a",
|
|
139
|
+
help="Authorization type (e.g., 'oauth').",
|
|
140
|
+
),
|
|
141
|
+
] = None,
|
|
142
|
+
):
|
|
143
|
+
"""Add an MCP server."""
|
|
144
|
+
config = _load_mcp_config()
|
|
145
|
+
server_args = server_args or []
|
|
146
|
+
|
|
147
|
+
if transport not in {"stdio", "http"}:
|
|
148
|
+
typer.echo(f"Unsupported transport: {transport}.", err=True)
|
|
149
|
+
raise typer.Exit(code=1)
|
|
150
|
+
|
|
151
|
+
if transport == "stdio":
|
|
152
|
+
if not server_args:
|
|
153
|
+
typer.echo(
|
|
154
|
+
"For stdio transport, provide the command to start the MCP server after `--`.",
|
|
155
|
+
err=True,
|
|
156
|
+
)
|
|
157
|
+
raise typer.Exit(code=1)
|
|
158
|
+
if header:
|
|
159
|
+
typer.echo("--header is only valid for http transport.", err=True)
|
|
160
|
+
raise typer.Exit(code=1)
|
|
161
|
+
if auth:
|
|
162
|
+
typer.echo("--auth is only valid for http transport.", err=True)
|
|
163
|
+
raise typer.Exit(code=1)
|
|
164
|
+
command, *command_args = server_args
|
|
165
|
+
server_config: dict[str, Any] = {"command": command, "args": command_args}
|
|
166
|
+
if env:
|
|
167
|
+
server_config["env"] = _parse_key_value_pairs(env, "env")
|
|
168
|
+
else:
|
|
169
|
+
if env:
|
|
170
|
+
typer.echo("--env is only supported for stdio transport.", err=True)
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
if not server_args:
|
|
173
|
+
typer.echo("URL is required for http transport.", err=True)
|
|
174
|
+
raise typer.Exit(code=1)
|
|
175
|
+
if len(server_args) > 1:
|
|
176
|
+
typer.echo(
|
|
177
|
+
"Multiple targets provided. Supply a single URL for http transport.",
|
|
178
|
+
err=True,
|
|
179
|
+
)
|
|
180
|
+
raise typer.Exit(code=1)
|
|
181
|
+
server_config = {"url": server_args[0], "transport": "http"}
|
|
182
|
+
if header:
|
|
183
|
+
server_config["headers"] = _parse_key_value_pairs(
|
|
184
|
+
header, "header", separator=":", strip_whitespace=True
|
|
185
|
+
)
|
|
186
|
+
if auth:
|
|
187
|
+
server_config["auth"] = auth
|
|
188
|
+
|
|
189
|
+
if "mcpServers" not in config:
|
|
190
|
+
config["mcpServers"] = {}
|
|
191
|
+
config["mcpServers"][name] = server_config
|
|
192
|
+
_save_mcp_config(config)
|
|
193
|
+
typer.echo(f"Added MCP server '{name}' to {get_global_mcp_config_file()}.")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command("remove")
|
|
197
|
+
def mcp_remove(
|
|
198
|
+
name: Annotated[
|
|
199
|
+
str,
|
|
200
|
+
typer.Argument(help="Name of the MCP server to remove."),
|
|
201
|
+
],
|
|
202
|
+
):
|
|
203
|
+
"""Remove an MCP server."""
|
|
204
|
+
_get_mcp_server(name)
|
|
205
|
+
config = _load_mcp_config()
|
|
206
|
+
del config["mcpServers"][name]
|
|
207
|
+
_save_mcp_config(config)
|
|
208
|
+
typer.echo(f"Removed MCP server '{name}' from {get_global_mcp_config_file()}.")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _has_oauth_tokens(server_url: str) -> bool:
|
|
212
|
+
"""Check if OAuth tokens exist for the server."""
|
|
213
|
+
import asyncio
|
|
214
|
+
|
|
215
|
+
async def _check() -> bool:
|
|
216
|
+
try:
|
|
217
|
+
from fastmcp.client.auth.oauth import FileTokenStorage
|
|
218
|
+
|
|
219
|
+
storage = FileTokenStorage(server_url=server_url)
|
|
220
|
+
tokens = await storage.get_tokens()
|
|
221
|
+
return tokens is not None
|
|
222
|
+
except Exception:
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
return asyncio.run(_check())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@cli.command("list")
|
|
229
|
+
def mcp_list():
|
|
230
|
+
"""List all MCP servers."""
|
|
231
|
+
config_file = get_global_mcp_config_file()
|
|
232
|
+
config = _load_mcp_config()
|
|
233
|
+
servers: dict[str, Any] = config.get("mcpServers", {})
|
|
234
|
+
|
|
235
|
+
typer.echo(f"MCP config file: {config_file}")
|
|
236
|
+
if not servers:
|
|
237
|
+
typer.echo("No MCP servers configured.")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
for name, server in servers.items():
|
|
241
|
+
if "command" in server:
|
|
242
|
+
cmd = server["command"]
|
|
243
|
+
cmd_args = " ".join(server.get("args", []))
|
|
244
|
+
line = f"{name} (stdio): {cmd} {cmd_args}".rstrip()
|
|
245
|
+
elif "url" in server:
|
|
246
|
+
transport = server.get("transport") or "http"
|
|
247
|
+
if transport == "streamable-http":
|
|
248
|
+
transport = "http"
|
|
249
|
+
line = f"{name} ({transport}): {server['url']}"
|
|
250
|
+
if server.get("auth") == "oauth" and not _has_oauth_tokens(server["url"]):
|
|
251
|
+
line += " [authorization required - run: kimi mcp auth " + name + "]"
|
|
252
|
+
else:
|
|
253
|
+
line = f"{name}: {server}"
|
|
254
|
+
typer.echo(f" {line}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@cli.command("auth")
|
|
258
|
+
def mcp_auth(
|
|
259
|
+
name: Annotated[
|
|
260
|
+
str,
|
|
261
|
+
typer.Argument(help="Name of the MCP server to authorize."),
|
|
262
|
+
],
|
|
263
|
+
):
|
|
264
|
+
"""Authorize with an OAuth-enabled MCP server."""
|
|
265
|
+
import asyncio
|
|
266
|
+
|
|
267
|
+
server = _get_mcp_server(name, require_remote=True)
|
|
268
|
+
if server.get("auth") != "oauth":
|
|
269
|
+
typer.echo(f"MCP server '{name}' does not use OAuth. Add with --auth oauth.", err=True)
|
|
270
|
+
raise typer.Exit(code=1)
|
|
271
|
+
|
|
272
|
+
async def _auth() -> None:
|
|
273
|
+
import fastmcp
|
|
274
|
+
|
|
275
|
+
typer.echo(f"Authorizing with '{name}'...")
|
|
276
|
+
typer.echo("A browser window will open for authorization.")
|
|
277
|
+
|
|
278
|
+
client = fastmcp.Client({"mcpServers": {name: server}})
|
|
279
|
+
try:
|
|
280
|
+
async with client:
|
|
281
|
+
tools = await client.list_tools()
|
|
282
|
+
typer.echo(f"Successfully authorized with '{name}'.")
|
|
283
|
+
typer.echo(f"Available tools: {len(tools)}")
|
|
284
|
+
except Exception as e:
|
|
285
|
+
typer.echo(f"Authorization failed: {type(e).__name__}: {e}", err=True)
|
|
286
|
+
raise typer.Exit(code=1) from None
|
|
287
|
+
|
|
288
|
+
asyncio.run(_auth())
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@cli.command("reset-auth")
|
|
292
|
+
def mcp_reset_auth(
|
|
293
|
+
name: Annotated[
|
|
294
|
+
str,
|
|
295
|
+
typer.Argument(help="Name of the MCP server to reset authorization."),
|
|
296
|
+
],
|
|
297
|
+
):
|
|
298
|
+
"""Reset OAuth authorization for an MCP server (clear cached tokens)."""
|
|
299
|
+
server = _get_mcp_server(name, require_remote=True)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
from fastmcp.client.auth.oauth import FileTokenStorage
|
|
303
|
+
|
|
304
|
+
storage = FileTokenStorage(server_url=server["url"])
|
|
305
|
+
storage.clear()
|
|
306
|
+
typer.echo(f"OAuth tokens cleared for '{name}'.")
|
|
307
|
+
except ImportError:
|
|
308
|
+
typer.echo("OAuth support not available.", err=True)
|
|
309
|
+
raise typer.Exit(code=1) from None
|
|
310
|
+
except Exception as e:
|
|
311
|
+
typer.echo(f"Failed to clear tokens: {type(e).__name__}: {e}", err=True)
|
|
312
|
+
raise typer.Exit(code=1) from None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@cli.command("test")
|
|
316
|
+
def mcp_test(
|
|
317
|
+
name: Annotated[
|
|
318
|
+
str,
|
|
319
|
+
typer.Argument(help="Name of the MCP server to test."),
|
|
320
|
+
],
|
|
321
|
+
):
|
|
322
|
+
"""Test connection to an MCP server and list available tools."""
|
|
323
|
+
import asyncio
|
|
324
|
+
|
|
325
|
+
server = _get_mcp_server(name)
|
|
326
|
+
|
|
327
|
+
async def _test() -> None:
|
|
328
|
+
import fastmcp
|
|
329
|
+
|
|
330
|
+
typer.echo(f"Testing connection to '{name}'...")
|
|
331
|
+
client = fastmcp.Client({"mcpServers": {name: server}})
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
async with client:
|
|
335
|
+
tools = await client.list_tools()
|
|
336
|
+
typer.echo(f"✓ Connected to '{name}'")
|
|
337
|
+
typer.echo(f" Available tools: {len(tools)}")
|
|
338
|
+
if tools:
|
|
339
|
+
typer.echo(" Tools:")
|
|
340
|
+
for tool in tools:
|
|
341
|
+
desc = tool.description or ""
|
|
342
|
+
if len(desc) > 50:
|
|
343
|
+
desc = desc[:47] + "..."
|
|
344
|
+
typer.echo(f" - {tool.name}: {desc}")
|
|
345
|
+
except Exception as e:
|
|
346
|
+
typer.echo(f"✗ Connection failed: {type(e).__name__}: {e}", err=True)
|
|
347
|
+
raise typer.Exit(code=1) from None
|
|
348
|
+
|
|
349
|
+
asyncio.run(_test())
|
kimi_cli/config.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
4
|
from pathlib import Path
|
|
3
|
-
from typing import
|
|
5
|
+
from typing import Self
|
|
4
6
|
|
|
7
|
+
import tomlkit
|
|
5
8
|
from pydantic import BaseModel, Field, SecretStr, ValidationError, field_serializer, model_validator
|
|
9
|
+
from tomlkit.exceptions import TOMLKitError
|
|
6
10
|
|
|
7
11
|
from kimi_cli.exception import ConfigError
|
|
12
|
+
from kimi_cli.llm import ModelCapability, ProviderType
|
|
8
13
|
from kimi_cli.share import get_share_dir
|
|
9
14
|
from kimi_cli.utils.logging import logger
|
|
10
15
|
|
|
@@ -12,12 +17,14 @@ from kimi_cli.utils.logging import logger
|
|
|
12
17
|
class LLMProvider(BaseModel):
|
|
13
18
|
"""LLM provider configuration."""
|
|
14
19
|
|
|
15
|
-
type:
|
|
20
|
+
type: ProviderType
|
|
16
21
|
"""Provider type"""
|
|
17
22
|
base_url: str
|
|
18
23
|
"""API base URL"""
|
|
19
24
|
api_key: SecretStr
|
|
20
25
|
"""API key"""
|
|
26
|
+
env: dict[str, str] | None = None
|
|
27
|
+
"""Environment variables to set before creating the provider instance"""
|
|
21
28
|
custom_headers: dict[str, str] | None = None
|
|
22
29
|
"""Custom headers to include in API requests"""
|
|
23
30
|
|
|
@@ -26,9 +33,6 @@ class LLMProvider(BaseModel):
|
|
|
26
33
|
return v.get_secret_value()
|
|
27
34
|
|
|
28
35
|
|
|
29
|
-
LLMModelCapability = Literal["image_in"]
|
|
30
|
-
|
|
31
|
-
|
|
32
36
|
class LLMModel(BaseModel):
|
|
33
37
|
"""LLM model configuration."""
|
|
34
38
|
|
|
@@ -38,17 +42,19 @@ class LLMModel(BaseModel):
|
|
|
38
42
|
"""Model name"""
|
|
39
43
|
max_context_size: int
|
|
40
44
|
"""Maximum context size (unit: tokens)"""
|
|
41
|
-
capabilities: set[
|
|
45
|
+
capabilities: set[ModelCapability] | None = None
|
|
42
46
|
"""Model capabilities"""
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
class LoopControl(BaseModel):
|
|
46
50
|
"""Agent loop control configuration."""
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
"""Maximum number of steps in one
|
|
50
|
-
max_retries_per_step: int = 3
|
|
52
|
+
max_steps_per_turn: int = Field(default=100, ge=1, validation_alias="max_steps_per_run")
|
|
53
|
+
"""Maximum number of steps in one turn"""
|
|
54
|
+
max_retries_per_step: int = Field(default=3, ge=1)
|
|
51
55
|
"""Maximum number of retries in one step"""
|
|
56
|
+
max_ralph_iterations: int = Field(default=0, ge=-1)
|
|
57
|
+
"""Extra iterations after the first turn in Ralph mode. Use -1 for unlimited."""
|
|
52
58
|
|
|
53
59
|
|
|
54
60
|
class MoonshotSearchConfig(BaseModel):
|
|
@@ -66,23 +72,62 @@ class MoonshotSearchConfig(BaseModel):
|
|
|
66
72
|
return v.get_secret_value()
|
|
67
73
|
|
|
68
74
|
|
|
75
|
+
class MoonshotFetchConfig(BaseModel):
|
|
76
|
+
"""Moonshot Fetch configuration."""
|
|
77
|
+
|
|
78
|
+
base_url: str
|
|
79
|
+
"""Base URL for Moonshot Fetch service."""
|
|
80
|
+
api_key: SecretStr
|
|
81
|
+
"""API key for Moonshot Fetch service."""
|
|
82
|
+
custom_headers: dict[str, str] | None = None
|
|
83
|
+
"""Custom headers to include in API requests."""
|
|
84
|
+
|
|
85
|
+
@field_serializer("api_key", when_used="json")
|
|
86
|
+
def dump_secret(self, v: SecretStr):
|
|
87
|
+
return v.get_secret_value()
|
|
88
|
+
|
|
89
|
+
|
|
69
90
|
class Services(BaseModel):
|
|
70
91
|
"""Services configuration."""
|
|
71
92
|
|
|
72
93
|
moonshot_search: MoonshotSearchConfig | None = None
|
|
73
94
|
"""Moonshot Search configuration."""
|
|
95
|
+
moonshot_fetch: MoonshotFetchConfig | None = None
|
|
96
|
+
"""Moonshot Fetch configuration."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MCPClientConfig(BaseModel):
|
|
100
|
+
"""MCP client configuration."""
|
|
101
|
+
|
|
102
|
+
tool_call_timeout_ms: int = 60000
|
|
103
|
+
"""Timeout for tool calls in milliseconds."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MCPConfig(BaseModel):
|
|
107
|
+
"""MCP configuration."""
|
|
108
|
+
|
|
109
|
+
client: MCPClientConfig = Field(
|
|
110
|
+
default_factory=MCPClientConfig, description="MCP client configuration"
|
|
111
|
+
)
|
|
74
112
|
|
|
75
113
|
|
|
76
114
|
class Config(BaseModel):
|
|
77
115
|
"""Main configuration structure."""
|
|
78
116
|
|
|
117
|
+
is_from_default_location: bool = Field(
|
|
118
|
+
default=False,
|
|
119
|
+
description="Whether the config was loaded from the default location",
|
|
120
|
+
exclude=True,
|
|
121
|
+
)
|
|
79
122
|
default_model: str = Field(default="", description="Default model to use")
|
|
123
|
+
default_thinking: bool = Field(default=False, description="Default thinking mode")
|
|
80
124
|
models: dict[str, LLMModel] = Field(default_factory=dict, description="List of LLM models")
|
|
81
125
|
providers: dict[str, LLMProvider] = Field(
|
|
82
126
|
default_factory=dict, description="List of LLM providers"
|
|
83
127
|
)
|
|
84
128
|
loop_control: LoopControl = Field(default_factory=LoopControl, description="Agent loop control")
|
|
85
129
|
services: Services = Field(default_factory=Services, description="Services configuration")
|
|
130
|
+
mcp: MCPConfig = Field(default_factory=MCPConfig, description="MCP configuration")
|
|
86
131
|
|
|
87
132
|
@model_validator(mode="after")
|
|
88
133
|
def validate_model(self) -> Self:
|
|
@@ -96,7 +141,7 @@ class Config(BaseModel):
|
|
|
96
141
|
|
|
97
142
|
def get_config_file() -> Path:
|
|
98
143
|
"""Get the configuration file path."""
|
|
99
|
-
return get_share_dir() / "config.
|
|
144
|
+
return get_share_dir() / "config.toml"
|
|
100
145
|
|
|
101
146
|
|
|
102
147
|
def get_default_config() -> Config:
|
|
@@ -123,24 +168,79 @@ def load_config(config_file: Path | None = None) -> Config:
|
|
|
123
168
|
Raises:
|
|
124
169
|
ConfigError: If the configuration file is invalid.
|
|
125
170
|
"""
|
|
126
|
-
|
|
171
|
+
default_config_file = get_config_file()
|
|
172
|
+
if config_file is None:
|
|
173
|
+
config_file = default_config_file
|
|
174
|
+
is_default_config_file = config_file.expanduser().resolve(
|
|
175
|
+
strict=False
|
|
176
|
+
) == default_config_file.expanduser().resolve(strict=False)
|
|
127
177
|
logger.debug("Loading config from file: {file}", file=config_file)
|
|
128
178
|
|
|
179
|
+
# If the user hasn't provided an explicit config path, migrate legacy JSON config once.
|
|
180
|
+
if is_default_config_file and not config_file.exists():
|
|
181
|
+
_migrate_json_config_to_toml()
|
|
182
|
+
|
|
129
183
|
if not config_file.exists():
|
|
130
184
|
config = get_default_config()
|
|
131
185
|
logger.debug("No config file found, creating default config: {config}", config=config)
|
|
132
|
-
|
|
133
|
-
|
|
186
|
+
save_config(config, config_file)
|
|
187
|
+
config.is_from_default_location = is_default_config_file
|
|
134
188
|
return config
|
|
135
189
|
|
|
136
190
|
try:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
191
|
+
config_text = config_file.read_text(encoding="utf-8")
|
|
192
|
+
if config_file.suffix.lower() == ".json":
|
|
193
|
+
data = json.loads(config_text)
|
|
194
|
+
else:
|
|
195
|
+
data = tomlkit.loads(config_text)
|
|
196
|
+
config = Config.model_validate(data)
|
|
140
197
|
except json.JSONDecodeError as e:
|
|
141
198
|
raise ConfigError(f"Invalid JSON in configuration file: {e}") from e
|
|
199
|
+
except TOMLKitError as e:
|
|
200
|
+
raise ConfigError(f"Invalid TOML in configuration file: {e}") from e
|
|
142
201
|
except ValidationError as e:
|
|
143
202
|
raise ConfigError(f"Invalid configuration file: {e}") from e
|
|
203
|
+
config.is_from_default_location = is_default_config_file
|
|
204
|
+
return config
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_config_from_string(config_string: str) -> Config:
|
|
208
|
+
"""
|
|
209
|
+
Load configuration from a TOML or JSON string.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
config_string (str): TOML or JSON configuration text.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Validated Config object.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ConfigError: If the configuration text is invalid.
|
|
219
|
+
"""
|
|
220
|
+
if not config_string.strip():
|
|
221
|
+
raise ConfigError("Configuration text cannot be empty")
|
|
222
|
+
|
|
223
|
+
json_error: json.JSONDecodeError | None = None
|
|
224
|
+
try:
|
|
225
|
+
data = json.loads(config_string)
|
|
226
|
+
except json.JSONDecodeError as exc:
|
|
227
|
+
json_error = exc
|
|
228
|
+
data = None
|
|
229
|
+
|
|
230
|
+
if data is None:
|
|
231
|
+
try:
|
|
232
|
+
data = tomlkit.loads(config_string)
|
|
233
|
+
except TOMLKitError as toml_error:
|
|
234
|
+
raise ConfigError(
|
|
235
|
+
f"Invalid configuration text: {json_error}; {toml_error}"
|
|
236
|
+
) from toml_error
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
config = Config.model_validate(data)
|
|
240
|
+
except ValidationError as e:
|
|
241
|
+
raise ConfigError(f"Invalid configuration text: {e}") from e
|
|
242
|
+
config.is_from_default_location = False
|
|
243
|
+
return config
|
|
144
244
|
|
|
145
245
|
|
|
146
246
|
def save_config(config: Config, config_file: Path | None = None):
|
|
@@ -153,5 +253,41 @@ def save_config(config: Config, config_file: Path | None = None):
|
|
|
153
253
|
"""
|
|
154
254
|
config_file = config_file or get_config_file()
|
|
155
255
|
logger.debug("Saving config to file: {file}", file=config_file)
|
|
256
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
config_data = config.model_dump(mode="json", exclude_none=True)
|
|
156
258
|
with open(config_file, "w", encoding="utf-8") as f:
|
|
157
|
-
|
|
259
|
+
if config_file.suffix.lower() == ".json":
|
|
260
|
+
f.write(json.dumps(config_data, ensure_ascii=False, indent=2))
|
|
261
|
+
else:
|
|
262
|
+
f.write(tomlkit.dumps(config_data)) # type: ignore[reportUnknownMemberType]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _migrate_json_config_to_toml() -> None:
|
|
266
|
+
old_json_config_file = get_share_dir() / "config.json"
|
|
267
|
+
new_toml_config_file = get_share_dir() / "config.toml"
|
|
268
|
+
|
|
269
|
+
if not old_json_config_file.exists():
|
|
270
|
+
return
|
|
271
|
+
if new_toml_config_file.exists():
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
logger.info(
|
|
275
|
+
"Migrating legacy config file from {old} to {new}",
|
|
276
|
+
old=old_json_config_file,
|
|
277
|
+
new=new_toml_config_file,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
with open(old_json_config_file, encoding="utf-8") as f:
|
|
282
|
+
data = json.load(f)
|
|
283
|
+
config = Config.model_validate(data)
|
|
284
|
+
except json.JSONDecodeError as e:
|
|
285
|
+
raise ConfigError(f"Invalid JSON in legacy configuration file: {e}") from e
|
|
286
|
+
except ValidationError as e:
|
|
287
|
+
raise ConfigError(f"Invalid legacy configuration file: {e}") from e
|
|
288
|
+
|
|
289
|
+
# Write new TOML config, then keep a backup of the original JSON file.
|
|
290
|
+
save_config(config, new_toml_config_file)
|
|
291
|
+
backup_path = old_json_config_file.with_name("config.json.bak")
|
|
292
|
+
old_json_config_file.replace(backup_path)
|
|
293
|
+
logger.info("Legacy config backed up to {file}", file=backup_path)
|
kimi_cli/constant.py
CHANGED
kimi_cli/exception.py
CHANGED
|
@@ -1,16 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
1
4
|
class KimiCLIException(Exception):
|
|
2
5
|
"""Base exception class for Kimi CLI."""
|
|
3
6
|
|
|
4
7
|
pass
|
|
5
8
|
|
|
6
9
|
|
|
7
|
-
class ConfigError(KimiCLIException):
|
|
10
|
+
class ConfigError(KimiCLIException, ValueError):
|
|
8
11
|
"""Configuration error."""
|
|
9
12
|
|
|
10
13
|
pass
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
class AgentSpecError(KimiCLIException):
|
|
16
|
+
class AgentSpecError(KimiCLIException, ValueError):
|
|
14
17
|
"""Agent specification error."""
|
|
15
18
|
|
|
16
19
|
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidToolError(KimiCLIException, ValueError):
|
|
23
|
+
"""Invalid tool error."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MCPConfigError(KimiCLIException, ValueError):
|
|
29
|
+
"""MCP config error."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MCPRuntimeError(KimiCLIException, RuntimeError):
|
|
35
|
+
"""MCP runtime error."""
|
|
36
|
+
|
|
37
|
+
pass
|