kiwimatecoder 0.1.0__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.
Files changed (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,417 @@
1
+ """Grouped option pickers: ``choice``-style prompts split into framed boxes.
2
+
3
+ Provider pickers show the experimental providers (currently KiwiMate) at the
4
+ top in their own box titled "Experimental", above the normal list in a second
5
+ box. prompt_toolkit's ``choice`` and ``repl.checkbox_choice`` render a single
6
+ flat list, so this module draws each group of options in its own
7
+ :class:`~prompt_toolkit.widgets.Frame` while one flat cursor moves across all
8
+ of them as if they were a single list.
9
+
10
+ :func:`grouped_choice` picks one value (rows numbered continuously across
11
+ groups, digits 1-9 jump); :func:`grouped_checkbox_choice` picks several and
12
+ returns them in the order they were checked, so the first checked value can be
13
+ treated as the primary one.
14
+
15
+ The module only depends on prompt_toolkit so ``main`` can import it without
16
+ pulling in the REPL.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Sequence
22
+ from functools import partial
23
+ from typing import Any, Generic, TypeAlias, TypeVar
24
+
25
+ from prompt_toolkit.application import Application, get_app
26
+ from prompt_toolkit.filters import Condition, is_done, renderer_height_is_known
27
+ from prompt_toolkit.formatted_text import (
28
+ AnyFormattedText,
29
+ StyleAndTextTuples,
30
+ fragment_list_to_text,
31
+ to_formatted_text,
32
+ )
33
+ from prompt_toolkit.input import Input
34
+ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
35
+ from prompt_toolkit.keys import Keys
36
+ from prompt_toolkit.layout.containers import (
37
+ AnyContainer,
38
+ ConditionalContainer,
39
+ HSplit,
40
+ Window,
41
+ )
42
+ from prompt_toolkit.layout.controls import FormattedTextControl
43
+ from prompt_toolkit.layout.dimension import Dimension
44
+ from prompt_toolkit.layout.layout import Layout
45
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
46
+ from prompt_toolkit.output import Output
47
+ from prompt_toolkit.shortcuts.choice_input import create_default_choice_input_style
48
+ from prompt_toolkit.styles import BaseStyle, Style, merge_styles
49
+ from prompt_toolkit.widgets import Box, Frame, Label
50
+
51
+ __all__ = ["OptionGroup", "grouped_checkbox_choice", "grouped_choice"]
52
+
53
+ _T = TypeVar("_T")
54
+
55
+ # Rows moved by PageUp/PageDown.
56
+ _PAGE_ROWS = 10
57
+
58
+ # ``(title, options)``: a titled group is drawn in its own titled box; an
59
+ # untitled one (``None``) in a plain box, or unframed when ``show_frame`` is off.
60
+ OptionGroup: TypeAlias = tuple[str | None, Sequence[tuple[_T, AnyFormattedText]]]
61
+
62
+
63
+ class _GroupedPicker(Generic[_T]):
64
+ """Flat cursor and check state spanning every group, plus one window per group."""
65
+
66
+ def __init__(
67
+ self,
68
+ groups: Sequence[OptionGroup[_T]],
69
+ *,
70
+ multiple: bool,
71
+ default_values: Sequence[_T] = (),
72
+ ) -> None:
73
+ self.multiple = multiple
74
+ self.values: list[_T] = []
75
+ self.labels: list[AnyFormattedText] = []
76
+ # ``(title, start, end)`` per non-empty group; its rows are values[start:end].
77
+ self.groups: list[tuple[str | None, int, int]] = []
78
+ for title, options in groups:
79
+ start = len(self.values)
80
+ for value, label in options:
81
+ self.values.append(value)
82
+ self.labels.append(label)
83
+ # Tested after iterating: an empty generator is truthy but adds no rows.
84
+ if len(self.values) > start:
85
+ self.groups.append((title, start, len(self.values)))
86
+
87
+ present = [row for row in map(self._row_of, default_values) if row is not None]
88
+ self.cursor = present[0] if present else 0
89
+ # Checked rows in the order they were checked (multi-choice only).
90
+ self.checked: list[int] = list(dict.fromkeys(present)) if multiple else []
91
+
92
+ self.windows = [
93
+ Window(
94
+ FormattedTextControl(
95
+ partial(self._fragments, index), focusable=True, show_cursor=False
96
+ ),
97
+ style="class:input-selection",
98
+ dont_extend_height=True,
99
+ )
100
+ for index in range(len(self.groups))
101
+ ]
102
+
103
+ def _row_of(self, value: _T) -> int | None:
104
+ try:
105
+ return self.values.index(value)
106
+ except ValueError:
107
+ return None
108
+
109
+ @property
110
+ def current_window(self) -> Window:
111
+ """The window of the group holding the cursor."""
112
+ for window, (_, start, end) in zip(self.windows, self.groups):
113
+ if start <= self.cursor < end:
114
+ return window
115
+ return self.windows[0]
116
+
117
+ @property
118
+ def checked_values(self) -> list[_T]:
119
+ return [self.values[row] for row in self.checked]
120
+
121
+ def move_to(self, row: int) -> None:
122
+ """Move the cursor (clamped, no wrap) and focus the window it lands in."""
123
+ self.cursor = max(0, min(len(self.values) - 1, row))
124
+ # Focusing the cursor's window keeps the terminal cursor on that row.
125
+ get_app().layout.focus(self.current_window)
126
+
127
+ def find(self, text: str) -> None:
128
+ """Move to the next row whose label starts with ``text`` (type-to-find)."""
129
+ needle = text.lower()
130
+ if not needle.strip():
131
+ return
132
+ count = len(self.values)
133
+ for step in range(1, count + 1):
134
+ row = (self.cursor + step) % count
135
+ label = fragment_list_to_text(to_formatted_text(self.labels[row]))
136
+ if label.lower().startswith(needle):
137
+ self.move_to(row)
138
+ return
139
+
140
+ def toggle(self) -> None:
141
+ if self.cursor in self.checked:
142
+ self.checked.remove(self.cursor)
143
+ else:
144
+ self.checked.append(self.cursor)
145
+
146
+ def _click(self, row: int, mouse_event: MouseEvent) -> None:
147
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
148
+ self.move_to(row)
149
+ if self.multiple:
150
+ self.toggle()
151
+
152
+ def _fragments(self, index: int) -> StyleAndTextTuples:
153
+ _, start, end = self.groups[index]
154
+ result: StyleAndTextTuples = []
155
+ for row in range(start, end):
156
+ fragments: StyleAndTextTuples = []
157
+ if self.multiple:
158
+ self._checkbox_row(row, fragments)
159
+ else:
160
+ self._choice_row(row, fragments)
161
+ fragments.append(("", "\n"))
162
+ # A handler bound to the row itself (not the clicked line number)
163
+ # keeps clicks right when a label spans several lines.
164
+ handler = partial(self._click, row)
165
+ result.extend((fragment[0], fragment[1], handler) for fragment in fragments)
166
+ result.pop()
167
+ return result
168
+
169
+ def _choice_row(self, row: int, result: StyleAndTextTuples) -> None:
170
+ # Mirrors the RadioList rows drawn by prompt_toolkit's ``choice``.
171
+ selected = row == self.cursor
172
+ style = " class:selected-option" if selected else ""
173
+ if selected:
174
+ result.append(("[SetCursorPosition]", ""))
175
+ result.append((style, ">" if selected else " "))
176
+ result.append((f"{style} class:option", " "))
177
+ result.append((f"{style} class:number", f"{row + 1:2d}. "))
178
+ result.extend(to_formatted_text(self.labels[row], style=f"{style} class:option"))
179
+
180
+ def _checkbox_row(self, row: int, result: StyleAndTextTuples) -> None:
181
+ # Mirrors ``repl._CheckboxChoiceList`` rows.
182
+ checked = row in self.checked
183
+ selected = row == self.cursor
184
+ text_style = "class:option"
185
+ if checked:
186
+ text_style += " class:selected-option"
187
+ if selected:
188
+ text_style += " class:focused-option"
189
+
190
+ result.append(("class:cursor" if selected else "", "> " if selected else " "))
191
+ if selected:
192
+ result.append(("[SetCursorPosition]", ""))
193
+ result.append(
194
+ ("class:checkbox-checked", "[*] ")
195
+ if checked
196
+ else ("class:checkbox-unchecked", "[ ] ")
197
+ )
198
+ result.extend(to_formatted_text(self.labels[row], style=text_style))
199
+
200
+
201
+ def _picker_style(style: BaseStyle | None) -> BaseStyle:
202
+ """``choice`` defaults, the checkbox colours and the titled-group colours."""
203
+ return merge_styles(
204
+ [
205
+ create_default_choice_input_style(),
206
+ Style.from_dict(
207
+ {
208
+ "cursor": "bold cyan",
209
+ "checkbox-checked": "bold green",
210
+ "checkbox-unchecked": "dim",
211
+ "focused-option": "bold",
212
+ # Frame styles its container "class:frame class:option-group"
213
+ # and its border/title windows "class:frame.border"/".label".
214
+ "option-group frame.border": "ansiyellow",
215
+ "option-group frame.label": "bold ansiyellow",
216
+ }
217
+ ),
218
+ style if style is not None else Style([]),
219
+ ]
220
+ )
221
+
222
+
223
+ def _build_application(
224
+ message: AnyFormattedText,
225
+ picker: _GroupedPicker[_T],
226
+ *,
227
+ bottom_toolbar: AnyFormattedText = None,
228
+ show_frame: bool = True,
229
+ mouse_support: bool = False,
230
+ style: BaseStyle | None = None,
231
+ input: Input | None = None,
232
+ output: Output | None = None,
233
+ ) -> Application[Any]:
234
+ """Lay out the message, one box per group and the toolbar; bind the keys."""
235
+ boxes: list[AnyContainer] = [
236
+ Box(
237
+ Label(text=message, dont_extend_height=True),
238
+ padding_top=0,
239
+ padding_left=1,
240
+ padding_right=1,
241
+ padding_bottom=0,
242
+ )
243
+ ]
244
+ for window, (title, _, _) in zip(picker.windows, picker.groups):
245
+ if title or show_frame:
246
+ body = Box(window, padding_top=0, padding_left=1, padding_right=1, padding_bottom=0)
247
+ if title:
248
+ boxes.append(Frame(body, title=title, style="class:option-group"))
249
+ else:
250
+ boxes.append(Frame(body))
251
+ else:
252
+ # One extra column keeps the rows aligned with framed groups.
253
+ boxes.append(
254
+ Box(window, padding_top=0, padding_left=2, padding_right=1, padding_bottom=0)
255
+ )
256
+
257
+ show_bottom_toolbar = (
258
+ Condition(lambda: bottom_toolbar is not None) & ~is_done & renderer_height_is_known
259
+ )
260
+ toolbar_container = ConditionalContainer(
261
+ Window(
262
+ FormattedTextControl(lambda: bottom_toolbar, style="class:bottom-toolbar.text"),
263
+ style="class:bottom-toolbar",
264
+ dont_extend_height=True,
265
+ height=Dimension(min=1),
266
+ ),
267
+ filter=show_bottom_toolbar,
268
+ )
269
+ layout = Layout(
270
+ HSplit(
271
+ [
272
+ HSplit(boxes),
273
+ ConditionalContainer(Window(), filter=show_bottom_toolbar),
274
+ toolbar_container,
275
+ ]
276
+ ),
277
+ focused_element=picker.current_window,
278
+ )
279
+
280
+ # Bound at the application level so they work whichever group has focus.
281
+ kb = KeyBindings()
282
+
283
+ @kb.add("up")
284
+ @kb.add("k") # Vi-like.
285
+ def _up(event: KeyPressEvent) -> None:
286
+ picker.move_to(picker.cursor - 1)
287
+
288
+ @kb.add("down")
289
+ @kb.add("j") # Vi-like.
290
+ def _down(event: KeyPressEvent) -> None:
291
+ picker.move_to(picker.cursor + 1)
292
+
293
+ @kb.add("pageup")
294
+ def _page_up(event: KeyPressEvent) -> None:
295
+ picker.move_to(picker.cursor - _PAGE_ROWS)
296
+
297
+ @kb.add("pagedown")
298
+ def _page_down(event: KeyPressEvent) -> None:
299
+ picker.move_to(picker.cursor + _PAGE_ROWS)
300
+
301
+ @kb.add("home")
302
+ def _first(event: KeyPressEvent) -> None:
303
+ picker.move_to(0)
304
+
305
+ @kb.add("end")
306
+ def _last(event: KeyPressEvent) -> None:
307
+ picker.move_to(len(picker.values) - 1)
308
+
309
+ @kb.add(Keys.Any)
310
+ def _type_to_find(event: KeyPressEvent) -> None:
311
+ # Like prompt_toolkit's lists: a letter jumps to the next matching row.
312
+ picker.find(event.data)
313
+
314
+ if picker.multiple:
315
+
316
+ @kb.add(" ")
317
+ def _toggle(event: KeyPressEvent) -> None:
318
+ picker.toggle()
319
+
320
+ else:
321
+ for digit in range(1, 10):
322
+
323
+ @kb.add(str(digit))
324
+ def _jump(event: KeyPressEvent, row: int = digit - 1) -> None:
325
+ picker.move_to(row)
326
+
327
+ @kb.add("enter", eager=True)
328
+ def _accept_input(event: KeyPressEvent) -> None:
329
+ if picker.multiple:
330
+ event.app.exit(result=picker.checked_values, style="class:accepted")
331
+ else:
332
+ event.app.exit(result=picker.values[picker.cursor], style="class:accepted")
333
+
334
+ @kb.add("c-c", eager=True)
335
+ @kb.add("<sigint>", eager=True)
336
+ def _keyboard_interrupt(event: KeyPressEvent) -> None:
337
+ event.app.exit(exception=KeyboardInterrupt(), style="class:aborting")
338
+
339
+ return Application(
340
+ layout=layout,
341
+ full_screen=False,
342
+ mouse_support=mouse_support,
343
+ key_bindings=kb,
344
+ style=_picker_style(style),
345
+ input=input,
346
+ output=output,
347
+ )
348
+
349
+
350
+ def grouped_choice(
351
+ message: AnyFormattedText,
352
+ *,
353
+ groups: Sequence[OptionGroup[_T]],
354
+ default: _T | None = None,
355
+ bottom_toolbar: AnyFormattedText = None,
356
+ show_frame: bool = True,
357
+ mouse_support: bool = False,
358
+ style: BaseStyle | None = None,
359
+ input: Input | None = None,
360
+ output: Output | None = None,
361
+ ) -> _T:
362
+ """Single-choice prompt like ``choice`` whose options are split into boxes.
363
+
364
+ Empty groups are skipped. Raises ``ValueError`` when no group has options
365
+ and ``KeyboardInterrupt`` on Ctrl-C.
366
+ """
367
+ picker = _GroupedPicker(
368
+ groups, multiple=False, default_values=[default] if default is not None else []
369
+ )
370
+ if not picker.values:
371
+ raise ValueError("grouped_choice() needs at least one option")
372
+ app: Application[_T] = _build_application(
373
+ message,
374
+ picker,
375
+ bottom_toolbar=bottom_toolbar,
376
+ show_frame=show_frame,
377
+ mouse_support=mouse_support,
378
+ style=style,
379
+ input=input,
380
+ output=output,
381
+ )
382
+ return app.run()
383
+
384
+
385
+ def grouped_checkbox_choice(
386
+ message: AnyFormattedText,
387
+ *,
388
+ groups: Sequence[OptionGroup[_T]],
389
+ default_values: Sequence[_T] | None = None,
390
+ bottom_toolbar: AnyFormattedText = None,
391
+ show_frame: bool = True,
392
+ mouse_support: bool = False,
393
+ style: BaseStyle | None = None,
394
+ input: Input | None = None,
395
+ output: Output | None = None,
396
+ ) -> list[_T]:
397
+ """Checklist like ``repl.checkbox_choice`` whose options are split into boxes.
398
+
399
+ Returns the checked values in the order they were checked, starting with
400
+ ``default_values`` in their given order. Empty groups are skipped; with no
401
+ options at all it returns ``[]`` without prompting. Raises
402
+ ``KeyboardInterrupt`` on Ctrl-C.
403
+ """
404
+ picker = _GroupedPicker(groups, multiple=True, default_values=default_values or ())
405
+ if not picker.values:
406
+ return []
407
+ app: Application[list[_T]] = _build_application(
408
+ message,
409
+ picker,
410
+ bottom_toolbar=bottom_toolbar,
411
+ show_frame=show_frame,
412
+ mouse_support=mouse_support,
413
+ style=style,
414
+ input=input,
415
+ output=output,
416
+ )
417
+ return app.run()
@@ -0,0 +1,245 @@
1
+ """External plugins: user and project Python files extending the CLI.
2
+
3
+ A plugin is a ``*.py`` file with a ``register(api)`` function. It can add
4
+ tools, slash commands, and event subscribers without touching core:
5
+
6
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
7
+
8
+ def register(api):
9
+ api.register_tool(FunctionTool(...))
10
+ api.register_command("hello", hello_handler, "Say hello.")
11
+ api.subscribe("post_tool", on_tool)
12
+
13
+ Plugins load from ``~/.kiwimatecoder/plugins/*.py`` always. Project plugins in
14
+ ``<workspace>/.kiwimatecoder/plugins/*.py`` are disabled by default — cloning
15
+ a repository must never execute code just because the CLI started there — and
16
+ are enabled with ``"plugins": {"allow_project": true}``. A failed plugin is
17
+ recorded and skipped so it can never prevent startup.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib.util
23
+ import sys
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from types import ModuleType
28
+ from typing import Any
29
+
30
+ from rich.console import Console
31
+ from rich.markup import escape
32
+
33
+ from kiwimatecoder import commands, config, events, tools
34
+ from kiwimatecoder.events import EventBus, Subscriber
35
+ from kiwimatecoder.session import Session
36
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
37
+ from kiwimatecoder.tools.registry import PLUGIN_SOURCE
38
+
39
+ PLUGINS_DIR_NAME = "plugins"
40
+ PROJECT_PLUGINS_DIR = Path(".kiwimatecoder") / "plugins"
41
+
42
+ ToolFactory = Callable[[], FunctionTool]
43
+
44
+
45
+ @dataclass
46
+ class PluginLoadResult:
47
+ """Outcome of one :func:`load_plugins` pass."""
48
+
49
+ loaded: list[str] = field(default_factory=list)
50
+ failed: list[tuple[str, str]] = field(default_factory=list)
51
+ skipped: list[str] = field(default_factory=list)
52
+
53
+
54
+ class PluginAPI:
55
+ """The extension surface handed to each plugin's ``register(api)``.
56
+
57
+ Registrations are tracked so a partially loaded plugin can be cleaned up
58
+ and so :func:`unload_plugins` can reverse everything on reload.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ name: str,
64
+ console: Console | None = None,
65
+ bus: EventBus | None = None,
66
+ ) -> None:
67
+ self.name = name
68
+ self._console = console if console is not None else Console()
69
+ self._bus = bus if bus is not None else events.BUS
70
+ self._tools: list[str] = []
71
+ self._commands: list[str] = []
72
+ self._unsubscribes: list[Callable[[], None]] = []
73
+
74
+ def register_tool(
75
+ self,
76
+ tool: FunctionTool | ToolFactory | None = None,
77
+ *,
78
+ name: str | None = None,
79
+ description: str = "",
80
+ parameters: dict[str, Any] | None = None,
81
+ func: Callable[[dict[str, Any], Any], ToolResult] | None = None,
82
+ writes: bool = False,
83
+ runs: bool = False,
84
+ ) -> FunctionTool:
85
+ """Register a tool (an instance, a zero-arg factory, or keyword args)."""
86
+ resolved: Any = tool
87
+ if resolved is not None and not isinstance(resolved, FunctionTool):
88
+ if not callable(resolved):
89
+ raise ValueError(
90
+ "register_tool expects a FunctionTool, a factory, or keyword args."
91
+ )
92
+ resolved = resolved()
93
+ if resolved is None:
94
+ if not name or parameters is None or func is None:
95
+ raise ValueError(
96
+ "register_tool needs a FunctionTool or name, parameters, and func."
97
+ )
98
+ resolved = FunctionTool(
99
+ name=name,
100
+ description=description,
101
+ parameters=parameters,
102
+ func=func,
103
+ writes=writes,
104
+ runs=runs,
105
+ )
106
+ if not isinstance(resolved, FunctionTool):
107
+ raise ValueError("register_tool expects a FunctionTool instance.")
108
+ registered = tools.register_tool(resolved, source=PLUGIN_SOURCE)
109
+ self._tools.append(registered.name)
110
+ return registered
111
+
112
+ def register_command(
113
+ self,
114
+ name: str,
115
+ handler: Callable[[str, Session, Console], str],
116
+ description: str = "",
117
+ ) -> str:
118
+ """Register a slash command with the given handler."""
119
+ key = commands.register_command(name, handler, description)
120
+ self._commands.append(key)
121
+ return key
122
+
123
+ def subscribe(self, event: str, callback: Subscriber) -> Callable[[], None]:
124
+ """Subscribe to an event on the loader's bus for the whole session."""
125
+ unsubscribe = self._bus.subscribe(event, callback)
126
+ self._unsubscribes.append(unsubscribe)
127
+ return unsubscribe
128
+
129
+ def log(self, message: str) -> None:
130
+ """Print a dim status line through the loader's console."""
131
+ self._console.print(f"[dim]plugin {escape(self.name)}: {escape(message)}[/dim]")
132
+
133
+ def cleanup(self) -> None:
134
+ """Undo every registration this plugin made."""
135
+ for name in self._tools:
136
+ tools.unregister_tool(name)
137
+ for name in self._commands:
138
+ commands.unregister_command(name)
139
+ for unsubscribe in self._unsubscribes:
140
+ unsubscribe()
141
+ self._tools.clear()
142
+ self._commands.clear()
143
+ self._unsubscribes.clear()
144
+
145
+
146
+ _ACTIVE: dict[str, PluginAPI] = {}
147
+
148
+
149
+ def unload_plugins() -> None:
150
+ """Unregister every plugin loaded by :func:`load_plugins`."""
151
+ for api in list(_ACTIVE.values()):
152
+ api.cleanup()
153
+ _ACTIVE.clear()
154
+
155
+
156
+ def _plugin_files(directory: Path) -> list[Path]:
157
+ if not directory.is_dir():
158
+ return []
159
+ return [
160
+ path
161
+ for path in sorted(directory.glob("*.py"))
162
+ if path.is_file() and not path.name.startswith("_")
163
+ ]
164
+
165
+
166
+ def _load_module(name: str, path: Path) -> ModuleType:
167
+ module_name = f"kiwimatecoder_plugin_{name}"
168
+ spec = importlib.util.spec_from_file_location(module_name, path)
169
+ if spec is None or spec.loader is None:
170
+ raise ImportError(f"cannot load plugin module from {path}")
171
+ module = importlib.util.module_from_spec(spec)
172
+ sys.modules[module_name] = module
173
+ try:
174
+ spec.loader.exec_module(module)
175
+ except BaseException:
176
+ sys.modules.pop(module_name, None)
177
+ raise
178
+ return module
179
+
180
+
181
+ def _load_one(
182
+ name: str,
183
+ path: Path,
184
+ console: Console | None,
185
+ bus: EventBus | None,
186
+ result: PluginLoadResult,
187
+ ) -> None:
188
+ try:
189
+ module = _load_module(name, path)
190
+ except (Exception, SystemExit) as exc:
191
+ result.failed.append((name, f"import failed: {exc}"))
192
+ return
193
+
194
+ register = getattr(module, "register", None)
195
+ if not callable(register):
196
+ result.failed.append((name, "no register(api) function found"))
197
+ return
198
+
199
+ api = PluginAPI(name, console=console, bus=bus)
200
+ try:
201
+ register(api)
202
+ except (Exception, SystemExit) as exc:
203
+ api.cleanup()
204
+ result.failed.append((name, f"register failed: {exc}"))
205
+ return
206
+ _ACTIVE[str(path)] = api
207
+ result.loaded.append(name)
208
+
209
+
210
+ def load_plugins(
211
+ workspace_root: Path,
212
+ console: Console | None = None,
213
+ bus: EventBus | None = None,
214
+ ) -> PluginLoadResult:
215
+ """Load user plugins and, when opted in, project plugins. Never raises.
216
+
217
+ Reloading first unloads everything registered by a previous call so tools
218
+ and commands do not pile up across sessions.
219
+ """
220
+ unload_plugins()
221
+ result = PluginLoadResult()
222
+ settings = config.get_plugins_config()
223
+
224
+ try:
225
+ user_dir = config.ensure_config_dir() / PLUGINS_DIR_NAME
226
+ except OSError:
227
+ # An unwritable home directory just means no user-level plugins.
228
+ user_dir = None
229
+ planned: list[tuple[str, Path]] = (
230
+ [(path.stem, path) for path in _plugin_files(user_dir)] if user_dir else []
231
+ )
232
+ project_dir = workspace_root / PROJECT_PLUGINS_DIR
233
+ project_files = _plugin_files(project_dir)
234
+ if settings["allow_project"]:
235
+ planned.extend((path.stem, path) for path in project_files)
236
+ else:
237
+ result.skipped.extend(path.stem for path in project_files)
238
+
239
+ disabled = set(settings["disabled"])
240
+ for name, path in planned:
241
+ if name in disabled:
242
+ result.skipped.append(name)
243
+ continue
244
+ _load_one(name, path, console, bus, result)
245
+ return result