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