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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"
|
kiwimatecoder/updater.py
ADDED
|
@@ -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
|