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
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+
6
+ import acp # type: ignore[reportMissingTypeStubs]
7
+ import pydantic
8
+ from kosong.chat_provider import ChatProviderError
9
+
10
+ from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul
11
+ from kimi_cli.soul.kimisoul import KimiSoul
12
+ from kimi_cli.utils.aioqueue import Queue, QueueShutDown
13
+ from kimi_cli.utils.logging import logger
14
+ from kimi_cli.wire import Wire
15
+ from kimi_cli.wire.types import ApprovalRequest, Request
16
+
17
+ from .jsonrpc import (
18
+ ErrorCodes,
19
+ JSONRPCApprovalRequestResult,
20
+ JSONRPCCancelMessage,
21
+ JSONRPCErrorObject,
22
+ JSONRPCErrorResponse,
23
+ JSONRPCEventMessage,
24
+ JSONRPCInMessage,
25
+ JSONRPCInMessageAdapter,
26
+ JSONRPCOutMessage,
27
+ JSONRPCPromptMessage,
28
+ JSONRPCRequestMessage,
29
+ JSONRPCSuccessResponse,
30
+ Statuses,
31
+ )
32
+
33
+ # Maximum buffer size for the asyncio StreamReader used for stdio.
34
+ # Passed as the `limit` argument to `acp.stdio_streams`, this caps how much
35
+ # data can be buffered when reading from stdin (e.g., large tool or model
36
+ # outputs sent over JSON-RPC). A 100MB limit is large enough for typical
37
+ # interactive use while still protecting the process from unbounded memory
38
+ # growth or buffer-overrun errors when peers send unexpectedly large payloads.
39
+ STDIO_BUFFER_LIMIT = 100 * 1024 * 1024
40
+
41
+
42
+ class WireOverStdio:
43
+ def __init__(self, soul: Soul):
44
+ self._reader: asyncio.StreamReader | None = None
45
+ self._writer: asyncio.StreamWriter | None = None
46
+
47
+ # outward
48
+ self._write_task: asyncio.Task[None] | None = None
49
+ self._write_queue: Queue[JSONRPCOutMessage] = Queue()
50
+
51
+ # inward
52
+ self._dispatch_tasks: set[asyncio.Task[None]] = set()
53
+
54
+ # soul running stuffs
55
+ self._soul = soul
56
+ self._cancel_event: asyncio.Event | None = None
57
+ self._pending_requests: dict[str, Request] = {}
58
+ """Maps JSON RPC message IDs to pending `Request`s."""
59
+
60
+ async def serve(self) -> None:
61
+ logger.info("Starting Wire server on stdio")
62
+
63
+ self._reader, self._writer = await acp.stdio_streams(limit=STDIO_BUFFER_LIMIT)
64
+ self._write_task = asyncio.create_task(self._write_loop())
65
+ try:
66
+ await self._read_loop()
67
+ finally:
68
+ await self._shutdown()
69
+
70
+ async def _write_loop(self) -> None:
71
+ assert self._writer is not None
72
+
73
+ try:
74
+ while True:
75
+ try:
76
+ msg = await self._write_queue.get()
77
+ except QueueShutDown:
78
+ logger.debug("Send queue shut down, stopping Wire server write loop")
79
+ break
80
+ self._writer.write(msg.model_dump_json().encode("utf-8") + b"\n")
81
+ await self._writer.drain()
82
+ except asyncio.CancelledError:
83
+ raise
84
+ except Exception:
85
+ logger.exception("Wire server write loop error:")
86
+ raise
87
+
88
+ async def _read_loop(self) -> None:
89
+ assert self._reader is not None
90
+
91
+ while True:
92
+ line = await self._reader.readline()
93
+ if not line:
94
+ logger.info("stdin closed, Wire server exiting")
95
+ break
96
+
97
+ try:
98
+ msg = JSONRPCInMessageAdapter.validate_json(line)
99
+ except ValueError:
100
+ logger.error("Invalid JSONRPC line: {line}", line=line)
101
+ continue
102
+
103
+ task = asyncio.create_task(self._dispatch_msg(msg))
104
+ task.add_done_callback(self._dispatch_tasks.discard)
105
+ self._dispatch_tasks.add(task)
106
+
107
+ async def _shutdown(self) -> None:
108
+ for request in self._pending_requests.values():
109
+ if not request.resolved:
110
+ request.resolve("reject")
111
+ self._pending_requests.clear()
112
+
113
+ if self._cancel_event is not None:
114
+ self._cancel_event.set()
115
+ self._cancel_event = None
116
+
117
+ self._write_queue.shutdown()
118
+ if self._write_task is not None:
119
+ with contextlib.suppress(asyncio.CancelledError):
120
+ await self._write_task
121
+
122
+ await asyncio.gather(*self._dispatch_tasks, return_exceptions=True)
123
+ self._dispatch_tasks.clear()
124
+
125
+ if self._writer is not None:
126
+ self._writer.close()
127
+ with contextlib.suppress(Exception):
128
+ await self._writer.wait_closed()
129
+ self._writer = None
130
+
131
+ self._reader = None
132
+
133
+ async def _dispatch_msg(self, msg: JSONRPCInMessage) -> None:
134
+ resp: JSONRPCSuccessResponse | JSONRPCErrorResponse | None = None
135
+ try:
136
+ match msg:
137
+ case JSONRPCPromptMessage():
138
+ resp = await self._handle_prompt(msg)
139
+ case JSONRPCCancelMessage():
140
+ resp = await self._handle_cancel(msg)
141
+ case JSONRPCSuccessResponse() | JSONRPCErrorResponse():
142
+ await self._handle_response(msg)
143
+
144
+ if resp is not None:
145
+ await self._send_msg(resp)
146
+ except Exception:
147
+ logger.exception("Unexpected error dispatching JSONRPC message:")
148
+ raise
149
+
150
+ async def _send_msg(self, msg: JSONRPCOutMessage) -> None:
151
+ try:
152
+ await self._write_queue.put(msg)
153
+ except QueueShutDown:
154
+ logger.error("Send queue shut down; dropping message: {msg}", msg=msg)
155
+
156
+ @property
157
+ def _soul_is_running(self) -> bool:
158
+ return self._cancel_event is not None
159
+
160
+ async def _handle_prompt(
161
+ self, msg: JSONRPCPromptMessage
162
+ ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:
163
+ if self._soul_is_running:
164
+ # TODO: support queueing multiple inputs
165
+ return JSONRPCErrorResponse(
166
+ id=msg.id,
167
+ error=JSONRPCErrorObject(
168
+ code=ErrorCodes.INVALID_STATE, message="An agent turn is already in progress"
169
+ ),
170
+ )
171
+
172
+ self._cancel_event = asyncio.Event()
173
+ try:
174
+ await run_soul(
175
+ self._soul,
176
+ msg.params.user_input,
177
+ self._stream_wire_messages,
178
+ self._cancel_event,
179
+ self._soul.wire_file if isinstance(self._soul, KimiSoul) else None,
180
+ )
181
+ return JSONRPCSuccessResponse(
182
+ id=msg.id,
183
+ result={"status": Statuses.FINISHED},
184
+ )
185
+ except LLMNotSet:
186
+ return JSONRPCErrorResponse(
187
+ id=msg.id,
188
+ error=JSONRPCErrorObject(code=ErrorCodes.LLM_NOT_SET, message="LLM is not set"),
189
+ )
190
+ except LLMNotSupported as e:
191
+ return JSONRPCErrorResponse(
192
+ id=msg.id,
193
+ error=JSONRPCErrorObject(code=ErrorCodes.LLM_NOT_SUPPORTED, message=str(e)),
194
+ )
195
+ except ChatProviderError as e:
196
+ return JSONRPCErrorResponse(
197
+ id=msg.id,
198
+ error=JSONRPCErrorObject(code=ErrorCodes.CHAT_PROVIDER_ERROR, message=str(e)),
199
+ )
200
+ except MaxStepsReached as e:
201
+ return JSONRPCSuccessResponse(
202
+ id=msg.id,
203
+ result={"status": Statuses.MAX_STEPS_REACHED, "steps": e.n_steps},
204
+ )
205
+ except RunCancelled:
206
+ return JSONRPCSuccessResponse(
207
+ id=msg.id,
208
+ result={"status": Statuses.CANCELLED},
209
+ )
210
+ finally:
211
+ self._cancel_event = None
212
+
213
+ async def _handle_cancel(
214
+ self, msg: JSONRPCCancelMessage
215
+ ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:
216
+ if not self._soul_is_running:
217
+ return JSONRPCErrorResponse(
218
+ id=msg.id,
219
+ error=JSONRPCErrorObject(
220
+ code=ErrorCodes.INVALID_STATE, message="No agent turn is in progress"
221
+ ),
222
+ )
223
+
224
+ assert self._cancel_event is not None
225
+ self._cancel_event.set()
226
+ return JSONRPCSuccessResponse(
227
+ id=msg.id,
228
+ result={},
229
+ )
230
+
231
+ async def _handle_response(self, msg: JSONRPCSuccessResponse | JSONRPCErrorResponse) -> None:
232
+ request = self._pending_requests.pop(msg.id, None)
233
+ if request is None:
234
+ logger.error("No pending request for response id={id}", id=msg.id)
235
+ return
236
+
237
+ match request:
238
+ case ApprovalRequest():
239
+ if isinstance(msg, JSONRPCErrorResponse):
240
+ request.resolve("reject")
241
+ else:
242
+ try:
243
+ result = JSONRPCApprovalRequestResult.model_validate(msg.result)
244
+ request.resolve(result.response)
245
+ except pydantic.ValidationError as e:
246
+ logger.error(
247
+ "Invalid response result for request id={id}: {error}",
248
+ id=msg.id,
249
+ error=e,
250
+ )
251
+ request.resolve("reject")
252
+
253
+ async def _stream_wire_messages(self, wire: Wire) -> None:
254
+ wire_ui = wire.ui_side(merge=False)
255
+ while True:
256
+ msg = await wire_ui.receive()
257
+ match msg:
258
+ case ApprovalRequest():
259
+ await self._request_approval(msg)
260
+ case _:
261
+ await self._send_msg(JSONRPCEventMessage(method="event", params=msg))
262
+
263
+ async def _request_approval(self, request: ApprovalRequest) -> None:
264
+ msg_id = request.id # just use the approval request id as message id
265
+ self._pending_requests[msg_id] = request
266
+ await self._send_msg(JSONRPCRequestMessage(id=msg_id, params=request))
267
+ await request.wait()
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from kosong.utils.typing import JsonType
6
+ from pydantic import (
7
+ BaseModel,
8
+ ConfigDict,
9
+ TypeAdapter,
10
+ field_serializer,
11
+ field_validator,
12
+ model_serializer,
13
+ )
14
+
15
+ from kimi_cli.wire.serde import serialize_wire_message
16
+ from kimi_cli.wire.types import (
17
+ ApprovalRequestResolved,
18
+ ContentPart,
19
+ Event,
20
+ Request,
21
+ is_event,
22
+ is_request,
23
+ )
24
+
25
+
26
+ class _MessageBase(BaseModel):
27
+ jsonrpc: Literal["2.0"] = "2.0"
28
+
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+
32
+ class JSONRPCEventMessage(_MessageBase):
33
+ method: Literal["event"] = "event"
34
+ params: Event
35
+
36
+ @field_serializer("params")
37
+ def _serialize_params(self, params: Event) -> dict[str, JsonType]:
38
+ return serialize_wire_message(params)
39
+
40
+ @field_validator("params", mode="before")
41
+ @classmethod
42
+ def _validate_params(cls, value: Any) -> Event:
43
+ if is_event(value):
44
+ return value
45
+ raise NotImplementedError("Event message deserialization is not implemented.")
46
+
47
+
48
+ class JSONRPCRequestMessage(_MessageBase):
49
+ method: Literal["request"] = "request"
50
+ id: str
51
+ params: Request
52
+
53
+ @field_serializer("params")
54
+ def _serialize_params(self, params: Request) -> dict[str, JsonType]:
55
+ return serialize_wire_message(params)
56
+
57
+ @field_validator("params", mode="before")
58
+ @classmethod
59
+ def _validate_params(cls, value: Any) -> Request:
60
+ if is_request(value):
61
+ return value
62
+ raise NotImplementedError("Request message deserialization is not implemented.")
63
+
64
+
65
+ class JSONRPCPromptMessage(_MessageBase):
66
+ class Params(BaseModel):
67
+ user_input: str | list[ContentPart]
68
+
69
+ method: Literal["prompt"] = "prompt"
70
+ id: str
71
+ params: Params
72
+
73
+ @model_serializer()
74
+ def _serialize(self) -> dict[str, Any]:
75
+ raise NotImplementedError("Prompt message serialization is not implemented.")
76
+
77
+
78
+ class JSONRPCCancelMessage(_MessageBase):
79
+ method: Literal["cancel"] = "cancel"
80
+ id: str
81
+ params: JsonType | None = None
82
+
83
+ @model_serializer()
84
+ def _serialize(self) -> dict[str, Any]:
85
+ raise NotImplementedError("Cancel message serialization is not implemented.")
86
+
87
+
88
+ class _ResponseBase(_MessageBase):
89
+ id: str
90
+
91
+
92
+ class JSONRPCSuccessResponse(_ResponseBase):
93
+ result: JsonType
94
+
95
+
96
+ class JSONRPCErrorObject(BaseModel):
97
+ code: int
98
+ message: str
99
+ data: JsonType | None = None
100
+
101
+
102
+ class JSONRPCErrorResponse(_ResponseBase):
103
+ error: JSONRPCErrorObject
104
+
105
+
106
+ class JSONRPCApprovalRequestResult(ApprovalRequestResolved):
107
+ """
108
+ The `JSONRPCSuccessResponse.result` field for approval request responses should be able to
109
+ be parsed into this type.
110
+ """
111
+
112
+ pass
113
+
114
+
115
+ type JSONRPCInMessage = (
116
+ JSONRPCPromptMessage | JSONRPCCancelMessage | JSONRPCSuccessResponse | JSONRPCErrorResponse
117
+ )
118
+ JSONRPCInMessageAdapter = TypeAdapter[JSONRPCInMessage](JSONRPCInMessage)
119
+
120
+ type JSONRPCOutMessage = (
121
+ JSONRPCEventMessage | JSONRPCRequestMessage | JSONRPCSuccessResponse | JSONRPCErrorResponse
122
+ )
123
+
124
+
125
+ class ErrorCodes:
126
+ INVALID_STATE = -32000
127
+ """The server is in an invalid state to process the request."""
128
+ LLM_NOT_SET = -32001
129
+ """The LLM is not set."""
130
+ LLM_NOT_SUPPORTED = -32002
131
+ """The specified LLM is not supported."""
132
+ CHAT_PROVIDER_ERROR = -32003
133
+ """There was an error from the chat provider."""
134
+
135
+
136
+ class Statuses:
137
+ FINISHED = "finished"
138
+ """The agent run has finished successfully."""
139
+ CANCELLED = "cancelled"
140
+ """The agent run was cancelled by the user."""
141
+ MAX_STEPS_REACHED = "max_steps_reached"
142
+ """The agent run reached the maximum number of steps."""
@@ -0,0 +1 @@
1
+ WIRE_PROTOCOL_VERSION: str = "1"
File without changes
kimi_cli/utils/aiohttp.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import ssl
2
4
 
3
5
  import aiohttp
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+
6
+ if sys.version_info >= (3, 13):
7
+ QueueShutDown = asyncio.QueueShutDown # type: ignore[assignment]
8
+
9
+ class Queue[T](asyncio.Queue[T]):
10
+ """Asyncio Queue with shutdown support."""
11
+
12
+ else:
13
+
14
+ class QueueShutDown(Exception):
15
+ """Raised when operating on a shut down queue."""
16
+
17
+ class _Shutdown:
18
+ """Sentinel for queue shutdown."""
19
+
20
+ _SHUTDOWN = _Shutdown()
21
+
22
+ class Queue[T](asyncio.Queue[T | _Shutdown]):
23
+ """Asyncio Queue with shutdown support for Python < 3.13."""
24
+
25
+ def __init__(self) -> None:
26
+ super().__init__()
27
+ self._shutdown = False
28
+
29
+ def shutdown(self, immediate: bool = False) -> None:
30
+ if self._shutdown:
31
+ return
32
+ self._shutdown = True
33
+ if immediate:
34
+ self._queue.clear()
35
+
36
+ getters = list(getattr(self, "_getters", []))
37
+ count = max(1, len(getters))
38
+ self._enqueue_shutdown(count)
39
+
40
+ def _enqueue_shutdown(self, count: int) -> None:
41
+ for _ in range(count):
42
+ try:
43
+ super().put_nowait(_SHUTDOWN)
44
+ except asyncio.QueueFull:
45
+ self._queue.clear()
46
+ super().put_nowait(_SHUTDOWN)
47
+
48
+ async def get(self) -> T:
49
+ if self._shutdown and self.empty():
50
+ raise QueueShutDown
51
+ item = await super().get()
52
+ if isinstance(item, _Shutdown):
53
+ raise QueueShutDown
54
+ return item
55
+
56
+ def get_nowait(self) -> T:
57
+ if self._shutdown and self.empty():
58
+ raise QueueShutDown
59
+ item = super().get_nowait()
60
+ if isinstance(item, _Shutdown):
61
+ raise QueueShutDown
62
+ return item
63
+
64
+ async def put(self, item: T) -> None:
65
+ if self._shutdown:
66
+ raise QueueShutDown
67
+ await super().put(item)
68
+
69
+ def put_nowait(self, item: T) -> None:
70
+ if self._shutdown:
71
+ raise QueueShutDown
72
+ super().put_nowait(item)
@@ -0,0 +1,37 @@
1
+ import asyncio
2
+
3
+ from kimi_cli.utils.aioqueue import Queue
4
+
5
+
6
+ class BroadcastQueue[T]:
7
+ """
8
+ A broadcast queue that allows multiple subscribers to receive published items.
9
+ """
10
+
11
+ def __init__(self) -> None:
12
+ self._queues: set[Queue[T]] = set()
13
+
14
+ def subscribe(self) -> Queue[T]:
15
+ """Create a new subscription queue."""
16
+ queue: Queue[T] = Queue()
17
+ self._queues.add(queue)
18
+ return queue
19
+
20
+ def unsubscribe(self, queue: Queue[T]) -> None:
21
+ """Remove a subscription queue."""
22
+ self._queues.discard(queue)
23
+
24
+ async def publish(self, item: T) -> None:
25
+ """Publish an item to all subscription queues."""
26
+ await asyncio.gather(*(queue.put(item) for queue in self._queues))
27
+
28
+ def publish_nowait(self, item: T) -> None:
29
+ """Publish an item to all subscription queues without waiting."""
30
+ for queue in self._queues:
31
+ queue.put_nowait(item)
32
+
33
+ def shutdown(self, immediate: bool = False) -> None:
34
+ """Close all subscription queues."""
35
+ for queue in self._queues:
36
+ queue.shutdown(immediate=immediate)
37
+ self._queues.clear()
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from pathlib import Path
2
4
  from typing import NamedTuple
3
5
 
@@ -41,13 +43,14 @@ def parse_changelog(md_text: str) -> dict[str, ReleaseEntry]:
41
43
 
42
44
  for raw in lines:
43
45
  line = raw.rstrip()
44
- if line.startswith("## ["):
45
- # New version section, flush previous
46
+ # Format: `## 0.75 (2026-01-09)` or `## Unreleased`
47
+ if line.startswith("## "):
46
48
  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()
49
+ ver = line[3:].strip()
50
+ # Remove trailing date in parentheses if present
51
+ if "(" in ver:
52
+ ver = ver[: ver.find("(")].strip()
53
+ current_ver = ver
51
54
  desc_lines = []
52
55
  bullet_lines = []
53
56
  collecting_desc = True
@@ -85,7 +88,7 @@ def parse_changelog(md_text: str) -> dict[str, ReleaseEntry]:
85
88
  return result
86
89
 
87
90
 
88
- def format_release_notes(changelog: dict[str, ReleaseEntry]) -> str:
91
+ def format_release_notes(changelog: dict[str, ReleaseEntry], include_lib_changes: bool) -> str:
89
92
  parts: list[str] = []
90
93
  for ver, entry in changelog.items():
91
94
  s = f"[bold]{ver}[/bold]"
@@ -93,6 +96,8 @@ def format_release_notes(changelog: dict[str, ReleaseEntry]) -> str:
93
96
  s += f": {entry.description}"
94
97
  if entry.entries:
95
98
  for it in entry.entries:
99
+ if it.lower().startswith("lib:") and not include_lib_changes:
100
+ continue
96
101
  s += "\n[markdown.item.bullet]• [/]" + it
97
102
  parts.append(s + "\n")
98
103
  return "\n".join(parts).strip()
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import pyperclip
4
+
5
+
6
+ def is_clipboard_available() -> bool:
7
+ """Check if the Pyperclip clipboard is available."""
8
+ try:
9
+ pyperclip.paste()
10
+ return True
11
+ except Exception:
12
+ return False
@@ -0,0 +1,37 @@
1
+ from datetime import datetime, timedelta
2
+
3
+
4
+ def format_relative_time(timestamp: float) -> str:
5
+ """Format a timestamp as a relative time string."""
6
+ now = datetime.now()
7
+ dt = datetime.fromtimestamp(timestamp)
8
+ diff = now - dt
9
+ if diff < timedelta(minutes=5):
10
+ return "just now"
11
+ if diff < timedelta(hours=1):
12
+ minutes = int(diff.total_seconds() / 60)
13
+ return f"{minutes}m ago"
14
+ if diff < timedelta(days=1):
15
+ hours = int(diff.total_seconds() / 3600)
16
+ return f"{hours}h ago"
17
+ if diff < timedelta(days=7):
18
+ return f"{diff.days}d ago"
19
+ return dt.strftime("%m-%d")
20
+
21
+
22
+ def format_duration(seconds: int) -> str:
23
+ """Format a duration in seconds using short units."""
24
+ delta = timedelta(seconds=seconds)
25
+ parts: list[str] = []
26
+ days = delta.days
27
+ if days:
28
+ parts.append(f"{days}d")
29
+ hours, remainder = divmod(delta.seconds, 3600)
30
+ minutes, secs = divmod(remainder, 60)
31
+ if hours:
32
+ parts.append(f"{hours}h")
33
+ if minutes:
34
+ parts.append(f"{minutes}m")
35
+ if secs and not parts:
36
+ parts.append(f"{secs}s")
37
+ return " ".join(parts) or "0s"
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ from kaos.path import KaosPath
8
+
9
+
10
+ @dataclass(slots=True, frozen=True, kw_only=True)
11
+ class Environment:
12
+ os_kind: Literal["Windows", "Linux", "macOS"] | str
13
+ os_arch: str
14
+ os_version: str
15
+ shell_name: Literal["bash", "sh", "Windows PowerShell"]
16
+ shell_path: KaosPath
17
+
18
+ @staticmethod
19
+ async def detect() -> Environment:
20
+ match platform.system():
21
+ case "Darwin":
22
+ os_kind = "macOS"
23
+ case "Windows":
24
+ os_kind = "Windows"
25
+ case "Linux":
26
+ os_kind = "Linux"
27
+ case system:
28
+ os_kind = system
29
+
30
+ os_arch = platform.machine()
31
+ os_version = platform.version()
32
+
33
+ if os_kind == "Windows":
34
+ shell_name = "Windows PowerShell"
35
+ shell_path = KaosPath("powershell.exe")
36
+ else:
37
+ possible_paths = [
38
+ KaosPath("/bin/bash"),
39
+ KaosPath("/usr/bin/bash"),
40
+ KaosPath("/usr/local/bin/bash"),
41
+ ]
42
+ fallback_path = KaosPath("/bin/sh")
43
+ for path in possible_paths:
44
+ if await path.is_file():
45
+ shell_name = "bash"
46
+ shell_path = path
47
+ break
48
+ else:
49
+ shell_name = "sh"
50
+ shell_path = fallback_path
51
+
52
+ return Environment(
53
+ os_kind=os_kind,
54
+ os_arch=os_arch,
55
+ os_version=os_version,
56
+ shell_name=shell_name,
57
+ shell_path=shell_path,
58
+ )