grokbot-kit 0.2.2__tar.gz → 0.3.0__tar.gz

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 (28) hide show
  1. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/PKG-INFO +1 -1
  2. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/pyproject.toml +1 -1
  3. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/pyproject.toml.orig +1 -1
  4. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/cli.py +17 -0
  5. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/doctor.py +9 -3
  6. grokbot_kit-0.3.0/src/grokbot_kit/update.py +320 -0
  7. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/LICENSE +0 -0
  8. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/README.md +0 -0
  9. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/__init__.py +0 -0
  10. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/assets/event.v1.json +0 -0
  11. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/assets/opencode/grokbot-kit.ts +0 -0
  12. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/config.py +0 -0
  13. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/deliver.py +0 -0
  14. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/emit.py +0 -0
  15. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/gc.py +0 -0
  16. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/launch.py +0 -0
  17. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/login.py +0 -0
  18. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/models.py +0 -0
  19. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/opencode_plugin.py +0 -0
  20. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/payload.py +0 -0
  21. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/redeliver.py +0 -0
  22. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/resolve.py +0 -0
  23. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/route.py +0 -0
  24. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/run.py +0 -0
  25. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/secret.py +0 -0
  26. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/setup.py +0 -0
  27. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/sinks.py +0 -0
  28. {grokbot_kit-0.2.2 → grokbot_kit-0.3.0}/src/grokbot_kit/terminal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grokbot-kit
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Grok Bot integration kit: delegate tasks to headless coding agents (opencode, Claude Code, Codex) and wake the right Grok Bot routine when they finish or need input.
5
5
  Author: Rodrigo Pasini
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "grokbot-kit"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Grok Bot integration kit: delegate tasks to headless coding agents (opencode, Claude Code, Codex) and wake the right Grok Bot routine when they finish or need input."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "grokbot-kit"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Grok Bot integration kit: delegate tasks to headless coding agents (opencode, Claude Code, Codex) and wake the right Grok Bot routine when they finish or need input."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -88,6 +88,15 @@ def build_parser() -> "argparse.ArgumentParser":
88
88
  uninstall = sub.add_parser("uninstall", help="remove the opencode plugin")
89
89
  uninstall.add_argument("target", choices=("opencode",))
90
90
 
91
+ update = sub.add_parser("update", help="check for a new grokbot-kit release and upgrade to it")
92
+ update_sub = update.add_subparsers(dest="update_command", required=True)
93
+ update_sub.add_parser("check", help="tell whether a new release exists").add_argument(
94
+ "--json", action="store_true", help="machine-readable output"
95
+ )
96
+ update_sub.add_parser("apply", help="upgrade with the tool that installed grokbot-kit and rewrite the plugin")
97
+ update_sub.add_parser("skip", help="do not ask again about this version").add_argument("version")
98
+ update_sub.add_parser("later", help="do not ask again for 24 hours")
99
+
91
100
  secret = sub.add_parser("secret", help="store secrets in ~/.secrets/grokbot-kit/ (mode 0600)")
92
101
  secret_sub = secret.add_subparsers(dest="secret_command", required=True)
93
102
  secret_sub.add_parser("set", help="read a secret from the terminal (no echo) or stdin").add_argument("name")
@@ -145,6 +154,14 @@ def main(argv: list[str] | None = None) -> int:
145
154
  from grokbot_kit import opencode_plugin
146
155
 
147
156
  return opencode_plugin.install() if args.command == "install" else opencode_plugin.uninstall()
157
+ if args.command == "update":
158
+ from grokbot_kit import update
159
+
160
+ if args.update_command == "check":
161
+ return update.check(args.json)
162
+ if args.update_command == "apply":
163
+ return update.apply()
164
+ return update.skip(args.version) if args.update_command == "skip" else update.later()
148
165
  if args.command == "secret":
149
166
  from grokbot_kit.secret import set_secret
150
167
 
