kimi-cli 0.35__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 +304 -0
- kimi_cli/__init__.py +374 -0
- kimi_cli/agent.py +261 -0
- kimi_cli/agents/koder/README.md +3 -0
- kimi_cli/agents/koder/agent.yaml +24 -0
- kimi_cli/agents/koder/sub.yaml +11 -0
- kimi_cli/agents/koder/system.md +72 -0
- kimi_cli/config.py +138 -0
- kimi_cli/llm.py +8 -0
- kimi_cli/metadata.py +117 -0
- kimi_cli/prompts/metacmds/__init__.py +4 -0
- kimi_cli/prompts/metacmds/compact.md +74 -0
- kimi_cli/prompts/metacmds/init.md +21 -0
- kimi_cli/py.typed +0 -0
- kimi_cli/share.py +8 -0
- kimi_cli/soul/__init__.py +59 -0
- kimi_cli/soul/approval.py +69 -0
- kimi_cli/soul/context.py +142 -0
- kimi_cli/soul/denwarenji.py +37 -0
- kimi_cli/soul/kimisoul.py +248 -0
- kimi_cli/soul/message.py +76 -0
- kimi_cli/soul/toolset.py +25 -0
- kimi_cli/soul/wire.py +101 -0
- kimi_cli/tools/__init__.py +85 -0
- kimi_cli/tools/bash/__init__.py +97 -0
- kimi_cli/tools/bash/bash.md +31 -0
- kimi_cli/tools/dmail/__init__.py +38 -0
- kimi_cli/tools/dmail/dmail.md +15 -0
- kimi_cli/tools/file/__init__.py +21 -0
- kimi_cli/tools/file/glob.md +17 -0
- kimi_cli/tools/file/glob.py +149 -0
- kimi_cli/tools/file/grep.md +5 -0
- kimi_cli/tools/file/grep.py +285 -0
- kimi_cli/tools/file/patch.md +8 -0
- kimi_cli/tools/file/patch.py +131 -0
- kimi_cli/tools/file/read.md +14 -0
- kimi_cli/tools/file/read.py +139 -0
- kimi_cli/tools/file/replace.md +7 -0
- kimi_cli/tools/file/replace.py +132 -0
- kimi_cli/tools/file/write.md +5 -0
- kimi_cli/tools/file/write.py +107 -0
- kimi_cli/tools/mcp.py +85 -0
- kimi_cli/tools/task/__init__.py +156 -0
- kimi_cli/tools/task/task.md +26 -0
- kimi_cli/tools/test.py +55 -0
- kimi_cli/tools/think/__init__.py +21 -0
- kimi_cli/tools/think/think.md +1 -0
- kimi_cli/tools/todo/__init__.py +27 -0
- kimi_cli/tools/todo/set_todo_list.md +15 -0
- kimi_cli/tools/utils.py +150 -0
- kimi_cli/tools/web/__init__.py +4 -0
- kimi_cli/tools/web/fetch.md +1 -0
- kimi_cli/tools/web/fetch.py +94 -0
- kimi_cli/tools/web/search.md +1 -0
- kimi_cli/tools/web/search.py +126 -0
- kimi_cli/ui/__init__.py +68 -0
- kimi_cli/ui/acp/__init__.py +441 -0
- kimi_cli/ui/print/__init__.py +176 -0
- kimi_cli/ui/shell/__init__.py +326 -0
- kimi_cli/ui/shell/console.py +3 -0
- kimi_cli/ui/shell/liveview.py +158 -0
- kimi_cli/ui/shell/metacmd.py +309 -0
- kimi_cli/ui/shell/prompt.py +574 -0
- kimi_cli/ui/shell/setup.py +192 -0
- kimi_cli/ui/shell/update.py +204 -0
- kimi_cli/utils/changelog.py +101 -0
- kimi_cli/utils/logging.py +18 -0
- kimi_cli/utils/message.py +8 -0
- kimi_cli/utils/path.py +23 -0
- kimi_cli/utils/provider.py +64 -0
- kimi_cli/utils/pyinstaller.py +24 -0
- kimi_cli/utils/string.py +12 -0
- kimi_cli-0.35.dist-info/METADATA +24 -0
- kimi_cli-0.35.dist-info/RECORD +76 -0
- kimi_cli-0.35.dist-info/WHEEL +4 -0
- kimi_cli-0.35.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
from prompt_toolkit import PromptSession
|
|
6
|
+
from prompt_toolkit.shortcuts.choice_input import ChoiceInput
|
|
7
|
+
from pydantic import SecretStr
|
|
8
|
+
|
|
9
|
+
from kimi_cli.config import LLMModel, LLMProvider, MoonshotSearchConfig, load_config, save_config
|
|
10
|
+
from kimi_cli.soul.kimisoul import KimiSoul
|
|
11
|
+
from kimi_cli.ui.shell.console import console
|
|
12
|
+
from kimi_cli.ui.shell.metacmd import meta_command
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from kimi_cli.ui.shell import ShellApp
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Platform(NamedTuple):
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
base_url: str
|
|
22
|
+
search_url: str | None = None
|
|
23
|
+
allowed_models: list[str] | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_PLATFORMS = [
|
|
27
|
+
_Platform(
|
|
28
|
+
id="kimi-coding",
|
|
29
|
+
name="Kimi Coding Plan",
|
|
30
|
+
base_url="https://api.kimi.com/coding/v1",
|
|
31
|
+
search_url="https://api.kimi.com/coding/v1/search",
|
|
32
|
+
),
|
|
33
|
+
_Platform(
|
|
34
|
+
id="moonshot-cn",
|
|
35
|
+
name="Moonshot AI 开放平台",
|
|
36
|
+
base_url="https://api.moonshot.cn/v1",
|
|
37
|
+
allowed_models=["kimi-k2-turbo-preview", "kimi-k2-0905-preview", "kimi-k2-0711-preview"],
|
|
38
|
+
),
|
|
39
|
+
_Platform(
|
|
40
|
+
id="moonshot-ai",
|
|
41
|
+
name="Moonshot AI Open Platform",
|
|
42
|
+
base_url="https://api.moonshot.ai/v1",
|
|
43
|
+
allowed_models=["kimi-k2-turbo-preview", "kimi-k2-0905-preview", "kimi-k2-0711-preview"],
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@meta_command(kimi_soul_only=True)
|
|
49
|
+
async def setup(app: "ShellApp", args: list[str]):
|
|
50
|
+
"""Setup Kimi CLI"""
|
|
51
|
+
assert isinstance(app.soul, KimiSoul)
|
|
52
|
+
|
|
53
|
+
result = await _setup()
|
|
54
|
+
if not result:
|
|
55
|
+
# error message already printed
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
config = load_config()
|
|
59
|
+
config.providers[result.platform.id] = LLMProvider(
|
|
60
|
+
type="kimi",
|
|
61
|
+
base_url=result.platform.base_url,
|
|
62
|
+
api_key=result.api_key,
|
|
63
|
+
)
|
|
64
|
+
config.models[result.model_id] = LLMModel(
|
|
65
|
+
provider=result.platform.id,
|
|
66
|
+
model=result.model_id,
|
|
67
|
+
max_context_size=result.max_context_size,
|
|
68
|
+
)
|
|
69
|
+
config.default_model = result.model_id
|
|
70
|
+
|
|
71
|
+
if result.platform.search_url:
|
|
72
|
+
config.services.moonshot_search = MoonshotSearchConfig(
|
|
73
|
+
base_url=result.platform.search_url,
|
|
74
|
+
api_key=result.api_key,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
save_config(config)
|
|
78
|
+
console.print("[green]✓[/green] Kimi CLI has been setup! Reloading...")
|
|
79
|
+
await asyncio.sleep(1)
|
|
80
|
+
console.clear()
|
|
81
|
+
|
|
82
|
+
from kimi_cli import Reload
|
|
83
|
+
|
|
84
|
+
raise Reload
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _SetupResult(NamedTuple):
|
|
88
|
+
platform: _Platform
|
|
89
|
+
api_key: SecretStr
|
|
90
|
+
model_id: str
|
|
91
|
+
max_context_size: int
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _setup() -> _SetupResult | None:
|
|
95
|
+
# select the API platform
|
|
96
|
+
platform_name = await _prompt_choice(
|
|
97
|
+
header="Select the API platform",
|
|
98
|
+
choices=[platform.name for platform in _PLATFORMS],
|
|
99
|
+
)
|
|
100
|
+
if not platform_name:
|
|
101
|
+
console.print("[red]No platform selected[/red]")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
platform = next(platform for platform in _PLATFORMS if platform.name == platform_name)
|
|
105
|
+
|
|
106
|
+
# enter the API key
|
|
107
|
+
api_key = await _prompt_text("Enter your API key", is_password=True)
|
|
108
|
+
if not api_key:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# list models
|
|
112
|
+
models_url = f"{platform.base_url}/models"
|
|
113
|
+
try:
|
|
114
|
+
async with (
|
|
115
|
+
aiohttp.ClientSession() as session,
|
|
116
|
+
session.get(
|
|
117
|
+
models_url,
|
|
118
|
+
headers={
|
|
119
|
+
"Authorization": f"Bearer {api_key}",
|
|
120
|
+
},
|
|
121
|
+
raise_for_status=True,
|
|
122
|
+
) as response,
|
|
123
|
+
):
|
|
124
|
+
resp_json = await response.json()
|
|
125
|
+
except aiohttp.ClientError as e:
|
|
126
|
+
console.print(f"[red]Failed to get models: {e}[/red]")
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
model_dict = {model["id"]: model for model in resp_json["data"]}
|
|
130
|
+
|
|
131
|
+
# select the model
|
|
132
|
+
if platform.allowed_models is None:
|
|
133
|
+
model_ids = [model["id"] for model in resp_json["data"]]
|
|
134
|
+
else:
|
|
135
|
+
id_set = set(model["id"] for model in resp_json["data"])
|
|
136
|
+
model_ids = [model_id for model_id in platform.allowed_models if model_id in id_set]
|
|
137
|
+
|
|
138
|
+
if not model_ids:
|
|
139
|
+
console.print("[red]No models available for the selected platform[/red]")
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
model_id = await _prompt_choice(
|
|
143
|
+
header="Select the model",
|
|
144
|
+
choices=model_ids,
|
|
145
|
+
)
|
|
146
|
+
if not model_id:
|
|
147
|
+
console.print("[red]No model selected[/red]")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
model = model_dict[model_id]
|
|
151
|
+
|
|
152
|
+
return _SetupResult(
|
|
153
|
+
platform=platform,
|
|
154
|
+
api_key=SecretStr(api_key),
|
|
155
|
+
model_id=model_id,
|
|
156
|
+
max_context_size=model["context_length"],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _prompt_choice(*, header: str, choices: list[str]) -> str | None:
|
|
161
|
+
if not choices:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
return await ChoiceInput(
|
|
166
|
+
message=header,
|
|
167
|
+
options=[(choice, choice) for choice in choices],
|
|
168
|
+
default=choices[0],
|
|
169
|
+
).prompt_async()
|
|
170
|
+
except (EOFError, KeyboardInterrupt):
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _prompt_text(prompt: str, *, is_password: bool = False) -> str | None:
|
|
175
|
+
session = PromptSession()
|
|
176
|
+
try:
|
|
177
|
+
return str(
|
|
178
|
+
await session.prompt_async(
|
|
179
|
+
f" {prompt}: ",
|
|
180
|
+
is_password=is_password,
|
|
181
|
+
)
|
|
182
|
+
).strip()
|
|
183
|
+
except (EOFError, KeyboardInterrupt):
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@meta_command
|
|
188
|
+
def reload(app: "ShellApp", args: list[str]):
|
|
189
|
+
"""Reload configuration"""
|
|
190
|
+
from kimi_cli import Reload
|
|
191
|
+
|
|
192
|
+
raise Reload
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
from enum import Enum, auto
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
from kimi_cli.ui.shell.console import console
|
|
15
|
+
from kimi_cli.utils.logging import logger
|
|
16
|
+
|
|
17
|
+
BASE_URL = "https://cdn.kimi.com/binaries/kimi-cli"
|
|
18
|
+
LATEST_VERSION_URL = f"{BASE_URL}/latest"
|
|
19
|
+
INSTALL_DIR = Path.home() / ".local" / "bin"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UpdateResult(Enum):
|
|
23
|
+
UPDATE_AVAILABLE = auto()
|
|
24
|
+
UPDATED = auto()
|
|
25
|
+
UP_TO_DATE = auto()
|
|
26
|
+
FAILED = auto()
|
|
27
|
+
UNSUPPORTED = auto()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_UPDATE_LOCK = asyncio.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _semver_tuple(version: str) -> tuple[int, int, int]:
|
|
34
|
+
v = version.strip()
|
|
35
|
+
if v.startswith("v"):
|
|
36
|
+
v = v[1:]
|
|
37
|
+
match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?", v)
|
|
38
|
+
if not match:
|
|
39
|
+
return (0, 0, 0)
|
|
40
|
+
major = int(match.group(1))
|
|
41
|
+
minor = int(match.group(2))
|
|
42
|
+
patch = int(match.group(3) or 0)
|
|
43
|
+
return (major, minor, patch)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _detect_target() -> str | None:
|
|
47
|
+
sys_name = platform.system()
|
|
48
|
+
mach = platform.machine()
|
|
49
|
+
if mach in ("x86_64", "amd64", "AMD64"):
|
|
50
|
+
arch = "x86_64"
|
|
51
|
+
elif mach in ("arm64", "aarch64"):
|
|
52
|
+
arch = "aarch64"
|
|
53
|
+
else:
|
|
54
|
+
logger.error("Unsupported architecture: {mach}", mach=mach)
|
|
55
|
+
return None
|
|
56
|
+
if sys_name == "Darwin":
|
|
57
|
+
os_name = "apple-darwin"
|
|
58
|
+
elif sys_name == "Linux":
|
|
59
|
+
os_name = "unknown-linux-gnu"
|
|
60
|
+
else:
|
|
61
|
+
logger.error("Unsupported OS: {sys_name}", sys_name=sys_name)
|
|
62
|
+
return None
|
|
63
|
+
return f"{arch}-{os_name}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _get_latest_version(session: aiohttp.ClientSession) -> str | None:
|
|
67
|
+
try:
|
|
68
|
+
async with session.get(LATEST_VERSION_URL) as resp:
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
data = await resp.text()
|
|
71
|
+
return data.strip()
|
|
72
|
+
except aiohttp.ClientError:
|
|
73
|
+
logger.exception("Failed to get latest version:")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def do_update(*, print: bool = True, check_only: bool = False) -> UpdateResult:
|
|
78
|
+
async with _UPDATE_LOCK:
|
|
79
|
+
return await _do_update(print=print, check_only=check_only)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _do_update(*, print: bool, check_only: bool) -> UpdateResult:
|
|
83
|
+
from kimi_cli import __version__ as current_version
|
|
84
|
+
|
|
85
|
+
def _print(message: str) -> None:
|
|
86
|
+
if print:
|
|
87
|
+
console.print(message)
|
|
88
|
+
|
|
89
|
+
target = _detect_target()
|
|
90
|
+
if not target:
|
|
91
|
+
_print("[red]Failed to detect target platform.[/red]")
|
|
92
|
+
return UpdateResult.UNSUPPORTED
|
|
93
|
+
|
|
94
|
+
async with aiohttp.ClientSession() as session:
|
|
95
|
+
logger.info("Checking for updates...")
|
|
96
|
+
_print("Checking for updates...")
|
|
97
|
+
latest_version = await _get_latest_version(session)
|
|
98
|
+
if not latest_version:
|
|
99
|
+
_print("[red]Failed to check for updates.[/red]")
|
|
100
|
+
return UpdateResult.FAILED
|
|
101
|
+
|
|
102
|
+
logger.debug("Latest version: {latest_version}", latest_version=latest_version)
|
|
103
|
+
|
|
104
|
+
cur_t = _semver_tuple(current_version)
|
|
105
|
+
lat_t = _semver_tuple(latest_version)
|
|
106
|
+
|
|
107
|
+
if cur_t >= lat_t:
|
|
108
|
+
logger.debug("Already up to date: {current_version}", current_version=current_version)
|
|
109
|
+
_print("[green]Already up to date.[/green]")
|
|
110
|
+
return UpdateResult.UP_TO_DATE
|
|
111
|
+
|
|
112
|
+
if check_only:
|
|
113
|
+
logger.info(
|
|
114
|
+
"Update available: current={current_version}, latest={latest_version}",
|
|
115
|
+
current_version=current_version,
|
|
116
|
+
latest_version=latest_version,
|
|
117
|
+
)
|
|
118
|
+
_print(f"[yellow]Update available: {latest_version}[/yellow]")
|
|
119
|
+
return UpdateResult.UPDATE_AVAILABLE
|
|
120
|
+
|
|
121
|
+
logger.info(
|
|
122
|
+
"Updating from {current_version} to {latest_version}...",
|
|
123
|
+
current_version=current_version,
|
|
124
|
+
latest_version=latest_version,
|
|
125
|
+
)
|
|
126
|
+
_print(f"Updating from {current_version} to {latest_version}...")
|
|
127
|
+
|
|
128
|
+
filename = f"kimi-{latest_version}-{target}.tar.gz"
|
|
129
|
+
download_url = f"{BASE_URL}/{latest_version}/{filename}"
|
|
130
|
+
|
|
131
|
+
with tempfile.TemporaryDirectory(prefix="kimi-cli-") as tmpdir:
|
|
132
|
+
tar_path = os.path.join(tmpdir, filename)
|
|
133
|
+
|
|
134
|
+
logger.info("Downloading from {download_url}...", download_url=download_url)
|
|
135
|
+
_print("[grey50]Downloading...[/grey50]")
|
|
136
|
+
try:
|
|
137
|
+
async with session.get(download_url) as resp:
|
|
138
|
+
resp.raise_for_status()
|
|
139
|
+
with open(tar_path, "wb") as f:
|
|
140
|
+
async for chunk in resp.content.iter_chunked(1024 * 64):
|
|
141
|
+
if chunk:
|
|
142
|
+
f.write(chunk)
|
|
143
|
+
except aiohttp.ClientError:
|
|
144
|
+
logger.exception(
|
|
145
|
+
"Failed to download update from {download_url}",
|
|
146
|
+
download_url=download_url,
|
|
147
|
+
)
|
|
148
|
+
_print("[red]Failed to download.[/red]")
|
|
149
|
+
return UpdateResult.FAILED
|
|
150
|
+
except Exception:
|
|
151
|
+
logger.exception("Failed to download:")
|
|
152
|
+
_print("[red]Failed to download.[/red]")
|
|
153
|
+
return UpdateResult.FAILED
|
|
154
|
+
|
|
155
|
+
logger.info("Extracting archive {tar_path}...", tar_path=tar_path)
|
|
156
|
+
_print("[grey50]Extracting...[/grey50]")
|
|
157
|
+
try:
|
|
158
|
+
with tarfile.open(tar_path, "r:gz") as tar:
|
|
159
|
+
tar.extractall(tmpdir)
|
|
160
|
+
binary_path = None
|
|
161
|
+
for root, _, files in os.walk(tmpdir):
|
|
162
|
+
if "kimi" in files:
|
|
163
|
+
binary_path = os.path.join(root, "kimi")
|
|
164
|
+
break
|
|
165
|
+
if not binary_path:
|
|
166
|
+
logger.error("Binary 'kimi' not found in archive.")
|
|
167
|
+
_print("[red]Binary 'kimi' not found in archive.[/red]")
|
|
168
|
+
return UpdateResult.FAILED
|
|
169
|
+
except Exception:
|
|
170
|
+
logger.exception("Failed to extract archive:")
|
|
171
|
+
_print("[red]Failed to extract archive.[/red]")
|
|
172
|
+
return UpdateResult.FAILED
|
|
173
|
+
|
|
174
|
+
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
dest_path = INSTALL_DIR / "kimi"
|
|
176
|
+
logger.info("Installing to {dest_path}...", dest_path=dest_path)
|
|
177
|
+
_print("[grey50]Installing...[/grey50]")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
shutil.copy2(binary_path, dest_path)
|
|
181
|
+
os.chmod(
|
|
182
|
+
dest_path,
|
|
183
|
+
os.stat(dest_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
|
|
184
|
+
)
|
|
185
|
+
except Exception:
|
|
186
|
+
logger.exception("Failed to install:")
|
|
187
|
+
_print("[red]Failed to install.[/red]")
|
|
188
|
+
return UpdateResult.FAILED
|
|
189
|
+
|
|
190
|
+
_print("[green]Updated successfully![/green]")
|
|
191
|
+
_print("[yellow]Restart Kimi CLI to use the new version.[/yellow]")
|
|
192
|
+
return UpdateResult.UPDATED
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# @meta_command
|
|
196
|
+
# async def update(app: "ShellApp", args: list[str]):
|
|
197
|
+
# """Check for updates"""
|
|
198
|
+
# await do_update(print=True)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# @meta_command(name="check-update")
|
|
202
|
+
# async def check_update(app: "ShellApp", args: list[str]):
|
|
203
|
+
# """Check for updates"""
|
|
204
|
+
# await do_update(print=True, check_only=True)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ReleaseEntry(NamedTuple):
|
|
6
|
+
description: str
|
|
7
|
+
entries: list[str]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_changelog(md_text: str) -> dict[str, ReleaseEntry]:
|
|
11
|
+
"""Parse a subset of Keep a Changelog-style markdown into a map:
|
|
12
|
+
version -> (description, entries)
|
|
13
|
+
|
|
14
|
+
Parsing rules:
|
|
15
|
+
- Versions are denoted by level-2 headings starting with '## ['
|
|
16
|
+
Example: `## [v0.10.1] - 2025-09-18` or `## [Unreleased]`
|
|
17
|
+
- For each version section, description is the first contiguous block of
|
|
18
|
+
non-empty lines that do not start with '-' or '#'.
|
|
19
|
+
- Entries are all markdown list items starting with '- ' under that version
|
|
20
|
+
(across any subheadings like '### Added').
|
|
21
|
+
"""
|
|
22
|
+
lines = md_text.splitlines()
|
|
23
|
+
result: dict[str, ReleaseEntry] = {}
|
|
24
|
+
|
|
25
|
+
current_ver: str | None = None
|
|
26
|
+
collecting_desc = False
|
|
27
|
+
desc_lines: list[str] = []
|
|
28
|
+
bullet_lines: list[str] = []
|
|
29
|
+
seen_content_after_header = False
|
|
30
|
+
|
|
31
|
+
def commit():
|
|
32
|
+
nonlocal current_ver, desc_lines, bullet_lines, result
|
|
33
|
+
if current_ver is None:
|
|
34
|
+
return
|
|
35
|
+
description = "\n".join([line.strip() for line in desc_lines]).strip()
|
|
36
|
+
# Deduplicate and normalize entries
|
|
37
|
+
norm_entries = [
|
|
38
|
+
line.strip()[2:].strip() for line in bullet_lines if line.strip().startswith("- ")
|
|
39
|
+
]
|
|
40
|
+
result[current_ver] = ReleaseEntry(description=description, entries=norm_entries)
|
|
41
|
+
|
|
42
|
+
for raw in lines:
|
|
43
|
+
line = raw.rstrip()
|
|
44
|
+
if line.startswith("## ["):
|
|
45
|
+
# New version section, flush previous
|
|
46
|
+
commit()
|
|
47
|
+
# Extract version token inside brackets
|
|
48
|
+
end = line.find("]")
|
|
49
|
+
ver = line[4:end] if end != -1 else line[3:].strip()
|
|
50
|
+
current_ver = ver.strip()
|
|
51
|
+
desc_lines = []
|
|
52
|
+
bullet_lines = []
|
|
53
|
+
collecting_desc = True
|
|
54
|
+
seen_content_after_header = False
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if current_ver is None:
|
|
58
|
+
# Skip until first version section
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if not line.strip():
|
|
62
|
+
# blank line ends initial description block only after we've seen content
|
|
63
|
+
if collecting_desc and seen_content_after_header:
|
|
64
|
+
collecting_desc = False
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
seen_content_after_header = True
|
|
68
|
+
|
|
69
|
+
if line.lstrip().startswith("### "):
|
|
70
|
+
collecting_desc = False
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if line.lstrip().startswith("- "):
|
|
74
|
+
collecting_desc = False
|
|
75
|
+
bullet_lines.append(line.strip())
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if collecting_desc:
|
|
79
|
+
# Accumulate description until a blank line or bullets/subheadings
|
|
80
|
+
desc_lines.append(line.strip())
|
|
81
|
+
# else: ignore any other free-form text after description block
|
|
82
|
+
|
|
83
|
+
# Final flush
|
|
84
|
+
commit()
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_release_notes(changelog: dict[str, ReleaseEntry]) -> str:
|
|
89
|
+
parts: list[str] = []
|
|
90
|
+
for ver, entry in changelog.items():
|
|
91
|
+
s = f"[bold]{ver}[/bold]"
|
|
92
|
+
if entry.description:
|
|
93
|
+
s += f": {entry.description}"
|
|
94
|
+
if entry.entries:
|
|
95
|
+
for it in entry.entries:
|
|
96
|
+
s += "\n[markdown.item.bullet]• [/]" + it
|
|
97
|
+
parts.append(s + "\n")
|
|
98
|
+
return "\n".join(parts).strip()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
CHANGELOG = parse_changelog((Path(__file__).parent.parent / "CHANGELOG.md").read_text())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import IO
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
|
|
5
|
+
logger.remove()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StreamToLogger(IO[str]):
|
|
9
|
+
def __init__(self, level: str = "ERROR"):
|
|
10
|
+
self._level = level
|
|
11
|
+
|
|
12
|
+
def write(self, buffer: str) -> int:
|
|
13
|
+
for line in buffer.rstrip().splitlines():
|
|
14
|
+
logger.opt(depth=1).log(self._level, line.rstrip())
|
|
15
|
+
return len(buffer)
|
|
16
|
+
|
|
17
|
+
def flush(self) -> None:
|
|
18
|
+
pass
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from kosong.base.message import Message, TextPart
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def message_extract_text(message: Message) -> str:
|
|
5
|
+
"""Extract text from a message."""
|
|
6
|
+
if isinstance(message.content, str):
|
|
7
|
+
return message.content
|
|
8
|
+
return "\n".join(part.text for part in message.content if isinstance(part, TextPart))
|
kimi_cli/utils/path.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import aiofiles.os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def next_available_rotation(path: Path) -> Path | None:
|
|
8
|
+
"""
|
|
9
|
+
Find the next available rotation path for a given path.
|
|
10
|
+
"""
|
|
11
|
+
if not path.parent.exists():
|
|
12
|
+
return None
|
|
13
|
+
base_name = path.stem
|
|
14
|
+
suffix = path.suffix
|
|
15
|
+
pattern = re.compile(rf"^{re.escape(base_name)}_(\d+){re.escape(suffix)}$")
|
|
16
|
+
max_num = 0
|
|
17
|
+
# FIXME: protect from race condition
|
|
18
|
+
for p in await aiofiles.os.listdir(path.parent):
|
|
19
|
+
if m := pattern.match(p):
|
|
20
|
+
max_num = max(max_num, int(m.group(1)))
|
|
21
|
+
next_num = max_num + 1
|
|
22
|
+
next_path = path.parent / f"{base_name}_{next_num}{suffix}"
|
|
23
|
+
return next_path
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from kosong.chat_provider import ChaosChatProvider, Kimi, OpenAILegacy
|
|
4
|
+
from kosong.chat_provider.chaos import ChaosConfig
|
|
5
|
+
from pydantic import SecretStr
|
|
6
|
+
|
|
7
|
+
import kimi_cli
|
|
8
|
+
from kimi_cli.config import LLMModel, LLMProvider
|
|
9
|
+
from kimi_cli.llm import LLM
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def augment_provider_with_env_vars(provider: LLMProvider, model: LLMModel):
|
|
13
|
+
match provider.type:
|
|
14
|
+
case "kimi":
|
|
15
|
+
if base_url := os.getenv("KIMI_BASE_URL"):
|
|
16
|
+
provider.base_url = base_url
|
|
17
|
+
if api_key := os.getenv("KIMI_API_KEY"):
|
|
18
|
+
provider.api_key = SecretStr(api_key)
|
|
19
|
+
if model_name := os.getenv("KIMI_MODEL_NAME"):
|
|
20
|
+
model.model = model_name
|
|
21
|
+
if max_context_size := os.getenv("KIMI_MODEL_MAX_CONTEXT_SIZE"):
|
|
22
|
+
model.max_context_size = int(max_context_size)
|
|
23
|
+
case "openai_legacy":
|
|
24
|
+
if base_url := os.getenv("OPENAI_BASE_URL"):
|
|
25
|
+
provider.base_url = base_url
|
|
26
|
+
if api_key := os.getenv("OPENAI_API_KEY"):
|
|
27
|
+
provider.api_key = SecretStr(api_key)
|
|
28
|
+
case _:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_llm(provider: LLMProvider, model: LLMModel, stream: bool = True) -> LLM:
|
|
33
|
+
match provider.type:
|
|
34
|
+
case "kimi":
|
|
35
|
+
chat_provider = Kimi(
|
|
36
|
+
model=model.model,
|
|
37
|
+
base_url=provider.base_url,
|
|
38
|
+
api_key=provider.api_key.get_secret_value(),
|
|
39
|
+
stream=stream,
|
|
40
|
+
default_headers={
|
|
41
|
+
"User-Agent": kimi_cli.USER_AGENT,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
case "openai_legacy":
|
|
45
|
+
chat_provider = OpenAILegacy(
|
|
46
|
+
model=model.model,
|
|
47
|
+
base_url=provider.base_url,
|
|
48
|
+
api_key=provider.api_key.get_secret_value(),
|
|
49
|
+
stream=stream,
|
|
50
|
+
)
|
|
51
|
+
case "_chaos":
|
|
52
|
+
chat_provider = ChaosChatProvider(
|
|
53
|
+
model=model.model,
|
|
54
|
+
base_url=provider.base_url,
|
|
55
|
+
api_key=provider.api_key.get_secret_value(),
|
|
56
|
+
chaos_config=ChaosConfig(
|
|
57
|
+
error_probability=0.8,
|
|
58
|
+
error_types=[429, 500, 503],
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
case _:
|
|
62
|
+
raise ValueError(f"Unsupported provider: {provider.type}")
|
|
63
|
+
|
|
64
|
+
return LLM(chat_provider=chat_provider, max_context_size=model.max_context_size)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
|
|
2
|
+
|
|
3
|
+
hiddenimports = collect_submodules("kimi_cli.tools")
|
|
4
|
+
datas = (
|
|
5
|
+
collect_data_files(
|
|
6
|
+
"kimi_cli",
|
|
7
|
+
includes=[
|
|
8
|
+
"agents/**/*.yaml",
|
|
9
|
+
"agents/**/*.md",
|
|
10
|
+
"deps/bin/**",
|
|
11
|
+
"prompts/**/*.md",
|
|
12
|
+
"tools/**/*.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
],
|
|
15
|
+
)
|
|
16
|
+
+ collect_data_files(
|
|
17
|
+
"dateparser",
|
|
18
|
+
includes=["**/*.pkl"],
|
|
19
|
+
)
|
|
20
|
+
+ collect_data_files(
|
|
21
|
+
"fastmcp",
|
|
22
|
+
includes=["../fastmcp-*.dist-info/*"],
|
|
23
|
+
)
|
|
24
|
+
)
|
kimi_cli/utils/string.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
_NEWLINE_RE = re.compile(r"[\r\n]+")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def shorten_middle(text: str, width: int, remove_newline: bool = True) -> str:
|
|
7
|
+
"""Shorten the text by inserting ellipsis in the middle."""
|
|
8
|
+
if len(text) <= width:
|
|
9
|
+
return text
|
|
10
|
+
if remove_newline:
|
|
11
|
+
text = _NEWLINE_RE.sub(" ", text)
|
|
12
|
+
return text[: width // 2] + "..." + text[-width // 2 :]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: kimi-cli
|
|
3
|
+
Version: 0.35
|
|
4
|
+
Summary: Kimi CLI is your next CLI agent.
|
|
5
|
+
Requires-Dist: agent-client-protocol>=0.4.9
|
|
6
|
+
Requires-Dist: aiofiles>=25.1.0
|
|
7
|
+
Requires-Dist: aiohttp>=3.13.1
|
|
8
|
+
Requires-Dist: click>=8.3.0
|
|
9
|
+
Requires-Dist: kosong>=0.14.1
|
|
10
|
+
Requires-Dist: loguru>=0.7.3
|
|
11
|
+
Requires-Dist: patch-ng>=1.19.0
|
|
12
|
+
Requires-Dist: prompt-toolkit>=3.0.52
|
|
13
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
14
|
+
Requires-Dist: rich>=14.2.0
|
|
15
|
+
Requires-Dist: ripgrepy>=2.2.0
|
|
16
|
+
Requires-Dist: streamingjson>=0.0.5
|
|
17
|
+
Requires-Dist: trafilatura>=2.0.0
|
|
18
|
+
Requires-Dist: tenacity>=9.1.2
|
|
19
|
+
Requires-Dist: fastmcp>=2.12.5
|
|
20
|
+
Requires-Dist: pydantic>=2.12.3
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Kimi CLI
|