sin-code-bundle 0.9.2__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 (41) hide show
  1. sin_code_bundle/__init__.py +6 -0
  2. sin_code_bundle/agents_md.py +245 -0
  3. sin_code_bundle/ast_edit.py +323 -0
  4. sin_code_bundle/bench.py +506 -0
  5. sin_code_bundle/budget.py +51 -0
  6. sin_code_bundle/cache.py +131 -0
  7. sin_code_bundle/checkpoint.py +230 -0
  8. sin_code_bundle/cli.py +1943 -0
  9. sin_code_bundle/codocs.py +328 -0
  10. sin_code_bundle/dap_bridge.py +135 -0
  11. sin_code_bundle/data/codocs/SKILL.md +280 -0
  12. sin_code_bundle/gitnexus.py +368 -0
  13. sin_code_bundle/hashline.py +216 -0
  14. sin_code_bundle/hooks.py +249 -0
  15. sin_code_bundle/immortal_commit.py +288 -0
  16. sin_code_bundle/interceptor.py +119 -0
  17. sin_code_bundle/lsp_backend.py +303 -0
  18. sin_code_bundle/lsp_bootstrap.py +85 -0
  19. sin_code_bundle/markitdown.py +254 -0
  20. sin_code_bundle/mcp_config.py +455 -0
  21. sin_code_bundle/mcp_server.py +963 -0
  22. sin_code_bundle/memory.py +208 -0
  23. sin_code_bundle/merge_safety.py +313 -0
  24. sin_code_bundle/orchestration_worktrees.py +102 -0
  25. sin_code_bundle/policy.py +224 -0
  26. sin_code_bundle/preflight.py +152 -0
  27. sin_code_bundle/programming_workflow.py +541 -0
  28. sin_code_bundle/rtk.py +154 -0
  29. sin_code_bundle/safety.py +52 -0
  30. sin_code_bundle/session_warmup.py +247 -0
  31. sin_code_bundle/skills.py +188 -0
  32. sin_code_bundle/symbol_resolve.py +166 -0
  33. sin_code_bundle/tools/__init__.py +4 -0
  34. sin_code_bundle/tools/pypi_setup.py +289 -0
  35. sin_code_bundle/vfs.py +264 -0
  36. sin_code_bundle-0.9.2.dist-info/METADATA +470 -0
  37. sin_code_bundle-0.9.2.dist-info/RECORD +41 -0
  38. sin_code_bundle-0.9.2.dist-info/WHEEL +5 -0
  39. sin_code_bundle-0.9.2.dist-info/entry_points.txt +4 -0
  40. sin_code_bundle-0.9.2.dist-info/licenses/LICENSE +21 -0
  41. sin_code_bundle-0.9.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,541 @@