@@ -1,4 +1,7 @@
1
- """`grokbot-kit doctor [--json]`: versões, CLIs, login, padrão, tiers, rotas e segredos (AC 100, door 21)."""
1
+ """`grokbot-kit doctor [--json]`: versões, CLIs, login, padrão, tiers, rotas e segredos (AC 100, door 21).
2
+
3
+ `kit.update` traz o objeto da checagem de release (kit-update, AC 34-36, door 4).
4
+ """
2
5
 
3
6
  import json
4
7
  import os
@@ -8,7 +11,7 @@ import subprocess
8
11
  import sys
9
12
  from typing import Any
10
13
 
11
- from grokbot_kit import config, launch, opencode_plugin
14
+ from grokbot_kit import config, launch, opencode_plugin, update
12
15
  from grokbot_kit.launch import TOOLS
13
16
 
14
17
  TIERS = ("trivial", "standard", "complex")
@@ -153,7 +156,7 @@ def collect() -> tuple[dict[str, Any], dict[str, Any]]:
153
156
  out = {
154
157
  "ok": True,
155
158
  "problems": [],
156
- "kit": {"version": __version__},
159
+ "kit": {"version": __version__, "update": update.status()},
157
160
  "python": {"version": platform.python_version(), "executable": sys.executable},
158
161
  "setup": {"installed": installed, "default_cli": default},
159
162
  "cli": {tool: _cli(tool, cfg.get("cli", {}).get(tool, {}), default) for tool in installed},
@@ -165,6 +168,9 @@ def collect() -> tuple[dict[str, Any], dict[str, Any]]:
165
168
 
166
169
  def _text(out: dict[str, Any]) -> str:
167
170
  lines = [f"grokbot-kit {out['kit']['version']} (Python {out['python']['version']}, {out['python']['executable']})"]
171
+ kit_update = out["kit"]["update"]
172
+ if kit_update["update_available"]:
173
+ lines.append(f"update: grokbot-kit {kit_update['latest']} available; run: grokbot-kit update apply")
168
174
  for tool, cli in out["cli"].items():
169
175
  login = cli["login"]
170
176
  state = {True: "logged in", False: "not logged in", None: "unknown"}[login["logged_in"]]
@@ -0,0 +1,320 @@
1
+ """`grokbot-kit update check|apply|skip|later`: a release nova no PyPI e o upgrade pelo instalador (kit-update).
2
+
3
+ A versão vem do PyPI (door 3) e fica em `~/.grokbot-kit/update.json` (door 2); a checagem vira o objeto da door 4,
4
+ que a skill e o `doctor` leem. O `apply` usa o instalador do recibo em `sys.prefix` (door 5) e regrava o plugin do
5
+ opencode com o executável novo.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import urllib.error
14
+ import urllib.request
15
+ from datetime import UTC, datetime, timedelta
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from grokbot_kit import config, launch, opencode_plugin
20
+ from grokbot_kit.payload import rfc3339
21
+
22
+ PYPI_URL = "https://pypi.org/pypi/grokbot-kit/json"
23
+ FETCH_TIMEOUT = 5
24
+ INSTALL_TIMEOUT = 300
25
+ PLUGIN_TIMEOUT = 60
26
+ FRESH_OK = timedelta(hours=24)
27
+ FRESH_ERROR = timedelta(hours=1)
28
+ LATER = timedelta(hours=24)
29
+ TASK_GRACE = timedelta(seconds=60)
30
+ STDERR_TAIL = 20
31
+ VERSION = re.compile(r"\d+\.\d+\.\d+")
32
+ UPGRADE_ARGV = {"uv": ["uv", "tool", "upgrade", "grokbot-kit"], "pipx": ["pipx", "upgrade", "grokbot-kit"]}
33
+ RECEIPTS = (("uv-receipt.toml", "uv"), ("pipx_metadata.json", "pipx"))
34
+ UNKNOWN_INSTALLER = "cannot tell how grokbot-kit was installed; upgrade it with the tool that installed it"
35
+
36
+
37
+ class FetchError(Exception):
38
+ pass
39
+
40
+
41
+ def state_path() -> Path:
42
+ return Path.home() / ".grokbot-kit" / "update.json"
43
+
44
+
45
+ def lock_path() -> Path:
46
+ return Path.home() / ".grokbot-kit" / "update.lock"
47
+
48
+
49
+ def _parse(value: Any) -> datetime | None:
50
+ try:
51
+ return datetime.fromisoformat(value) if isinstance(value, str) else None
52
+ except ValueError:
53
+ return None
54
+
55
+
56
+ def newer(a: str, b: str) -> bool:
57
+ """`a` > `b`, comparando os três números de `X.Y.Z`."""
58
+ return tuple(map(int, a.split("."))) > tuple(map(int, b.split(".")))
59
+
60
+
61
+ def ask(
62
+ update_available: bool, latest: str | None, skipped: str | None, later_until: str | None, now: datetime
63
+ ) -> bool:
64
+ """Perguntar ao usuário: há versão nova, ela não foi pulada e o adiamento venceu (door 4)."""
65
+ until = _parse(later_until)
66
+ return update_available and latest != skipped and (until is None or now >= until)
67
+
68
+
69
+ def installer(prefix: Path) -> str | None:
70
+ """O instalador do kit pelo recibo em `sys.prefix` (door 5)."""
71
+ for receipt, name in RECEIPTS:
72
+ if (prefix / receipt).exists():
73
+ return name
74
+ return None
75
+
76
+
77
+ def task_running(task_dir: Path, now: datetime) -> bool:
78
+ """Sem `finished_at` e ainda dentro de `started_at` + `timeout_seconds` + 60 s; na dúvida, parada."""
79
+ try:
80
+ meta = json.loads((task_dir / "meta.json").read_text(encoding="utf-8"))
81
+ if meta["finished_at"] is not None:
82
+ return False
83
+ deadline = datetime.fromisoformat(meta["started_at"]) + timedelta(seconds=meta["timeout_seconds"])
84
+ return bool(deadline + TASK_GRACE > now)
85
+ except (OSError, ValueError, KeyError, TypeError):
86
+ return False
87
+
88
+
89
+ def running_tasks(now: datetime) -> list[str]:
90
+ root = config.tasks_dir()
91
+ return [d.name for d in sorted(root.iterdir()) if d.is_dir() and task_running(d, now)] if root.is_dir() else []
92
+
93
+
94
+ def fetch(url: str) -> str:
95
+ """O `info.version` do PyPI (door 3); qualquer falha vira `FetchError` com o motivo."""
96
+ from grokbot_kit import __version__
97
+
98
+ request = urllib.request.Request(url, headers={"User-Agent": f"grokbot-kit/{__version__}"})
99
+ try:
100
+ with urllib.request.urlopen(request, timeout=FETCH_TIMEOUT) as response:
101
+ if response.status != 200:
102
+ raise FetchError(f"HTTP {response.status} from {url}")
103
+ data = json.loads(response.read())
104
+ except urllib.error.HTTPError as e:
105
+ raise FetchError(f"HTTP {e.code} from {url}") from None
106
+ except urllib.error.URLError as e:
107
+ raise FetchError(f"cannot reach {url}: {e.reason}") from None
108
+ except TimeoutError:
109
+ raise FetchError(f"{url} did not answer in {FETCH_TIMEOUT}s") from None
110
+ except OSError as e:
111
+ raise FetchError(f"cannot reach {url}: {e}") from None
112
+ except ValueError:
113
+ raise FetchError(f"{url} did not return JSON") from None
114
+ version = data.get("info", {}).get("version") if isinstance(data, dict) else None
115
+ if not isinstance(version, str) or not VERSION.fullmatch(version):
116
+ raise FetchError(f"{url} has no X.Y.Z info.version")
117
+ return version
118
+
119
+
120
+ def load_state() -> dict[str, Any] | None:
121
+ try:
122
+ state = json.loads(state_path().read_text(encoding="utf-8"))
123
+ except (OSError, ValueError):
124
+ return None
125
+ return state if isinstance(state, dict) else None
126
+
127
+
128
+ def save_state(state: dict[str, Any]) -> None:
129
+ path = state_path()
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ keys = ("checked_at", "latest", "error", "skipped", "later_until")
132
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
133
+ tmp.write_text(json.dumps({k: state.get(k) for k in keys}), encoding="utf-8")
134
+ os.replace(tmp, path)
135
+
136
+
137
+ def _fresh(state: dict[str, Any], now: datetime) -> bool:
138
+ checked = _parse(state.get("checked_at"))
139
+ return checked is not None and now - checked < (FRESH_ERROR if state.get("error") is not None else FRESH_OK)
140
+
141
+
142
+ def refresh(now: datetime, *, force: bool = False) -> dict[str, Any]:
143
+ """O estado da door 2, consultando o PyPI quando venceu (AC 1, 2 e 6) ou com `force`."""
144
+ state = load_state() or {}
145
+ if force or not _fresh(state, now):
146
+ try:
147
+ latest, error = fetch(os.environ.get("GROKBOT_KIT_PYPI_URL") or PYPI_URL), None
148
+ except FetchError as e:
149
+ latest, error = None, str(e)
150
+ state.update(checked_at=rfc3339(now), latest=latest, error=error)
151
+ save_state(state)
152
+ return state
153
+
154
+
155
+ def status_of(state: dict[str, Any], now: datetime) -> dict[str, Any]:
156
+ """O objeto da door 4."""
157
+ from grokbot_kit import __version__
158
+
159
+ latest = state.get("latest")
160
+ available = isinstance(latest, str) and bool(VERSION.fullmatch(latest)) and newer(latest, __version__)
161
+ return {
162
+ "installed": __version__,
163
+ "latest": latest,
164
+ "update_available": available,
165
+ "ask": ask(available, latest, state.get("skipped"), state.get("later_until"), now),
166
+ "skipped": state.get("skipped"),
167
+ "later_until": state.get("later_until"),
168
+ "checked_at": state.get("checked_at"),
169
+ "installer": installer(Path(sys.prefix)),
170
+ "error": state.get("error"),
171
+ }
172
+
173
+
174
+ def status() -> dict[str, Any]:
175
+ now = datetime.now(UTC)
176
+ return status_of(refresh(now), now)
177
+
178
+
179
+ def check(as_json: bool) -> int:
180
+ out = status()
181
+ if as_json:
182
+ print(json.dumps(out, ensure_ascii=False))
183
+ elif out["error"] is not None:
184
+ print(f"update check failed: {out['error']}")
185
+ elif out["update_available"]:
186
+ print(f"grokbot-kit {out['latest']} available (installed {out['installed']}); run: grokbot-kit update apply")
187
+ else:
188
+ print(f"grokbot-kit {out['installed']} is the latest")
189
+ return 0
190
+
191
+
192
+ def skip(version: str) -> int:
193
+ if not VERSION.fullmatch(version):
194
+ print(f"grokbot-kit update skip: {version!r} is not a X.Y.Z version", file=sys.stderr)
195
+ return 2
196
+ state = refresh(datetime.now(UTC))
197
+ state["skipped"] = version
198
+ save_state(state)
199
+ print(f"skipping grokbot-kit {version}")
200
+ return 0
201
+
202
+
203
+ def later() -> int:
204
+ now = datetime.now(UTC)
205
+ state = refresh(now)
206
+ state["later_until"] = rfc3339(now + LATER)
207
+ save_state(state)
208
+ print(f"asking again after {state['later_until']}")
209
+ return 0
210
+
211
+
212
+ def _pid_alive(pid: int) -> bool:
213
+ try:
214
+ os.kill(pid, 0)
215
+ except ProcessLookupError:
216
+ return False
217
+ except PermissionError:
218
+ return True
219
+ return True
220
+
221
+
222
+ def _take_lock() -> bool:
223
+ """O lock do `apply` (AC 26); um lock cujo pid não existe é tomado."""
224
+ path = lock_path()
225
+ path.parent.mkdir(parents=True, exist_ok=True)
226
+ for _ in range(2):
227
+ try:
228
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
229
+ except FileExistsError:
230
+ try:
231
+ pid = int(path.read_text(encoding="utf-8").strip())
232
+ except (OSError, ValueError):
233
+ pid = 0
234
+ if pid > 0 and _pid_alive(pid):
235
+ return False
236
+ path.unlink(missing_ok=True)
237
+ continue
238
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
239
+ f.write(str(os.getpid()))
240
+ return True
241
+ return False
242
+
243
+
244
+ def _fail(code: int, message: str) -> int:
245
+ print(message, file=sys.stderr)
246
+ return code
247
+
248
+
249
+ def apply() -> int:
250
+ if not _take_lock():
251
+ return _fail(2, f"another grokbot-kit update apply is running ({lock_path()})")
252
+ try:
253
+ return _apply()
254
+ finally:
255
+ lock_path().unlink(missing_ok=True)
256
+
257
+
258
+ def _apply() -> int:
259
+ now = datetime.now(UTC)
260
+ state = refresh(now, force=True)
261
+ out = status_of(state, now)
262
+ if out["error"] is not None:
263
+ return _fail(1, f"update check failed: {out['error']}")
264
+ if not out["update_available"]:
265
+ print(f"grokbot-kit {out['installed']} is the latest")
266
+ return 0
267
+ tool = out["installer"]
268
+ if tool is None:
269
+ return _fail(2, UNKNOWN_INSTALLER)
270
+ running = running_tasks(now)
271
+ if running:
272
+ return _fail(2, "tasks still running, try again when they end: " + ", ".join(running))
273
+ argv = UPGRADE_ARGV[tool]
274
+ try:
275
+ upgrade = subprocess.run(
276
+ argv, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=INSTALL_TIMEOUT, check=False
277
+ )
278
+ except subprocess.TimeoutExpired:
279
+ return _fail(1, f"{' '.join(argv)} did not finish in {INSTALL_TIMEOUT}s")
280
+ except OSError as e:
281
+ return _fail(1, f"cannot run {argv[0]}: {e.strerror}")
282
+ if upgrade.returncode != 0:
283
+ tail = "\n".join(upgrade.stderr.splitlines()[-STDERR_TAIL:])
284
+ return _fail(1, f"{' '.join(argv)} exited with {upgrade.returncode}\n{tail}".rstrip())
285
+ exe = launch.kit_executable()
286
+ new = _version_of(exe) or out["latest"]
287
+ if opencode_plugin.plugin_path().exists() and _call([exe, "install", "opencode"]) != 0:
288
+ return _fail(
289
+ 1, f"upgraded to {new}, but the opencode plugin was not rewritten; run: grokbot-kit install opencode"
290
+ )
291
+ state.update(skipped=None, later_until=None)
292
+ save_state(state)
293
+ print(f"grokbot-kit {out['installed']} -> {new}")
294
+ return 0
295
+
296
+
297
+ def _call(argv: list[str]) -> int | None:
298
+ try:
299
+ return subprocess.run(
300
+ argv, stdin=subprocess.DEVNULL, capture_output=True, timeout=PLUGIN_TIMEOUT, check=False
301
+ ).returncode
302
+ except (OSError, subprocess.TimeoutExpired):
303
+ return None
304
+
305
+
306
+ def _version_of(exe: str) -> str | None:
307
+ """A versão do executável novo (`grokbot-kit X.Y.Z`); o código já carregado ainda é o antigo."""
308
+ try:
309
+ proc = subprocess.run(
310
+ [exe, "--version"],
311
+ stdin=subprocess.DEVNULL,
312
+ capture_output=True,
313
+ text=True,
314
+ timeout=PLUGIN_TIMEOUT,
315
+ check=False,
316
+ )
317
+ except (OSError, subprocess.TimeoutExpired):
318
+ return None
319
+ words = proc.stdout.split()
320
+ return words[-1] if proc.returncode == 0 and words and VERSION.fullmatch(words[-1]) else None
File without changes
File without changes