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/ui.py ADDED
@@ -0,0 +1,189 @@
1
+ """UI preferences: color control, themes, glyphs, and output modes.
2
+
3
+ The persistent values live under the ``ui`` key in the config file and are
4
+ normalized by :func:`kiwimatecoder.config.get_ui`. This module keeps the pure
5
+ decision logic (color precedence, glyph selection, theme accents) so it can be
6
+ unit-tested without a terminal.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ from typing import Any
14
+
15
+ from rich.console import Console
16
+
17
+ from kiwimatecoder.i18n import DEFAULT_LOCALE
18
+
19
+ UI_DEFAULTS: dict[str, Any] = {
20
+ "color": "auto",
21
+ "output_mode": "normal",
22
+ "ascii": False,
23
+ "theme": "default",
24
+ "locale": DEFAULT_LOCALE,
25
+ "keybindings": "emacs",
26
+ "notify": "off",
27
+ "notify_after_seconds": 20,
28
+ "spinner": "auto",
29
+ }
30
+
31
+ COLOR_MODES = ("auto", "always", "never")
32
+ OUTPUT_MODES = ("normal", "compact", "verbose")
33
+ THEMES = ("default", "ocean", "magenta", "mono")
34
+ KEYBINDINGS = ("emacs", "vim")
35
+ NOTIFY_MODES = ("off", "bell", "desktop")
36
+ SPINNER_MODES = ("auto", "on", "off")
37
+
38
+ # Process-wide overrides used by the global ``--plain`` flag. They are applied
39
+ # by the UI readers below but never written back to the config file.
40
+ _RUNTIME_OVERRIDES: dict[str, Any] = {}
41
+
42
+
43
+ def apply_plain_mode(enabled: bool) -> None:
44
+ """Force (or clear) plain output for this process; nothing is persisted.
45
+
46
+ Plain mode is ASCII glyphs, no color, compact tool output, and no animated
47
+ spinner. It is applied on top of whatever ``ui`` config resolves to.
48
+ """
49
+ global _RUNTIME_OVERRIDES
50
+ _RUNTIME_OVERRIDES = (
51
+ {
52
+ "ascii": True,
53
+ "color": "never",
54
+ "output_mode": "compact",
55
+ "spinner": "off",
56
+ }
57
+ if enabled
58
+ else {}
59
+ )
60
+
61
+
62
+ def runtime_overrides() -> dict[str, Any]:
63
+ """Return a copy of the active process-wide UI overrides."""
64
+ return dict(_RUNTIME_OVERRIDES)
65
+
66
+ UNICODE_GLYPHS: dict[str, str] = {
67
+ "check": "✓",
68
+ "cross": "✗",
69
+ "blocked": "⊘",
70
+ "folder": "📁",
71
+ "bullet": "•",
72
+ }
73
+
74
+ ASCII_GLYPHS: dict[str, str] = {
75
+ "check": "[ok]",
76
+ "cross": "[fail]",
77
+ "blocked": "[blocked]",
78
+ "folder": "",
79
+ "bullet": "-",
80
+ }
81
+
82
+ _THEME_ACCENTS: dict[str, str] = {
83
+ "default": "green",
84
+ "ocean": "cyan",
85
+ "magenta": "magenta",
86
+ "mono": "white",
87
+ }
88
+
89
+
90
+ def _ui_config(cfg: dict[str, Any] | None) -> dict[str, Any]:
91
+ """Normalize ``cfg`` (a config dict or nothing) into a UI settings dict.
92
+
93
+ Process-wide ``--plain`` overrides are merged on top so every reader in
94
+ this module sees the effective settings.
95
+ """
96
+ from kiwimatecoder.config import get_ui
97
+
98
+ if not isinstance(cfg, dict):
99
+ cfg = None
100
+ effective = get_ui(cfg)
101
+ if _RUNTIME_OVERRIDES:
102
+ effective = {**effective, **_RUNTIME_OVERRIDES}
103
+ return effective
104
+
105
+
106
+ def ui_config(cfg: dict[str, Any] | None = None) -> dict[str, Any]:
107
+ """Return the effective UI settings (config plus process overrides)."""
108
+ return _ui_config(cfg)
109
+
110
+
111
+ def resolve_color(cfg: dict[str, Any] | None = None) -> bool:
112
+ """Decide whether colored output is enabled, in this precedence order:
113
+
114
+ 1. An explicit ``ui.color`` of ``"always"`` enables color and ``"never"``
115
+ disables it, overriding the environment.
116
+ 2. ``NO_COLOR`` set to a non-empty value disables color (https://no-color.org).
117
+ 3. ``FORCE_COLOR`` set to a non-empty value enables color.
118
+ 4. Otherwise True. Rich still auto-detects a non-TTY and strips styling,
119
+ so a pipe or log file stays plain even when this returns True.
120
+ """
121
+ color = _ui_config(cfg)["color"]
122
+ if color == "always":
123
+ return True
124
+ if color == "never":
125
+ return False
126
+ if os.environ.get("NO_COLOR"):
127
+ return False
128
+ if os.environ.get("FORCE_COLOR"):
129
+ return True
130
+ return True
131
+
132
+
133
+ def make_console(**kwargs: Any) -> Console:
134
+ """Build a :class:`Console` honoring the color config and NO_COLOR.
135
+
136
+ Extra keyword arguments (``file``, ``width``, ``force_terminal``, ...) are
137
+ forwarded to Rich; an explicit ``no_color`` override wins.
138
+ """
139
+ kwargs.setdefault("no_color", not resolve_color())
140
+ return Console(**kwargs)
141
+
142
+
143
+ def glyph(name: str, cfg: dict[str, Any] | None = None) -> str:
144
+ """Return the active glyph for ``name`` (ASCII map when ascii mode is on).
145
+
146
+ Unknown names fall back to the name itself so callers get a predictable
147
+ label instead of a crash.
148
+ """
149
+ active = ASCII_GLYPHS if _ui_config(cfg)["ascii"] else UNICODE_GLYPHS
150
+ return active.get(name, name)
151
+
152
+
153
+ def glyphs(cfg: dict[str, Any] | None = None) -> dict[str, str]:
154
+ """Return the whole active glyph map (a copy, safe to mutate)."""
155
+ return dict(ASCII_GLYPHS if _ui_config(cfg)["ascii"] else UNICODE_GLYPHS)
156
+
157
+
158
+ def theme_accent(cfg: dict[str, Any] | None = None) -> str:
159
+ """Map the configured theme to a Rich color name.
160
+
161
+ ``cfg`` may be a config dict or a session-like object; non-dicts fall back
162
+ to the stored config.
163
+ """
164
+ return _THEME_ACCENTS.get(_ui_config(cfg)["theme"], "green")
165
+
166
+
167
+ def vi_mode_enabled(cfg: dict[str, Any] | None = None) -> bool:
168
+ """Whether the prompt should start in Vim mode (``ui.keybindings``)."""
169
+ return _ui_config(cfg)["keybindings"] == "vim"
170
+
171
+
172
+ def hide_typed_secrets() -> bool:
173
+ """Whether a typed secret (an API key) should be read without echo.
174
+
175
+ Hidden input goes through ``getpass``, which reads the controlling terminal
176
+ and ignores stdin. That is right when stdin *is* the terminal, and wrong
177
+ otherwise: a piped key (``echo $KEY | kiwimatecoder setup``) or a test
178
+ runner's simulated stdin would be ignored while the program waits on the
179
+ keyboard. A redirected stdin is never echoed anyway, so it is read as is.
180
+ """
181
+ try:
182
+ return bool(sys.stdin and sys.stdin.isatty())
183
+ except (AttributeError, ValueError, OSError): # closed or detached stdin
184
+ return False
185
+
186
+
187
+ def spinner_enabled(cfg: dict[str, Any] | None = None) -> bool:
188
+ """Whether animated status spinners may run (``ui.spinner`` != ``off``)."""
189
+ return _ui_config(cfg)["spinner"] != "off"
@@ -0,0 +1,502 @@
1
+ """Self-update helper for the KiwiMateCoder CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shlex
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+
16
+ PACKAGE_NAME = "kiwimatecoder"
17
+ GITHUB_REPO_URL = "https://github.com/Kyle8933/kiwimatecoder.git"
18
+ GITHUB_REPO_OWNER = "Kyle8933"
19
+ GITHUB_REPO_NAME = "kiwimatecoder"
20
+ UPDATE_STATE_NAME = "update_check.json"
21
+ DEFAULT_CHECK_INTERVAL_HOURS = 24
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class UpdateInfo:
26
+ """Result of checking for a newer KiwiMateCoder version."""
27
+
28
+ update_available: bool
29
+ current_version: str
30
+ latest_version: str
31
+ current_sha: str | None = None
32
+ latest_sha: str | None = None
33
+ commits_behind: int | None = None
34
+ error: str | None = None
35
+
36
+
37
+ def _update_state_path() -> Path:
38
+ from kiwimatecoder.config import CONFIG_DIR
39
+
40
+ return CONFIG_DIR / UPDATE_STATE_NAME
41
+
42
+
43
+ def load_update_state() -> dict:
44
+ """Load the cached update-check state (last check time, last seen version)."""
45
+ path = _update_state_path()
46
+ if not path.is_file():
47
+ return {}
48
+ try:
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ return data if isinstance(data, dict) else {}
51
+ except (json.JSONDecodeError, OSError):
52
+ return {}
53
+
54
+
55
+ def save_update_state(state: dict) -> None:
56
+ """Persist the update-check state."""
57
+ from kiwimatecoder.config import ensure_config_dir
58
+
59
+ ensure_config_dir()
60
+ path = _update_state_path()
61
+ try:
62
+ path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
63
+ except OSError:
64
+ pass
65
+
66
+
67
+ def _get_remote_head_sha(branch: str = "main") -> str | None:
68
+ """Return the short SHA of the remote branch HEAD via ``git ls-remote``."""
69
+ try:
70
+ cp = subprocess.run(
71
+ ["git", "ls-remote", GITHUB_REPO_URL, f"refs/heads/{branch}"],
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=10,
75
+ )
76
+ except (OSError, subprocess.TimeoutExpired):
77
+ return None
78
+ if cp.returncode != 0:
79
+ return None
80
+ line = cp.stdout.strip().splitlines()
81
+ if not line:
82
+ return None
83
+ full_sha = line[0].split()[0]
84
+ return full_sha[:7] if len(full_sha) >= 7 else full_sha
85
+
86
+
87
+ def _get_latest_release_tag() -> str | None:
88
+ """Return the latest GitHub release tag, or None if unavailable."""
89
+ import httpx
90
+
91
+ from kiwimatecoder import network
92
+
93
+ url = f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/releases/latest"
94
+ try:
95
+ with httpx.Client(
96
+ timeout=8.0,
97
+ follow_redirects=True,
98
+ **network.current_options(),
99
+ ) as client:
100
+ response = client.get(url, headers={"Accept": "application/vnd.github+json"})
101
+ if response.status_code != 200:
102
+ return None
103
+ data = response.json()
104
+ tag = data.get("tag_name")
105
+ return str(tag) if tag else None
106
+ except Exception:
107
+ return None
108
+
109
+
110
+ def check_for_update(
111
+ *, force: bool = False, console: Console | None = None
112
+ ) -> UpdateInfo:
113
+ """Check whether a newer KiwiMateCoder version is available.
114
+
115
+ For source checkouts, compares the local HEAD SHA against the remote
116
+ branch HEAD. For packaged installs, queries the GitHub releases API.
117
+ Results are cached in ``~/.kiwimatecoder/update_check.json`` to avoid
118
+ hitting the network on every startup; ``force`` bypasses the cache.
119
+
120
+ Respects offline mode and never raises — failures return an ``UpdateInfo``
121
+ with ``error`` set.
122
+ """
123
+ from kiwimatecoder import __version__, network
124
+
125
+ if network.offline_enabled():
126
+ return UpdateInfo(
127
+ update_available=False,
128
+ current_version=__version__,
129
+ latest_version=__version__,
130
+ error="offline mode is enabled",
131
+ )
132
+
133
+ state = load_update_state()
134
+ now = time.time()
135
+
136
+ if not force:
137
+ last_check = state.get("last_check")
138
+ if isinstance(last_check, (int, float)):
139
+ from kiwimatecoder.config import get_update
140
+
141
+ interval_hours = get_update().get("check_interval_hours", DEFAULT_CHECK_INTERVAL_HOURS)
142
+ if now - last_check < interval_hours * 3600:
143
+ cached_available = state.get("update_available", False)
144
+ return UpdateInfo(
145
+ update_available=bool(cached_available),
146
+ current_version=str(state.get("current_version", __version__)),
147
+ latest_version=str(state.get("latest_version", __version__)),
148
+ current_sha=state.get("current_sha"),
149
+ latest_sha=state.get("latest_sha"),
150
+ commits_behind=state.get("commits_behind"),
151
+ )
152
+
153
+ current_version = __version__
154
+ source_root = find_source_root()
155
+
156
+ if source_root is not None and _has_git_remote(source_root):
157
+ local_sha = _get_short_sha(source_root)
158
+ branch = _get_branch(source_root) or "main"
159
+ remote_sha = _get_remote_head_sha(branch)
160
+
161
+ if remote_sha is None:
162
+ return UpdateInfo(
163
+ update_available=False,
164
+ current_version=current_version,
165
+ latest_version=current_version,
166
+ current_sha=local_sha,
167
+ error="could not reach GitHub",
168
+ )
169
+
170
+ if local_sha is None:
171
+ return UpdateInfo(
172
+ update_available=False,
173
+ current_version=current_version,
174
+ latest_version=current_version,
175
+ latest_sha=remote_sha,
176
+ error="could not determine local commit",
177
+ )
178
+
179
+ behind = _commits_behind(source_root, branch)
180
+ # Fetch first to get an accurate behind count
181
+ if behind is None:
182
+ try:
183
+ subprocess.run(
184
+ ["git", "-C", str(source_root), "fetch", "origin", branch],
185
+ capture_output=True,
186
+ timeout=15,
187
+ )
188
+ behind = _commits_behind(source_root, branch)
189
+ except (OSError, subprocess.TimeoutExpired):
190
+ behind = None
191
+
192
+ update_available = local_sha != remote_sha and (behind is None or behind > 0)
193
+ result = UpdateInfo(
194
+ update_available=update_available,
195
+ current_version=current_version,
196
+ latest_version=current_version,
197
+ current_sha=local_sha,
198
+ latest_sha=remote_sha,
199
+ commits_behind=behind,
200
+ )
201
+ else:
202
+ latest_tag = _get_latest_release_tag()
203
+ if latest_tag is None:
204
+ return UpdateInfo(
205
+ update_available=False,
206
+ current_version=current_version,
207
+ latest_version=current_version,
208
+ error="could not reach GitHub",
209
+ )
210
+ update_available = latest_tag.lstrip("v") != current_version.lstrip("v")
211
+ result = UpdateInfo(
212
+ update_available=update_available,
213
+ current_version=current_version,
214
+ latest_version=latest_tag,
215
+ )
216
+
217
+ save_update_state(
218
+ {
219
+ "last_check": now,
220
+ "update_available": result.update_available,
221
+ "current_version": result.current_version,
222
+ "latest_version": result.latest_version,
223
+ "current_sha": result.current_sha,
224
+ "latest_sha": result.latest_sha,
225
+ "commits_behind": result.commits_behind,
226
+ }
227
+ )
228
+ return result
229
+
230
+
231
+ def should_check_for_update() -> bool:
232
+ """Whether the periodic auto-check should run now (config + interval)."""
233
+ from kiwimatecoder.config import get_update
234
+
235
+ settings = get_update()
236
+ if not settings.get("auto_check", True):
237
+ return False
238
+ state = load_update_state()
239
+ last_check = state.get("last_check")
240
+ if not isinstance(last_check, (int, float)):
241
+ return True
242
+ interval_hours = settings.get("check_interval_hours", DEFAULT_CHECK_INTERVAL_HOURS)
243
+ return time.time() - last_check >= interval_hours * 3600
244
+
245
+
246
+ def build_update_command(ref: str | None = None) -> list[str]:
247
+ """Return the fallback update command for packaged (non-Git) installs.
248
+
249
+ KiwiMateCoder is not published to PyPI, so the fallback installs from the
250
+ GitHub repository instead of a PyPI package name. ``--force-reinstall`` is
251
+ required because the package version string is often unchanged between
252
+ commits, so ``--upgrade`` alone can be a no-op ("Requirement already
253
+ satisfied"). ``ref`` pins a branch, tag, or commit SHA.
254
+ """
255
+ target = f"git+{GITHUB_REPO_URL}"
256
+ if ref:
257
+ target = f"{target}@{ref}"
258
+ return [
259
+ sys.executable,
260
+ "-m",
261
+ "pip",
262
+ "install",
263
+ "--upgrade",
264
+ "--force-reinstall",
265
+ target,
266
+ ]
267
+
268
+
269
+ def build_git_pull_command(source_root: Path) -> list[str]:
270
+ """Return the command used to fetch source updates for a Git checkout."""
271
+ return ["git", "-C", str(source_root), "pull", "--ff-only"]
272
+
273
+
274
+ def build_git_checkout_command(
275
+ source_root: Path, ref: str, *, current_branch: str | None = None
276
+ ) -> list[str]:
277
+ """Return the command that puts a Git checkout on ``ref``.
278
+
279
+ When ``ref`` is the branch already checked out, a fast-forward pull is
280
+ used so local commits are never merged unexpectedly; every other ref
281
+ (tag, SHA, or other branch) is a plain ``git checkout``.
282
+ """
283
+ if current_branch is not None and ref == current_branch:
284
+ return build_git_pull_command(source_root)
285
+ return ["git", "-C", str(source_root), "checkout", ref]
286
+
287
+
288
+ def build_source_install_command(source_root: Path) -> list[str]:
289
+ """Return the command used to reinstall KiwiMateCoder from a source checkout."""
290
+ return [
291
+ sys.executable,
292
+ "-m",
293
+ "pip",
294
+ "install",
295
+ "--upgrade",
296
+ "-e",
297
+ str(source_root),
298
+ ]
299
+
300
+
301
+ def find_source_root(package_file: Path | None = None) -> Path | None:
302
+ """Find the project root for editable/local source installs."""
303
+ current = (package_file or Path(__file__)).resolve()
304
+ for candidate in (current.parent, *current.parents):
305
+ if (
306
+ (candidate / "pyproject.toml").is_file()
307
+ and (candidate / PACKAGE_NAME).is_dir()
308
+ ):
309
+ return candidate
310
+ return None
311
+
312
+
313
+ def _has_git_remote(source_root: Path) -> bool:
314
+ probe = subprocess.run(
315
+ ["git", "-C", str(source_root), "remote", "get-url", "origin"],
316
+ stdout=subprocess.DEVNULL,
317
+ stderr=subprocess.DEVNULL,
318
+ text=True,
319
+ )
320
+ return probe.returncode == 0
321
+
322
+
323
+ def _git(args: list[str], source_root: Path) -> subprocess.CompletedProcess[str]:
324
+ return subprocess.run(
325
+ ["git", "-C", str(source_root), *args],
326
+ capture_output=True,
327
+ text=True,
328
+ )
329
+
330
+
331
+ def _get_short_sha(source_root: Path) -> str | None:
332
+ """Return the short SHA of HEAD, or None if it cannot be determined."""
333
+ cp = _git(["rev-parse", "--short", "HEAD"], source_root)
334
+ if cp.returncode != 0:
335
+ return None
336
+ return cp.stdout.strip() or None
337
+
338
+
339
+ def _get_branch(source_root: Path) -> str | None:
340
+ """Return the current branch name, or None when detached/unknown."""
341
+ cp = _git(["rev-parse", "--abbrev-ref", "HEAD"], source_root)
342
+ if cp.returncode != 0:
343
+ return None
344
+ branch = cp.stdout.strip()
345
+ return None if branch in ("", "HEAD") else branch
346
+
347
+
348
+ def _commits_behind(source_root: Path, branch: str | None) -> int | None:
349
+ """Return how many commits HEAD is behind origin/<branch>, or None if unknown."""
350
+ if not branch:
351
+ return None
352
+ cp = _git(["rev-list", "--count", f"HEAD..origin/{branch}"], source_root)
353
+ if cp.returncode != 0:
354
+ return None
355
+ try:
356
+ return int(cp.stdout.strip())
357
+ except ValueError:
358
+ return None
359
+
360
+
361
+ def _fetch(source_root: Path, console: Console) -> int:
362
+ """Best-effort `git fetch origin`. Failures are non-fatal."""
363
+ command = ["git", "-C", str(source_root), "fetch", "origin"]
364
+ console.print(f"[dim]{escape(shlex.join(command))}[/dim]")
365
+ try:
366
+ completed = subprocess.run(command, text=True)
367
+ except OSError as exc:
368
+ console.print(f"[yellow]Could not fetch from origin: {escape(str(exc))}[/yellow]")
369
+ return 1
370
+ if completed.returncode != 0:
371
+ console.print(
372
+ "[yellow]git fetch failed; continuing with a direct pull.[/yellow]"
373
+ )
374
+ return completed.returncode
375
+
376
+
377
+ def _run(command: list[str], console: Console) -> int:
378
+ console.print(f"[dim]{escape(shlex.join(command))}[/dim]")
379
+ try:
380
+ completed = subprocess.run(command, text=True)
381
+ except OSError as exc:
382
+ console.print(f"[red]Could not start command: {escape(str(exc))}[/red]")
383
+ return 1
384
+ return completed.returncode
385
+
386
+
387
+ def run_update(console: Console | None = None, ref: str | None = None) -> int:
388
+ """Update KiwiMateCoder in the current Python environment.
389
+
390
+ ``ref`` pins a branch, tag, or commit SHA for the update. In a Git
391
+ checkout the current branch is fast-forwarded when it matches, and any
392
+ other ref is a plain ``git checkout`` followed by a reinstall; packaged
393
+ installs target ``git+<repo>@<ref>``.
394
+ """
395
+ console = console or Console()
396
+ console.print("[cyan]Updating KiwiMateCoder...[/cyan]")
397
+ if ref:
398
+ console.print(f"[dim]Target ref: {escape(ref)}[/dim]")
399
+
400
+ source_root = find_source_root()
401
+ if source_root is not None and _has_git_remote(source_root):
402
+ console.print(f"[dim]Detected source checkout: {escape(str(source_root))}[/dim]")
403
+
404
+ old_sha = _get_short_sha(source_root)
405
+ _fetch(source_root, console)
406
+ branch = _get_branch(source_root)
407
+
408
+ if ref is not None:
409
+ if branch is not None and ref == branch and _commits_behind(source_root, branch) == 0:
410
+ console.print(
411
+ "[green]Already on "
412
+ + f"{escape(ref)} (commit {old_sha or 'unknown'}).[/green]"
413
+ )
414
+ return 0
415
+ checkout = build_git_checkout_command(
416
+ source_root, ref, current_branch=branch
417
+ )
418
+ console.print(
419
+ f"[cyan]Checking out {escape(ref)} from {old_sha or 'unknown'}…[/cyan]"
420
+ )
421
+ checkout_code = _run(checkout, console)
422
+ if checkout_code != 0:
423
+ console.print(
424
+ f"[red]Update failed while checking out {escape(ref)}.[/red] "
425
+ + "[yellow]Commit/stash local changes or verify the ref "
426
+ + "exists (try `git fetch --tags origin`), then try "
427
+ + "again.[/yellow]"
428
+ )
429
+ return checkout_code
430
+
431
+ new_sha = _get_short_sha(source_root)
432
+ if new_sha == old_sha:
433
+ console.print(
434
+ "[green]Already on "
435
+ + f"{escape(ref)} (commit {old_sha or 'unknown'}).[/green]"
436
+ )
437
+ return 0
438
+ console.print(
439
+ f"[dim]Updated {old_sha or 'unknown'} → "
440
+ + f"{new_sha or escape(ref)} (ref {escape(ref)}).[/dim]"
441
+ )
442
+ code = _run(build_source_install_command(source_root), console)
443
+ else:
444
+ behind = _commits_behind(source_root, branch)
445
+
446
+ if behind == 0:
447
+ console.print(
448
+ "[green]Already on the latest version "
449
+ + f"(commit {old_sha or 'unknown'}).[/green]"
450
+ )
451
+ return 0
452
+
453
+ if old_sha and behind and branch:
454
+ console.print(
455
+ f"[cyan]Updating from {old_sha} "
456
+ + f"({behind} commit(s) behind origin/{escape(branch)})…[/cyan]"
457
+ )
458
+ else:
459
+ console.print(
460
+ f"[cyan]Updating KiwiMateCoder from {old_sha or 'unknown'}…[/cyan]"
461
+ )
462
+
463
+ pull_code = _run(build_git_pull_command(source_root), console)
464
+ if pull_code != 0:
465
+ console.print(
466
+ "[red]Update failed while pulling from Git.[/red] "
467
+ + "[yellow]Commit/stash local changes or resolve Git errors, "
468
+ + "then try again.[/yellow]"
469
+ )
470
+ return pull_code
471
+
472
+ new_sha = _get_short_sha(source_root)
473
+ if new_sha == old_sha:
474
+ console.print(
475
+ "[green]Already on the latest version "
476
+ + f"(commit {old_sha or 'unknown'}).[/green]"
477
+ )
478
+ return 0
479
+
480
+ if old_sha and new_sha:
481
+ console.print(f"[dim]Updated {old_sha} → {new_sha}.[/dim]")
482
+
483
+ code = _run(build_source_install_command(source_root), console)
484
+ else:
485
+ code = _run(build_update_command(ref), console)
486
+ if code != 0:
487
+ target = f"git+{GITHUB_REPO_URL}"
488
+ if ref:
489
+ target = f"{target}@{ref}"
490
+ console.print(
491
+ f"[red]Update failed with exit code {code}.[/red] "
492
+ + "[yellow]KiwiMateCoder is not on PyPI; install from a source "
493
+ + "checkout or run: pip install --upgrade --force-reinstall "
494
+ + f"{escape(target)}[/yellow]"
495
+ )
496
+ return code
497
+
498
+ if code == 0:
499
+ console.print("[green]KiwiMateCoder update complete.[/green]")
500
+ else:
501
+ console.print(f"[red]Update failed with exit code {code}.[/red]")
502
+ return code