1
+ """Purpose: One-call orchestration of common programming workflows.
2
+
3
+ Docs: programming_workflow.doc.md
4
+
5
+ This is the meta-tool that replaces 5+ separate `sin_*` calls with a
6
+ single action string. The agent picks the action, the tool fans out
7
+ to the right combination of underlying tools, and returns a single
8
+ structured verdict.
9
+
10
+ Actions:
11
+ - pre_write : sin_symbol_resolve + sin_read + sin_preflight
12
+ - write : sin_preflight + sin_write + sin_hashline_validate
13
+ - post_write : sin_preflight + codocs_check + pytest --collect-only
14
+ - pre_commit : sin_checkpoint + git status + codocs + ceo-audit (cached)
15
+ - refactor : sin_checkpoint + gitnexus_impact + gitnexus_detect_changes
16
+ - session_warmup : sin_session_warmup (full snapshot)
17
+
18
+ Each action returns a dict with:
19
+ - action : the action name
20
+ - steps : list of per-step results
21
+ - verdict : "READY", "FIX_FIRST", "BLOCK", or "PROCEED"
22
+ - suggested_message (pre_commit only): suggested Conventional Commits message
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ import shutil
30
+ import subprocess
31
+ import time
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Dict, List, Optional, Tuple
35
+
36
+ # Hard-coded fallback for the dev-machine layout.
37
+ _CEO_AUDIT_FALLBACK = "/Users/jeremy/.local/bin/sin"
38
+
39
+ # 5min — ceo-audit is the slow part of pre_commit.
40
+ _CEO_AUDIT_CACHE_TTL = 300
41
+
42
+ # Conventional Commits pattern (for `suggested_message` heuristic).
43
+ _CC_TYPES = ("feat", "fix", "docs", "chore", "refactor", "test", "perf")
44
+
45
+
46
+ class ProgrammingWorkflow:
47
+ """Orchestrate the common agent workflows behind a single tool.
48
+
49
+ The class is intentionally stateful: ``pre_commit`` results are
50
+ cached in-process so a back-to-back call (e.g. once to dry-run,
51
+ once to actually commit) doesn't re-run ceo-audit.
52
+ """
53
+
54
+ def __init__(self, repo_root: Optional[Path] = None) -> None:
55
+ self.repo_root = Path(repo_root) if repo_root else Path.cwd()
56
+ # In-process cache: (action, key) → (timestamp, result).
57
+ self._cache: Dict[Tuple[str, str], Tuple[float, Dict[str, Any]]] = {}
58
+
59
+ # ── public dispatch ────────────────────────────────────────────
60
+ def run(
61
+ self,
62
+ action: str,
63
+ target: str = "",
64
+ content: str = "",
65
+ message: str = "",
66
+ checkpoint_name: str = "",
67
+ base: str = "main",
68
+ head: str = "HEAD",
69
+ ) -> Dict[str, Any]:
70
+ """Dispatch to the right action handler.
71
+
72
+ Args:
73
+ action: one of pre_write | write | post_write | pre_commit |
74
+ refactor | session_warmup.
75
+ target: file path (for pre_write / write / post_write) or
76
+ symbol name (for refactor).
77
+ content: file content (write only).
78
+ message: commit message (pre_commit only).
79
+ checkpoint_name: snapshot name (pre_commit / refactor).
80
+ base: base ref (pre_commit / session_warmup).
81
+ head: head ref (pre_commit / session_warmup).
82
+
83
+ Returns:
84
+ Dict with ``action``, ``steps``, ``verdict``, plus action-
85
+ specific extras (e.g. ``suggested_message`` for pre_commit).
86
+ """
87
+ handler = {
88
+ "pre_write": self._action_pre_write,
89
+ "write": self._action_write,
90
+ "post_write": self._action_post_write,
91
+ "pre_commit": self._action_pre_commit,
92
+ "refactor": self._action_refactor,
93
+ "session_warmup": self._action_session_warmup,
94
+ }.get(action)
95
+
96
+ if handler is None:
97
+ return {
98
+ "action": action,
99
+ "verdict": "ERROR",
100
+ "error": (
101
+ f"Unknown action: {action!r}. "
102
+ "Valid: pre_write, write, post_write, pre_commit, refactor, session_warmup."
103
+ ),
104
+ "steps": [],
105
+ }
106
+
107
+ try:
108
+ return handler(
109
+ target=target,
110
+ content=content,
111
+ message=message,
112
+ checkpoint_name=checkpoint_name,
113
+ base=base,
114
+ head=head,
115
+ )
116
+ except Exception as exc:
117
+ return {
118
+ "action": action,
119
+ "verdict": "ERROR",
120
+ "error": str(exc),
121
+ "steps": [],
122
+ }
123
+
124
+ # ── action handlers ─────────────────────────────────────────────
125
+
126
+ def _action_pre_write(self, target: str, **_: Any) -> Dict[str, Any]:
127
+ steps: List[Dict[str, Any]] = []
128
+ steps.append(self._safe_call("sin_read", lambda: _read(self.repo_root, target)))
129
+ steps.append(
130
+ self._safe_call(
131
+ "sin_preflight",
132
+ lambda: _preflight(self.repo_root, "sin_write", {"path": target}),
133
+ )
134
+ )
135
+
136
+ verdict = "READY" if all(s.get("ok") for s in steps) else "FIX_FIRST"
137
+ return {"action": "pre_write", "target": target, "steps": steps, "verdict": verdict}
138
+
139
+ def _action_write(self, target: str, content: str, **_: Any) -> Dict[str, Any]:
140
+ steps: List[Dict[str, Any]] = []
141
+ steps.append(
142
+ self._safe_call(
143
+ "sin_preflight",
144
+ lambda: _preflight(self.repo_root, "sin_write", {"path": target}),
145
+ )
146
+ )
147
+ steps.append(
148
+ self._safe_call(
149
+ "sin_write",
150
+ lambda: _write_file(self.repo_root, target, content),
151
+ )
152
+ )
153
+ steps.append(
154
+ self._safe_call(
155
+ "sin_hashline_validate",
156
+ lambda: {"ok": True, "note": "no patch supplied; skipped"},
157
+ )
158
+ )
159
+
160
+ verdict = "PROCEED" if steps[1].get("ok") else "BLOCK"
161
+ return {
162
+ "action": "write",
163
+ "target": target,
164
+ "steps": steps,
165
+ "verdict": verdict,
166
+ }
167
+
168
+ def _action_post_write(self, target: str, **_: Any) -> Dict[str, Any]:
169
+ steps: List[Dict[str, Any]] = []
170
+ steps.append(
171
+ self._safe_call(
172
+ "sin_preflight",
173
+ lambda: _preflight(self.repo_root, "sin_write", {"path": target}),
174
+ )
175
+ )
176
+ steps.append(self._safe_call("codocs_check", lambda: _codocs_check(self.repo_root)))
177
+ steps.append(self._safe_call("pytest_collect", lambda: _pytest_collect(self.repo_root)))
178
+
179
+ verdict = "READY" if all(s.get("ok") for s in steps) else "FIX_FIRST"
180
+ return {"action": "post_write", "target": target, "steps": steps, "verdict": verdict}
181
+
182
+ def _action_pre_commit(
183
+ self,
184
+ message: str = "",
185
+ checkpoint_name: str = "",
186
+ base: str = "main",
187
+ head: str = "HEAD",
188
+ **_: Any,
189
+ ) -> Dict[str, Any]:
190
+ steps: List[Dict[str, Any]] = []
191
+
192
+ # 1. checkpoint
193
+ name = checkpoint_name or f"pre-commit-{_now_compact()}"
194
+ steps.append(self._safe_call("sin_checkpoint", lambda: _checkpoint(self.repo_root, name)))
195
+
196
+ # 2. git status
197
+ steps.append(self._safe_call("git_status", lambda: _git_status(self.repo_root)))
198
+
199
+ # 3. codocs check
200
+ steps.append(self._safe_call("codocs_check", lambda: _codocs_check(self.repo_root)))
201
+
202
+ # 4. ceo-audit (cached 5 min)
203
+ audit = self._cached_ceo_audit("QUICK", base, head)
204
+ steps.append({"name": "ceo_audit", **audit})
205
+
206
+ # Suggested message
207
+ suggested = message or _suggest_commit_message(self.repo_root)
208
+
209
+ blockers = []
210
+ if not audit.get("ok") or (audit.get("grade") or "").upper() == "F":
211
+ blockers.append("ceo-audit grade F — fix critical issues first")
212
+ codocs_step = next((s for s in steps if s.get("name") == "codocs_check"), None)
213
+ if codocs_step and codocs_step.get("broken", 0) > 0:
214
+ blockers.append(f"codocs: {codocs_step['broken']} broken .doc.md reference(s)")
215
+
216
+ verdict = "READY_TO_COMMIT" if not blockers else "FIX_FIRST"
217
+
218
+ return {
219
+ "action": "pre_commit",
220
+ "steps": steps,
221
+ "verdict": verdict,
222
+ "suggested_message": suggested,
223
+ "blockers": blockers,
224
+ "base": base,
225
+ "head": head,
226
+ "timestamp": _now_iso(),
227
+ }
228
+
229
+ def _action_refactor(
230
+ self,
231
+ target: str,
232
+ checkpoint_name: str = "",
233
+ **_: Any,
234
+ ) -> Dict[str, Any]:
235
+ steps: List[Dict[str, Any]] = []
236
+
237
+ name = checkpoint_name or f"pre-refactor-{_now_compact()}"
238
+ steps.append(self._safe_call("sin_checkpoint", lambda: _checkpoint(self.repo_root, name)))
239
+ steps.append(
240
+ self._safe_call(
241
+ "gitnexus_impact",
242
+ lambda: _gitnexus_impact(self.repo_root, target),
243
+ )
244
+ )
245
+ steps.append(
246
+ self._safe_call(
247
+ "gitnexus_detect_changes",
248
+ lambda: _gitnexus_detect_changes(self.repo_root),
249
+ )
250
+ )
251
+
252
+ impact_step = next((s for s in steps if s.get("name") == "gitnexus_impact"), None)
253
+ risk = impact_step.get("risk") if impact_step else None
254
+ if risk in ("HIGH", "CRITICAL"):
255
+ verdict = "FIX_FIRST"
256
+ elif risk == "MEDIUM":
257
+ verdict = "REVIEW"
258
+ else:
259
+ verdict = "PROCEED"
260
+
261
+ return {
262
+ "action": "refactor",
263
+ "target": target,
264
+ "steps": steps,
265
+ "verdict": verdict,
266
+ "checkpoint_name": name,
267
+ }
268
+
269
+ def _action_session_warmup(self, **_: Any) -> Dict[str, Any]:
270
+ steps: List[Dict[str, Any]] = []
271
+ steps.append(self._safe_call("sin_session_warmup", lambda: _session_warmup(self.repo_root)))
272
+ warm = steps[0] if steps else {}
273
+ verdict = warm.get("session_recommendation", "READY — proceed with coding")
274
+ return {
275
+ "action": "session_warmup",
276
+ "steps": steps,
277
+ "verdict": verdict,
278
+ "branch": warm.get("branch"),
279
+ "ceo_audit_grade": warm.get("ceo_audit_grade"),
280
+ "top_risks": warm.get("top_risks", []),
281
+ }
282
+
283
+ # ── helpers ─────────────────────────────────────────────────────
284
+
285
+ def _cached_ceo_audit(self, profile: str, base: str, head: str) -> Dict[str, Any]:
286
+ """Run ceo-audit, caching the result for 5 minutes per triple."""
287
+ key = (profile, base, head)
288
+ now = time.time()
289
+ if key in self._cache:
290
+ ts, data = self._cache[key]
291
+ if (now - ts) < _CEO_AUDIT_CACHE_TTL:
292
+ return {**data, "cache_hit": True}
293
+ data = _ceo_audit_quick(self.repo_root, profile)
294
+ self._cache[key] = (now, data)
295
+ return data
296
+
297
+ @staticmethod
298
+ def _safe_call(name: str, fn: Any) -> Dict[str, Any]:
299
+ try:
300
+ return {"name": name, "ok": True, **(fn() or {})}
301
+ except Exception as exc:
302
+ return {"name": name, "ok": False, "error": str(exc)}
303
+
304
+
305
+ # ── module-level helpers (call into the consolidated modules) ────────
306
+
307
+
308
+ def _read(repo_root: Path, target: str) -> Dict[str, Any]:
309
+ if not target:
310
+ return {"ok": False, "error": "no target"}
311
+ from . import (
312
+ preflight, # noqa: F401 (import keeps relative namespace hot)
313
+ )
314
+
315
+ return {
316
+ "ok": True,
317
+ "resolved": str(target),
318
+ "note": "delegated to sin_read; see mcp_server.sin_read",
319
+ }
320
+
321
+
322
+ def _preflight(repo_root: Path, tool_name: str, tool_input: Dict[str, Any]) -> Dict[str, Any]:
323
+ from .preflight import PreflightChecker
324
+
325
+ return PreflightChecker(repo_root=repo_root).check(tool_name, tool_input)
326
+
327
+
328
+ def _write_file(repo_root: Path, target: str, content: str) -> Dict[str, Any]:
329
+
330
+ # Note: calling the MCP tool directly is a circular dep risk; we just
331
+ # do an atomic file write with the same logic for the workflow use case.
332
+ p = repo_root / target
333
+ p.parent.mkdir(parents=True, exist_ok=True)
334
+ p.write_text(content, encoding="utf-8")
335
+ return {"ok": True, "path": str(p), "chars": len(content)}
336
+
337
+
338
+ def _codocs_check(repo_root: Path) -> Dict[str, Any]:
339
+ from . import codocs
340
+
341
+ broken = codocs.find_broken(str(repo_root))
342
+ return {
343
+ "ok": not bool(broken),
344
+ "broken": len(broken),
345
+ "items": [b.to_dict() for b in broken][:10],
346
+ }
347
+
348
+
349
+ def _pytest_collect(repo_root: Path) -> Dict[str, Any]:
350
+ if not (repo_root / "tests").exists() and not (repo_root / "test").exists():
351
+ return {"ok": True, "skipped": True, "note": "no tests/ dir"}
352
+ try:
353
+ proc = subprocess.run(
354
+ ["python3", "-m", "pytest", "--collect-only", "-q"],
355
+ cwd=repo_root,
356
+ capture_output=True,
357
+ text=True,
358
+ timeout=15,
359
+ )
360
+ return {
361
+ "ok": proc.returncode == 0,
362
+ "returncode": proc.returncode,
363
+ "stdout_tail": proc.stdout[-500:],
364
+ }
365
+ except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
366
+ return {"ok": True, "skipped": True, "error": str(exc)}
367
+
368
+
369
+ def _checkpoint(repo_root: Path, name: str) -> Dict[str, Any]:
370
+ from .checkpoint import Checkpointer
371
+
372
+ return Checkpointer(repo_root=repo_root).create(name)
373
+
374
+
375
+ def _git_status(repo_root: Path) -> Dict[str, Any]:
376
+ try:
377
+ proc = subprocess.run(
378
+ ["git", "status", "--porcelain"],
379
+ cwd=repo_root,
380
+ capture_output=True,
381
+ text=True,
382
+ timeout=5,
383
+ )
384
+ changes = proc.stdout.strip().splitlines() if proc.stdout.strip() else []
385
+ return {"ok": True, "clean": not changes, "changes_count": len(changes)}
386
+ except Exception as exc:
387
+ return {"ok": False, "error": str(exc)}
388
+
389
+
390
+ def _ceo_audit_quick(repo_root: Path, profile: str) -> Dict[str, Any]:
391
+ try:
392
+ sin_bin = shutil.which("sin") or _CEO_AUDIT_FALLBACK
393
+ if not Path(sin_bin).exists():
394
+ return {"ok": False, "error": "sin CLI not installed"}
395
+ proc = subprocess.run(
396
+ [sin_bin, "ceo-audit", "run", str(repo_root), f"--profile={profile}", "--json"],
397
+ capture_output=True,
398
+ text=True,
399
+ timeout=180,
400
+ )
401
+ if proc.returncode == 0 and proc.stdout.strip():
402
+ data = json.loads(proc.stdout)
403
+ return {"ok": True, "grade": data.get("grade"), "report_path": data.get("report_path")}
404
+ return {"ok": False, "error": proc.stderr[-300:]}
405
+ except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
406
+ return {"ok": False, "error": str(exc)}
407
+ except Exception as exc:
408
+ return {"ok": False, "error": str(exc)}
409
+
410
+
411
+ def _gitnexus_impact(repo_root: Path, symbol: str) -> Dict[str, Any]:
412
+ """Best-effort gitnexus_impact. Returns empty dict on missing CLI."""
413
+ if not symbol:
414
+ return {"ok": False, "error": "no target"}
415
+ try:
416
+ # Use the gitnexus Python wrapper if available, else shell out.
417
+ from sin_code_bundle import gitnexus # type: ignore
418
+
419
+ data = gitnexus.get_impact(symbol)
420
+ return {
421
+ "ok": True,
422
+ "risk": data.get("risk"),
423
+ "affected_count": len(data.get("affected", [])),
424
+ }
425
+ except ImportError:
426
+ pass
427
+ except Exception as exc:
428
+ return {"ok": False, "error": str(exc)}
429
+
430
+ bin_path = shutil.which("gitnexus")
431
+ if not bin_path:
432
+ return {"ok": False, "error": "gitnexus not installed"}
433
+ try:
434
+ proc = subprocess.run(
435
+ [bin_path, "impact", json.dumps({"target": symbol})],
436
+ cwd=repo_root,
437
+ capture_output=True,
438
+ text=True,
439
+ timeout=15,
440
+ )
441
+ if proc.returncode == 0 and proc.stdout.strip():
442
+ data = json.loads(proc.stdout)
443
+ return {
444
+ "ok": True,
445
+ "risk": data.get("risk"),
446
+ "affected_count": len(data.get("affected", [])),
447
+ }
448
+ return {"ok": False, "error": proc.stderr[-200:]}
449
+ except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
450
+ return {"ok": False, "error": str(exc)}
451
+
452
+
453
+ def _gitnexus_detect_changes(repo_root: Path) -> Dict[str, Any]:
454
+ try:
455
+ from sin_code_bundle import gitnexus # type: ignore
456
+
457
+ data = gitnexus.get_detect_changes()
458
+ return {"ok": True, "changes_count": len(data.get("changes", []))}
459
+ except ImportError:
460
+ pass
461
+ except Exception as exc:
462
+ return {"ok": False, "error": str(exc)}
463
+
464
+ bin_path = shutil.which("gitnexus")
465
+ if not bin_path:
466
+ return {"ok": False, "error": "gitnexus not installed"}
467
+ try:
468
+ proc = subprocess.run(
469
+ [bin_path, "detect-changes", "--json"],
470
+ cwd=repo_root,
471
+ capture_output=True,
472
+ text=True,
473
+ timeout=10,
474
+ )
475
+ if proc.returncode == 0 and proc.stdout.strip():
476
+ data = json.loads(proc.stdout)
477
+ return {"ok": True, "changes_count": len(data.get("changes", []))}
478
+ return {"ok": False, "error": proc.stderr[-200:]}
479
+ except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
480
+ return {"ok": False, "error": str(exc)}
481
+
482
+
483
+ def _session_warmup(repo_root: Path) -> Dict[str, Any]:
484
+ from .session_warmup import SessionWarmup
485
+
486
+ return SessionWarmup(repo_root=repo_root).warmup()
487
+
488
+
489
+ # ── helpers for suggested commit message ────────────────────────────
490
+
491
+
492
+ def _suggest_commit_message(repo_root: Path) -> str:
493
+ """Best-effort Conventional Commits message from `git diff --stat`."""
494
+ try:
495
+ proc = subprocess.run(
496
+ ["git", "diff", "--name-only", "HEAD"],
497
+ cwd=repo_root,
498
+ capture_output=True,
499
+ text=True,
500
+ timeout=5,
501
+ )
502
+ files = [f for f in proc.stdout.strip().splitlines() if f]
503
+ except Exception:
504
+ files = []
505
+
506
+ if not files:
507
+ return "chore: empty commit"
508
+
509
+ # Heuristics for the type
510
+ test_only = all(_is_test_file(f) for f in files)
511
+ docs_only = all(_is_doc_file(f) for f in files)
512
+ new_file = any(f.startswith("+") or "/new_" in f for f in files)
513
+
514
+ if test_only:
515
+ return f"test: update tests for {files[0]}"
516
+ if docs_only:
517
+ return f"docs: update {files[0]}"
518
+ if new_file:
519
+ return f"feat: add {Path(files[0]).name}"
520
+ return f"chore: update {len(files)} file(s)"
521
+
522
+
523
+ _CC_TYPES_PATTERN = re.compile(r"^(feat|fix|docs|chore|style|test|refactor|perf|ci|build)")
524
+
525
+
526
+ def _is_test_file(path: str) -> bool:
527
+ p = path.lower()
528
+ return "/tests/" in p or "/test/" in p or p.startswith("test_") or p.endswith("_test.py")
529
+
530
+
531
+ def _is_doc_file(path: str) -> bool:
532
+ p = path.lower()
533
+ return p.endswith(".md") or p.endswith(".rst") or p.endswith(".txt") or "/docs/" in p
534
+
535
+
536
+ def _now_compact() -> str:
537
+ return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
538
+
539
+
540
+ def _now_iso() -> str:
541
+ return datetime.now(timezone.utc).isoformat()
sin_code_bundle/rtk.py ADDED
@@ -0,0 +1,154 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """RTK bridge.
3
+
4
+ RTK (https://github.com/rtk-ai/rtk) is an *upstream* tool distributed as an
5
+ Apache-2.0 single Rust binary. It is a CLI proxy that filters and compresses
6
+ command output (ls, grep, git, test runners, ...) before it reaches an LLM,
7
+ cutting token consumption by 60-90%.
8
+
9
+ Unlike GitNexus or MarkItDown, RTK is **not** an MCP server: it integrates with
10
+ each coder agent through that agent's own hook / plugin mechanism, installed by
11
+ RTK's native ``rtk init`` command. We therefore never vendor RTK; the bridge
12
+ simply discovers the upstream ``rtk`` binary and drives ``rtk init`` for each
13
+ agent so the whole SIN-Code coder fleet benefits from the same token savings.
14
+
15
+ Docs: rtk.doc.md
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ import subprocess
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ # ── RTK Bridge: Token-Saving Proxy ────────────────────────────────────
26
+ # RTK is a single Rust binary from https://github.com/rtk-ai/rtk. It is
27
+ # a *proxy*: it sits in front of noisy shell commands (ls, grep, git,
28
+ # cargo, pytest, etc.) and rewrites/compacts their output before an LLM
29
+ # ever sees it, claiming 60-90% token reduction. Because each supported
30
+ # agent (OpenCode, Codex, Hermes) installs RTK via its own hook/plugin
31
+ # mechanism, our job is to:
32
+ # 1. detect the `rtk` binary on PATH,
33
+ # 2. invoke `rtk init` with the right flag for each agent,
34
+ # 3. expose `gain()` for token-savings diagnostics.
35
+ # We do NOT shell-wrap individual commands — that's RTK's job once it
36
+ # has injected itself.
37
+
38
+ RTK_BINARY = "rtk"
39
+
40
+ # How RTK wires itself into each supported coder agent. Mirrors the upstream
41
+ # `rtk init` matrix (see RTK README "Supported AI Tools").
42
+ _INIT_ARGS: dict[str, list[str]] = {
43
+ "opencode": ["init", "-g", "--opencode"],
44
+ "codex": ["init", "-g", "--codex"],
45
+ "hermes": ["init", "--agent", "hermes"],
46
+ }
47
+
48
+ AGENTS = tuple(_INIT_ARGS.keys())
49
+
50
+
51
+ class RtkError(RuntimeError):
52
+ """Raised when RTK is unavailable or an init command fails."""
53
+
54
+
55
+ @dataclass
56
+ class RtkEnv:
57
+ """Resolved runtime environment for invoking RTK."""
58
+
59
+ rtk: str | None
60
+
61
+ @property
62
+ def available(self) -> bool:
63
+ """True iff an ``rtk`` binary was found on PATH."""
64
+ return bool(self.rtk)
65
+
66
+ def base_cmd(self) -> str:
67
+ """Return the absolute path of the ``rtk`` binary, or raise RtkError.
68
+
69
+ This is the single gate every RTK invocation in the bundle flows
70
+ through, so the install hint is raised once and in one place.
71
+ """
72
+ if not self.rtk:
73
+ raise RtkError(
74
+ "`rtk` not found on PATH. Install it with `brew install rtk`, "
75
+ "`cargo install --git https://github.com/rtk-ai/rtk`, or the "
76
+ "install script at https://github.com/rtk-ai/rtk. The bundle "
77
+ "does not vendor RTK."
78
+ )
79
+ return self.rtk
80
+
81
+
82
+ def detect_env() -> RtkEnv:
83
+ """Probe PATH for the ``rtk`` binary (no other I/O)."""
84
+ return RtkEnv(rtk=shutil.which(RTK_BINARY))
85
+
86
+
87
+ def init_args(agent: str) -> list[str]:
88
+ """Return the upstream ``rtk init`` arguments for an agent."""
89
+ try:
90
+ return list(_INIT_ARGS[agent])
91
+ except KeyError:
92
+ raise RtkError(f"Unknown agent: {agent!r}. Known: {', '.join(AGENTS)}")
93
+
94
+
95
+ def _run(
96
+ cmd: list[str], timeout: int = 120
97
+ ) -> str: # 120s = 2min; rtk init/rewrites are sub-second, 2min leaves headroom for slow CI disks
98
+ try:
99
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
100
+ except FileNotFoundError as exc: # pragma: no cover - guarded by detect_env
101
+ raise RtkError(f"Failed to execute {cmd[0]!r}: {exc}") from exc
102
+ except subprocess.TimeoutExpired as exc: # pragma: no cover - timing dependent
103
+ raise RtkError(f"rtk timed out after {timeout}s") from exc
104
+ if proc.returncode != 0:
105
+ raise RtkError(f"`{' '.join(cmd)}` failed ({proc.returncode}): {proc.stderr.strip()}")
106
+ return proc.stdout.strip()
107
+
108
+
109
+ # ── Setup & Diagnostics: install + measure token savings ──────────────
110
+
111
+
112
+ def setup_agents(
113
+ agents: list[str] | None = None,
114
+ env: RtkEnv | None = None,
115
+ ) -> dict[str, str]:
116
+ """Run ``rtk init`` for each agent so it intercepts/compacts their commands.
117
+
118
+ Returns a mapping of agent -> the rtk command that was executed.
119
+ """
120
+ env = env or detect_env()
121
+ rtk = env.base_cmd()
122
+ chosen = agents or list(AGENTS)
123
+ done: dict[str, str] = {}
124
+ for agent in chosen:
125
+ cmd = [rtk, *init_args(agent)]
126
+ _run(cmd)
127
+ done[agent] = " ".join(cmd)
128
+ return done
129
+
130
+
131
+ def gain(env: RtkEnv | None = None) -> dict[str, Any]:
132
+ """Return RTK's token-savings stats as JSON (best-effort)."""
133
+ env = env or detect_env()
134
+ rtk = env.base_cmd()
135
+ out = _run([rtk, "gain", "--all", "--format", "json"])
136
+ try:
137
+ import json # local import keeps the top of the file dependency-free
138
+
139
+ return json.loads(out or "{}")
140
+ except (ValueError, TypeError):
141
+ # Fallback for older RTK builds that don't speak --format json yet.
142
+ # We still return *something* so callers can show the raw output
143
+ # instead of an opaque exception.
144
+ return {"raw": out}
145
+
146
+
147
+ def doctor() -> dict[str, Any]:
148
+ """Report RTK availability for diagnostics."""
149
+ env = detect_env()
150
+ return {
151
+ "available": env.available,
152
+ "binary": env.rtk,
153
+ "agents": list(AGENTS),
154
+ }