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
kiwimatecoder/hooks.py ADDED
@@ -0,0 +1,170 @@
1
+ """Run user-configured shell commands on lifecycle events.
2
+
3
+ Commands live under the ``"hooks"`` block of the config, keyed by event name
4
+ (see :data:`kiwimatecoder.config.HOOK_EVENTS`). Each command runs with
5
+ ``shell=True`` in the session workspace and receives context through the
6
+ environment (``KIWI_EVENT``, ``KIWI_WORKSPACE``, and for tool events
7
+ ``KIWI_TOOL_NAME`` / ``KIWI_TOOL_OK`` / ``KIWI_TOOL_ARGS``).
8
+
9
+ Hooks are deliberately best-effort: a timeout, a non-zero exit, or a broken
10
+ command is reported as a :class:`HookResult` and never raises into the caller.
11
+ ``pre_tool`` hook failures are surfaced via :attr:`HookResult.blocked` so the
12
+ agent can stop the action.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from rich.console import Console
25
+ from rich.markup import escape
26
+
27
+ from kiwimatecoder import config
28
+ from kiwimatecoder.redaction import redact
29
+
30
+ HOOK_TIMEOUT_SECONDS = 60
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class HookResult:
35
+ """Outcome of running one configured hook command."""
36
+
37
+ name: str
38
+ command: str
39
+ exit_code: int
40
+ output: str
41
+ timed_out: bool = False
42
+
43
+ @property
44
+ def ok(self) -> bool:
45
+ return self.exit_code == 0 and not self.timed_out
46
+
47
+ @property
48
+ def blocked(self) -> bool:
49
+ """Whether this result should stop the action (``pre_tool`` hooks)."""
50
+ return not self.ok
51
+
52
+
53
+ def _workspace(session: Any) -> Path:
54
+ root = getattr(session, "workspace_root", None)
55
+ return Path(root) if root is not None else Path.cwd()
56
+
57
+
58
+ def _as_text(value: Any) -> str:
59
+ if value is None:
60
+ return ""
61
+ if isinstance(value, bytes):
62
+ return value.decode("utf-8", "replace")
63
+ return str(value)
64
+
65
+
66
+ def _combine_output(stdout: Any, stderr: Any) -> str:
67
+ return _as_text(stdout) + _as_text(stderr)
68
+
69
+
70
+ def _hook_environment(
71
+ event: str,
72
+ workspace: Path,
73
+ tool_name: str,
74
+ tool_args: dict[str, Any] | None,
75
+ ok: bool | None,
76
+ duration_ms: int | None,
77
+ ) -> dict[str, str]:
78
+ env = dict(os.environ)
79
+ env["KIWI_EVENT"] = event
80
+ env["KIWI_WORKSPACE"] = str(workspace)
81
+ if tool_name:
82
+ env["KIWI_TOOL_NAME"] = str(tool_name)
83
+ if ok is not None:
84
+ env["KIWI_TOOL_OK"] = "true" if ok else "false"
85
+ if tool_args is not None:
86
+ try:
87
+ encoded = json.dumps(tool_args, default=str, ensure_ascii=False)
88
+ except (TypeError, ValueError):
89
+ encoded = str(tool_args)
90
+ env["KIWI_TOOL_ARGS"] = redact(encoded)
91
+ if duration_ms is not None:
92
+ env["KIWI_TOOL_DURATION_MS"] = str(duration_ms)
93
+ return env
94
+
95
+
96
+ def _run_one(
97
+ event: str,
98
+ command: str,
99
+ workspace: Path,
100
+ env: dict[str, str],
101
+ console: Console | None,
102
+ ) -> HookResult:
103
+ try:
104
+ completed = subprocess.run(
105
+ command,
106
+ shell=True,
107
+ cwd=str(workspace),
108
+ capture_output=True,
109
+ text=True,
110
+ timeout=HOOK_TIMEOUT_SECONDS,
111
+ env=env,
112
+ )
113
+ result = HookResult(
114
+ name=event,
115
+ command=command,
116
+ exit_code=completed.returncode,
117
+ output=_combine_output(completed.stdout, completed.stderr),
118
+ )
119
+ except subprocess.TimeoutExpired as exc:
120
+ output = _combine_output(exc.stdout, exc.stderr)
121
+ result = HookResult(
122
+ name=event,
123
+ command=command,
124
+ exit_code=-1,
125
+ output=output or f"timed out after {HOOK_TIMEOUT_SECONDS}s",
126
+ timed_out=True,
127
+ )
128
+ except OSError as exc:
129
+ result = HookResult(
130
+ name=event,
131
+ command=command,
132
+ exit_code=-1,
133
+ output=f"could not run hook: {exc}",
134
+ )
135
+ except Exception as exc: # defensive: a hook must never break the caller
136
+ result = HookResult(
137
+ name=event,
138
+ command=command,
139
+ exit_code=-1,
140
+ output=f"hook crashed: {exc!r}",
141
+ )
142
+ if console is not None:
143
+ status = "ok" if result.ok else f"exit {result.exit_code}"
144
+ console.print(
145
+ f"[dim]hook {escape(event)} \\[{status}]: {escape(redact(command))}[/dim]"
146
+ )
147
+ output = result.output.strip()
148
+ if output:
149
+ console.print(redact(output), style="dim", markup=False, highlight=False)
150
+ return result
151
+
152
+
153
+ def run_hooks(
154
+ event: str,
155
+ *,
156
+ session: Any = None,
157
+ console: Console | None = None,
158
+ tool_name: str = "",
159
+ tool_args: dict[str, Any] | None = None,
160
+ ok: bool | None = None,
161
+ duration_ms: int | None = None,
162
+ cfg: dict[str, Any] | None = None,
163
+ ) -> list[HookResult]:
164
+ """Run every command configured for ``event``; never raises."""
165
+ commands = config.get_hooks(cfg).get(event, [])
166
+ if not commands:
167
+ return []
168
+ workspace = _workspace(session)
169
+ env = _hook_environment(event, workspace, tool_name, tool_args, ok, duration_ms)
170
+ return [_run_one(event, command, workspace, env, console) for command in commands]
kiwimatecoder/hunks.py ADDED
@@ -0,0 +1,260 @@
1
+ """Pure helpers for parsing unified diffs and applying hunk selections.
2
+
3
+ The approval UI can offer per-hunk review of a diff; this module turns a diff
4
+ into :class:`Hunk` records and rebuilds file content from only the selected
5
+ hunks. It performs no I/O and never touches the filesystem.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ _HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
16
+
17
+ # The body of a hunk is built from these prefix characters; ``\`` marks a GNU
18
+ # "no newline at end of file" line.
19
+ _BODY_PREFIXES = (" ", "+", "-", "\\")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Hunk:
24
+ """One ``@@`` hunk of a unified diff."""
25
+
26
+ index: int
27
+ header: str
28
+ lines: tuple[str, ...]
29
+ old_start: int
30
+ old_count: int
31
+ new_start: int
32
+ new_count: int
33
+
34
+
35
+ def split_hunks(diff_text: str) -> list[Hunk]:
36
+ """Parse ``diff_text`` into 1-based-indexed hunks.
37
+
38
+ Returns an empty list when the text carries no parseable unified-diff
39
+ hunks (for example the ``+++ create`` preview used for new files).
40
+ """
41
+ hunks: list[Hunk] = []
42
+ body: list[str] = []
43
+ current: tuple[str, int, int, int, int] | None = None
44
+
45
+ def flush() -> None:
46
+ nonlocal current, body
47
+ if current is None:
48
+ return
49
+ header, old_start, old_count, new_start, new_count = current
50
+ hunks.append(
51
+ Hunk(
52
+ index=len(hunks) + 1,
53
+ header=header,
54
+ lines=tuple(body),
55
+ old_start=old_start,
56
+ old_count=old_count,
57
+ new_start=new_start,
58
+ new_count=new_count,
59
+ )
60
+ )
61
+ body = []
62
+
63
+ for line in diff_text.splitlines(keepends=True):
64
+ match = _HEADER_RE.match(line)
65
+ if match:
66
+ flush()
67
+ current = (
68
+ line.rstrip("\n"),
69
+ int(match.group(1)),
70
+ int(match.group(2) or 1),
71
+ int(match.group(3)),
72
+ int(match.group(4) or 1),
73
+ )
74
+ continue
75
+ if current is not None and line[:1] in _BODY_PREFIXES:
76
+ body.append(line)
77
+ flush()
78
+ return hunks
79
+
80
+
81
+ def _parse_body(
82
+ body: Sequence[str],
83
+ old_lines: Sequence[str],
84
+ old_index: int,
85
+ old_count: int,
86
+ ) -> list[tuple[str, str]] | None:
87
+ """Split physical body lines into ``(prefix, content)`` pairs.
88
+
89
+ ``difflib`` does not always terminate a diff line with a newline, so a
90
+ physical line can glue several logical segments together (for example
91
+ ``-old+new`` at end of file). Context and deletion segments are anchored
92
+ against the original lines, which disambiguates glued additions.
93
+ """
94
+ out: list[tuple[str, str]] = []
95
+
96
+ def walk(line_no: int, position: int, old_i: int) -> int | None:
97
+ if line_no == len(body):
98
+ return old_i
99
+ raw = body[line_no]
100
+ if position == len(raw):
101
+ return walk(line_no + 1, 0, old_i)
102
+ char = raw[position]
103
+ if char == "\n":
104
+ return walk(line_no, position + 1, old_i)
105
+ if char in (" ", "-"):
106
+ if old_i >= len(old_lines):
107
+ return None
108
+ expected = old_lines[old_i]
109
+ end = position + 1 + len(expected)
110
+ if raw[position + 1 : end] != expected:
111
+ return None
112
+ out.append((char, expected))
113
+ result = walk(line_no, end, old_i + 1)
114
+ if result is not None:
115
+ return result
116
+ out.pop()
117
+ return None
118
+ if char == "+":
119
+ candidates = [len(raw)]
120
+ for candidate in range(position + 1, len(raw)):
121
+ if raw[candidate] not in (" ", "-") or old_i >= len(old_lines):
122
+ continue
123
+ expected = old_lines[old_i]
124
+ end = candidate + 1 + len(expected)
125
+ if raw[candidate + 1 : end] == expected:
126
+ candidates.append(candidate)
127
+ for candidate in sorted(set(candidates), reverse=True):
128
+ out.append(("+", raw[position + 1 : candidate]))
129
+ result = walk(line_no, candidate, old_i)
130
+ if result is not None:
131
+ return result
132
+ out.pop()
133
+ return None
134
+ if char == "\\":
135
+ if out:
136
+ prefix, content = out[-1]
137
+ out[-1] = (prefix, content.rstrip("\n"))
138
+ result = walk(line_no, len(raw), old_i)
139
+ if result is not None:
140
+ return result
141
+ out[-1] = (prefix, content)
142
+ return None
143
+ return walk(line_no, len(raw), old_i)
144
+ return None
145
+
146
+ consumed = walk(0, 0, old_index)
147
+ if consumed is None or consumed != old_index + old_count:
148
+ return None
149
+ return out
150
+
151
+
152
+ def _naive_segments(body: Sequence[str]) -> list[tuple[str, str]]:
153
+ """Best-effort parse for diffs whose old lines are not consistent."""
154
+ segments: list[tuple[str, str]] = []
155
+ for line in body:
156
+ if line.startswith("\\"):
157
+ continue
158
+ if line[:1] in (" ", "+", "-"):
159
+ segments.append((line[:1], line[1:]))
160
+ return segments
161
+
162
+
163
+ def _creation_target(diff_text: str) -> str | None:
164
+ """Rebuild the content targeted by a ``+++ create`` preview, if any."""
165
+ created = False
166
+ lines: list[str] = []
167
+ for line in diff_text.splitlines():
168
+ if line.startswith("+++ create "):
169
+ created = True
170
+ continue
171
+ if created and line.startswith("+"):
172
+ lines.append(line[1:])
173
+ if not created:
174
+ return None
175
+ return "\n".join(lines)
176
+
177
+
178
+ def apply_selected_hunks(
179
+ old_text: str,
180
+ diff_text: str,
181
+ selected: Sequence[int] | None,
182
+ ) -> str:
183
+ """Return ``old_text`` with only the selected 1-based hunks applied.
184
+
185
+ ``selected=None`` applies every hunk. When the diff has no parseable hunks
186
+ (such as a ``+++ create`` preview) any non-empty selection (or ``None``)
187
+ yields the target content and an empty selection yields ``old_text``.
188
+ """
189
+ old_lines = old_text.splitlines(keepends=True)
190
+ parsed = split_hunks(diff_text)
191
+ if not parsed:
192
+ if selected is None or len(selected) > 0:
193
+ target = _creation_target(diff_text)
194
+ if target is not None:
195
+ return target
196
+ return old_text
197
+
198
+ chosen = None if selected is None else set(selected)
199
+ result: list[str] = []
200
+ cursor = 0
201
+ for hunk in parsed:
202
+ start = max(cursor, hunk.old_start - 1)
203
+ if start > cursor:
204
+ result.extend(old_lines[cursor:start])
205
+ cursor = start
206
+ selected_hunk = chosen is None or hunk.index in chosen
207
+ if selected_hunk:
208
+ segments = _parse_body(hunk.lines, old_lines, cursor, hunk.old_count)
209
+ if segments is None:
210
+ segments = _naive_segments(hunk.lines)
211
+ result.extend(
212
+ content for prefix, content in segments if prefix in (" ", "+")
213
+ )
214
+ else:
215
+ result.extend(old_lines[cursor : cursor + hunk.old_count])
216
+ cursor += hunk.old_count
217
+ result.extend(old_lines[cursor:])
218
+ return "".join(result)
219
+
220
+
221
+ HunkSelection = tuple[int, ...] | Literal["all", "none"]
222
+
223
+
224
+ def parse_hunk_selection(answer: str, count: int) -> HunkSelection | None:
225
+ """Parse a user's hunk selection.
226
+
227
+ Accepts ``all``/``none``, comma- or space-separated indices (``1,3``) and
228
+ inclusive ranges (``1-2``). Returns ``None`` for an invalid answer.
229
+ """
230
+ text = answer.strip().lower()
231
+ if not text:
232
+ return None
233
+ if text in ("all", "a", "*"):
234
+ return "all"
235
+ if text in ("none", "n"):
236
+ return "none"
237
+
238
+ selected: set[int] = set()
239
+ for part in re.split(r"[,\s]+", text):
240
+ if not part:
241
+ continue
242
+ if "-" in part:
243
+ pieces = part.split("-")
244
+ if len(pieces) != 2 or not all(piece.isdigit() for piece in pieces):
245
+ return None
246
+ low, high = int(pieces[0]), int(pieces[1])
247
+ if low > high:
248
+ return None
249
+ candidates: Sequence[int] = range(low, high + 1)
250
+ elif part.isdigit():
251
+ candidates = (int(part),)
252
+ else:
253
+ return None
254
+ for index in candidates:
255
+ if not 1 <= index <= count:
256
+ return None
257
+ selected.add(index)
258
+ if not selected:
259
+ return None
260
+ return tuple(sorted(selected))