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.

Files changed (137) hide show
  1. kimi_cli/CHANGELOG.md +349 -40
  2. kimi_cli/__init__.py +6 -0
  3. kimi_cli/acp/AGENTS.md +91 -0
  4. kimi_cli/acp/__init__.py +13 -0
  5. kimi_cli/acp/convert.py +111 -0
  6. kimi_cli/acp/kaos.py +270 -0
  7. kimi_cli/acp/mcp.py +46 -0
  8. kimi_cli/acp/server.py +335 -0
  9. kimi_cli/acp/session.py +445 -0
  10. kimi_cli/acp/tools.py +158 -0
  11. kimi_cli/acp/types.py +13 -0
  12. kimi_cli/agents/default/agent.yaml +4 -4
  13. kimi_cli/agents/default/sub.yaml +2 -1
  14. kimi_cli/agents/default/system.md +79 -21
  15. kimi_cli/agents/okabe/agent.yaml +17 -0
  16. kimi_cli/agentspec.py +53 -25
  17. kimi_cli/app.py +180 -52
  18. kimi_cli/cli/__init__.py +595 -0
  19. kimi_cli/cli/__main__.py +8 -0
  20. kimi_cli/cli/info.py +63 -0
  21. kimi_cli/cli/mcp.py +349 -0
  22. kimi_cli/config.py +153 -17
  23. kimi_cli/constant.py +3 -0
  24. kimi_cli/exception.py +23 -2
  25. kimi_cli/flow/__init__.py +117 -0
  26. kimi_cli/flow/d2.py +376 -0
  27. kimi_cli/flow/mermaid.py +218 -0
  28. kimi_cli/llm.py +129 -23
  29. kimi_cli/metadata.py +32 -7
  30. kimi_cli/platforms.py +262 -0
  31. kimi_cli/prompts/__init__.py +2 -0
  32. kimi_cli/prompts/compact.md +4 -5
  33. kimi_cli/session.py +223 -31
  34. kimi_cli/share.py +2 -0
  35. kimi_cli/skill.py +145 -0
  36. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  37. kimi_cli/skills/skill-creator/SKILL.md +351 -0
  38. kimi_cli/soul/__init__.py +51 -20
  39. kimi_cli/soul/agent.py +213 -85
  40. kimi_cli/soul/approval.py +86 -17
  41. kimi_cli/soul/compaction.py +64 -53
  42. kimi_cli/soul/context.py +38 -5
  43. kimi_cli/soul/denwarenji.py +2 -0
  44. kimi_cli/soul/kimisoul.py +442 -60
  45. kimi_cli/soul/message.py +54 -54
  46. kimi_cli/soul/slash.py +72 -0
  47. kimi_cli/soul/toolset.py +387 -6
  48. kimi_cli/toad.py +74 -0
  49. kimi_cli/tools/AGENTS.md +5 -0
  50. kimi_cli/tools/__init__.py +42 -34
  51. kimi_cli/tools/display.py +25 -0
  52. kimi_cli/tools/dmail/__init__.py +10 -10
  53. kimi_cli/tools/dmail/dmail.md +11 -9
  54. kimi_cli/tools/file/__init__.py +1 -3
  55. kimi_cli/tools/file/glob.py +20 -23
  56. kimi_cli/tools/file/grep.md +1 -1
  57. kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
  58. kimi_cli/tools/file/read.md +24 -6
  59. kimi_cli/tools/file/read.py +134 -50
  60. kimi_cli/tools/file/replace.md +1 -1
  61. kimi_cli/tools/file/replace.py +36 -29
  62. kimi_cli/tools/file/utils.py +282 -0
  63. kimi_cli/tools/file/write.py +43 -22
  64. kimi_cli/tools/multiagent/__init__.py +7 -0
  65. kimi_cli/tools/multiagent/create.md +11 -0
  66. kimi_cli/tools/multiagent/create.py +50 -0
  67. kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
  68. kimi_cli/tools/shell/__init__.py +120 -0
  69. kimi_cli/tools/{bash → shell}/bash.md +1 -2
  70. kimi_cli/tools/shell/powershell.md +25 -0
  71. kimi_cli/tools/test.py +4 -4
  72. kimi_cli/tools/think/__init__.py +2 -2
  73. kimi_cli/tools/todo/__init__.py +14 -8
  74. kimi_cli/tools/utils.py +64 -24
  75. kimi_cli/tools/web/fetch.py +68 -13
  76. kimi_cli/tools/web/search.py +10 -12
  77. kimi_cli/ui/acp/__init__.py +65 -412
  78. kimi_cli/ui/print/__init__.py +37 -49
  79. kimi_cli/ui/print/visualize.py +179 -0
  80. kimi_cli/ui/shell/__init__.py +141 -84
  81. kimi_cli/ui/shell/console.py +2 -0
  82. kimi_cli/ui/shell/debug.py +28 -23
  83. kimi_cli/ui/shell/keyboard.py +5 -1
  84. kimi_cli/ui/shell/prompt.py +220 -194
  85. kimi_cli/ui/shell/replay.py +111 -46
  86. kimi_cli/ui/shell/setup.py +89 -82
  87. kimi_cli/ui/shell/slash.py +422 -0
  88. kimi_cli/ui/shell/update.py +4 -2
  89. kimi_cli/ui/shell/usage.py +271 -0
  90. kimi_cli/ui/shell/visualize.py +574 -72
  91. kimi_cli/ui/wire/__init__.py +267 -0
  92. kimi_cli/ui/wire/jsonrpc.py +142 -0
  93. kimi_cli/ui/wire/protocol.py +1 -0
  94. kimi_cli/utils/__init__.py +0 -0
  95. kimi_cli/utils/aiohttp.py +2 -0
  96. kimi_cli/utils/aioqueue.py +72 -0
  97. kimi_cli/utils/broadcast.py +37 -0
  98. kimi_cli/utils/changelog.py +12 -7
  99. kimi_cli/utils/clipboard.py +12 -0
  100. kimi_cli/utils/datetime.py +37 -0
  101. kimi_cli/utils/environment.py +58 -0
  102. kimi_cli/utils/envvar.py +12 -0
  103. kimi_cli/utils/frontmatter.py +44 -0
  104. kimi_cli/utils/logging.py +7 -6
  105. kimi_cli/utils/message.py +9 -14
  106. kimi_cli/utils/path.py +99 -9
  107. kimi_cli/utils/pyinstaller.py +6 -0
  108. kimi_cli/utils/rich/__init__.py +33 -0
  109. kimi_cli/utils/rich/columns.py +99 -0
  110. kimi_cli/utils/rich/markdown.py +961 -0
  111. kimi_cli/utils/rich/markdown_sample.md +108 -0
  112. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  113. kimi_cli/utils/signals.py +2 -0
  114. kimi_cli/utils/slashcmd.py +124 -0
  115. kimi_cli/utils/string.py +2 -0
  116. kimi_cli/utils/term.py +168 -0
  117. kimi_cli/utils/typing.py +20 -0
  118. kimi_cli/wire/__init__.py +98 -29
  119. kimi_cli/wire/serde.py +45 -0
  120. kimi_cli/wire/types.py +299 -0
  121. kimi_cli-0.78.dist-info/METADATA +200 -0
  122. kimi_cli-0.78.dist-info/RECORD +135 -0
  123. kimi_cli-0.78.dist-info/entry_points.txt +4 -0
  124. kimi_cli/cli.py +0 -250
  125. kimi_cli/soul/runtime.py +0 -96
  126. kimi_cli/tools/bash/__init__.py +0 -99
  127. kimi_cli/tools/file/patch.md +0 -8
  128. kimi_cli/tools/file/patch.py +0 -143
  129. kimi_cli/tools/mcp.py +0 -85
  130. kimi_cli/ui/shell/liveview.py +0 -386
  131. kimi_cli/ui/shell/metacmd.py +0 -262
  132. kimi_cli/wire/message.py +0 -91
  133. kimi_cli-0.44.dist-info/METADATA +0 -188
  134. kimi_cli-0.44.dist-info/RECORD +0 -89
  135. kimi_cli-0.44.dist-info/entry_points.txt +0 -3
  136. /kimi_cli/tools/{task → multiagent}/task.md +0 -0
  137. {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 Literal, Self
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: Literal["kimi", "openai_legacy", "_chaos"]
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[LLMModelCapability] | None = None
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
- max_steps_per_run: int = 100
49
- """Maximum number of steps in one run"""
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.json"
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
- config_file = config_file or get_config_file()
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
- with open(config_file, "w", encoding="utf-8") as f:
133
- f.write(config.model_dump_json(indent=2, exclude_none=True))
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
- with open(config_file, encoding="utf-8") as f:
138
- data = json.load(f)
139
- return Config(**data)
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
- f.write(config.model_dump_json(indent=2, exclude_none=True))
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
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  import importlib.metadata
2
4
 
5
+ NAME = "Kimi CLI"
3
6
  VERSION = importlib.metadata.version("kimi-cli")
4
7
  USER_AGENT = f"KimiCLI/{VERSION}"
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