boomtick-cli 0.2.1__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 (103) hide show
  1. boomtick_cli-0.2.1.dist-info/METADATA +20 -0
  2. boomtick_cli-0.2.1.dist-info/RECORD +103 -0
  3. boomtick_cli-0.2.1.dist-info/WHEEL +5 -0
  4. boomtick_cli-0.2.1.dist-info/entry_points.txt +3 -0
  5. boomtick_cli-0.2.1.dist-info/top_level.txt +1 -0
  6. dev_tools/__init__.py +1 -0
  7. dev_tools/cli-schema.json +2028 -0
  8. dev_tools/cli.py +1542 -0
  9. dev_tools/config.py +235 -0
  10. dev_tools/constants.py +14 -0
  11. dev_tools/daemon.py +146 -0
  12. dev_tools/dist/config.js +108 -0
  13. dev_tools/dist/index.js +10 -0
  14. dev_tools/dist/lib/error_utils.js +8 -0
  15. dev_tools/dist/lib/git.js +31 -0
  16. dev_tools/dist/lib/result.js +21 -0
  17. dev_tools/dist/lib/shell.js +91 -0
  18. dev_tools/dist/lib/shell.test.js +10 -0
  19. dev_tools/dist/lib/td-cli.js +27 -0
  20. dev_tools/dist/lib/test-utils.js +20 -0
  21. dev_tools/dist/mcp/definitions.js +242 -0
  22. dev_tools/dist/mcp/server.js +317 -0
  23. dev_tools/dist/mcp/tools.js +18 -0
  24. dev_tools/dist/tools/contract.js +2069 -0
  25. dev_tools/dist/tools/ddgs.search.js +50 -0
  26. dev_tools/dist/tools/ddgs.search.test.js +67 -0
  27. dev_tools/dist/tools/ddgs_search.py +23 -0
  28. dev_tools/dist/tools/github.checkout_branch.js +20 -0
  29. dev_tools/dist/tools/github.comment_triage_summary.js +25 -0
  30. dev_tools/dist/tools/github.create_issue.js +28 -0
  31. dev_tools/dist/tools/github.create_issue.test.js +54 -0
  32. dev_tools/dist/tools/github.create_pull_request.js +44 -0
  33. dev_tools/dist/tools/github.get_merge_conflict_files.js +26 -0
  34. dev_tools/dist/tools/github.get_pr.js +18 -0
  35. dev_tools/dist/tools/github.get_pr_diff.js +24 -0
  36. dev_tools/dist/tools/github.issue_comment.js +24 -0
  37. dev_tools/dist/tools/github.issue_update.js +29 -0
  38. dev_tools/dist/tools/github.issue_view.js +28 -0
  39. dev_tools/dist/tools/github.open_replacement_pr.js +41 -0
  40. dev_tools/dist/tools/github.search_open_prs.js +31 -0
  41. dev_tools/dist/tools/github.search_open_prs.test.js +51 -0
  42. dev_tools/dist/tools/jules/cancel-session.js +20 -0
  43. dev_tools/dist/tools/jules/create-session.js +20 -0
  44. dev_tools/dist/tools/jules/create-session.test.js +99 -0
  45. dev_tools/dist/tools/jules/get-messages.js +19 -0
  46. dev_tools/dist/tools/jules/get-messages.test.js +45 -0
  47. dev_tools/dist/tools/jules/get-pr.js +14 -0
  48. dev_tools/dist/tools/jules/get-pr.test.js +37 -0
  49. dev_tools/dist/tools/jules/get-session.js +10 -0
  50. dev_tools/dist/tools/jules/list-sessions.js +43 -0
  51. dev_tools/dist/tools/jules/send-message.js +22 -0
  52. dev_tools/dist/tools/jules/send-message.test.js +56 -0
  53. dev_tools/dist/tools/jules/shared.js +26 -0
  54. dev_tools/dist/tools/jules/trigger-feedback.js +16 -0
  55. dev_tools/dist/tools/jules/trigger-feedback.test.js +42 -0
  56. dev_tools/dist/tools/repo.commit_patch.js +33 -0
  57. dev_tools/dist/tools/repo.create_branch.js +21 -0
  58. dev_tools/dist/tools/repo.create_branch.test.js +42 -0
  59. dev_tools/dist/tools/repo.create_repair_branch.js +38 -0
  60. dev_tools/dist/tools/repo.get_changed_files.js +21 -0
  61. dev_tools/dist/tools/repo.get_command_schema.js +27 -0
  62. dev_tools/dist/tools/repo.get_package_scripts.js +35 -0
  63. dev_tools/dist/tools/repo.get_package_scripts.test.js +39 -0
  64. dev_tools/dist/tools/repo.get_route_map.js +37 -0
  65. dev_tools/dist/tools/repo.logs.js +16 -0
  66. dev_tools/dist/tools/repo.read_agent_context.js +20 -0
  67. dev_tools/dist/tools/repo.read_ci_logs.js +18 -0
  68. dev_tools/dist/tools/repo.run_lighthouse.js +37 -0
  69. dev_tools/dist/tools/repo.run_playwright.js +29 -0
  70. dev_tools/dist/tools/repo.run_tests.js +43 -0
  71. dev_tools/dist/tools/types.js +1 -0
  72. dev_tools/get_ai_context.py +63 -0
  73. dev_tools/handlers/__init__.py +1 -0
  74. dev_tools/handlers/command_handler.py +69 -0
  75. dev_tools/mcp_server.py +36 -0
  76. dev_tools/models.py +354 -0
  77. dev_tools/orchestrator.py +2841 -0
  78. dev_tools/pr_overlap.py +139 -0
  79. dev_tools/resources/__init__.py +1 -0
  80. dev_tools/resources/build-repo-context.py +182 -0
  81. dev_tools/resources/prompt_constants.json +5 -0
  82. dev_tools/resources/review_template.md +41 -0
  83. dev_tools/resources/ux-audit.config.json +15 -0
  84. dev_tools/resources/visual_guidelines.json +3 -0
  85. dev_tools/review_read_pass.py +232 -0
  86. dev_tools/schema_gen.py +55 -0
  87. dev_tools/schema_utils.py +125 -0
  88. dev_tools/scope_check.py +85 -0
  89. dev_tools/services/__init__.py +1 -0
  90. dev_tools/services/ai_service.py +816 -0
  91. dev_tools/services/dependency_graph.py +123 -0
  92. dev_tools/services/github.py +783 -0
  93. dev_tools/services/jules.py +181 -0
  94. dev_tools/services/repair_service.py +199 -0
  95. dev_tools/services/vector_store.py +82 -0
  96. dev_tools/services/vision_service.py +91 -0
  97. dev_tools/td_cli.py +28 -0
  98. dev_tools/utils/__init__.py +1035 -0
  99. dev_tools/utils/git.py +68 -0
  100. dev_tools/ux_report.py +213 -0
  101. dev_tools/verify_infra.py +91 -0
  102. dev_tools/verify_versions.py +191 -0
  103. dev_tools/version_utils.py +175 -0
@@ -0,0 +1,181 @@
1
+ # pylint: disable=invalid-name,line-too-long,missing-docstring,missing-timeout,raise-missing-from
2
+ import os
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ import requests
7
+ from dev_tools.utils import log_debug, log_info, log_warn
8
+
9
+
10
+ class JulesClient:
11
+ def __init__(self, api_key: Optional[str] = None):
12
+ self.api_key = api_key or os.environ.get("JULES_API_KEY")
13
+ if not self.api_key:
14
+ raise ValueError("JULES_API_KEY is not set or empty")
15
+
16
+ self.base_url = "https://jules.googleapis.com/v1alpha"
17
+ self.legacy_url = "https://api.jules.ai/v1/sessions"
18
+ self.headers = {"Content-Type": "application/json", "x-goog-api-key": self.api_key}
19
+
20
+ def _get_clean_id(self, res_id: str, prefix: str) -> str:
21
+ return res_id.replace(f"{prefix}/", "")
22
+
23
+ def list_sources(self) -> List[Dict[str, Any]]:
24
+ url = f"{self.base_url}/sources"
25
+ response = requests.get(url, headers=self.headers, timeout=10)
26
+ response.raise_for_status()
27
+ return response.json().get("sources", [])
28
+
29
+ def list_sessions(self, pageSize: int = 100) -> List[Dict[str, Any]]:
30
+ url = f"{self.base_url}/sessions"
31
+ params = {"pageSize": pageSize}
32
+ response = requests.get(url, headers=self.headers, params=params, timeout=10)
33
+ response.raise_for_status()
34
+ return response.json().get("sessions", [])
35
+
36
+ def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
37
+ clean_id = self._get_clean_id(session_id, "sessions")
38
+ url = f"{self.base_url}/sessions/{clean_id}"
39
+ response = requests.get(url, headers=self.headers, timeout=10)
40
+ response.raise_for_status()
41
+ return response.json()
42
+
43
+ def discover_source_id(self, repo_full_name: str) -> Optional[str]:
44
+ sources = self.list_sources()
45
+ for s in sources:
46
+ ctx = s.get("githubRepo", {})
47
+ owner, repo = ctx.get("owner"), ctx.get("repo")
48
+ if owner and repo and f"{owner}/{repo}" == repo_full_name:
49
+ return self._get_clean_id(s.get("name") or "", "sources")
50
+ if repo_full_name in s.get("displayName", ""):
51
+ return self._get_clean_id(s.get("name") or "", "sources")
52
+ return None
53
+
54
+ def create_session_from_source(self, source_id: str, branch: str, prompt: str) -> Optional[Dict[str, Any]]:
55
+ url = f"{self.base_url}/sessions"
56
+ clean_source_id = self._get_clean_id(source_id, "sources")
57
+ payload = {
58
+ "prompt": prompt,
59
+ "sourceContext": {
60
+ "source": f"sources/{clean_source_id}",
61
+ "githubRepoContext": {"startingBranch": branch},
62
+ },
63
+ "automationMode": "AUTO_CREATE_PR",
64
+ }
65
+
66
+ log_debug(f"Creating Jules session at {url}")
67
+ log_debug(f"Payload: {payload}")
68
+
69
+ response = requests.post(url, headers=self.headers, json=payload, timeout=15)
70
+ response.raise_for_status()
71
+ return response.json()
72
+
73
+ def create_session(self, prompt: str, branch: str, title: str, owner: str, repo_name: str) -> str:
74
+ """Creates a new Jules session via the legacy API."""
75
+ headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
76
+ payload = {
77
+ "prompt": prompt,
78
+ "branch": branch,
79
+ "title": title,
80
+ "owner": owner,
81
+ "repo_name": repo_name,
82
+ }
83
+ try:
84
+ response = requests.post(self.legacy_url, headers=headers, json=payload)
85
+ response.raise_for_status()
86
+ session_id = response.json().get("id")
87
+ if not session_id:
88
+ raise RuntimeError("Could not find session ID in API response.")
89
+ return session_id
90
+ except requests.exceptions.RequestException as e:
91
+ error_msg = f"Error creating Jules session: {e}"
92
+ if e.response is not None:
93
+ error_msg += f"\nResponse: {e.response.text}"
94
+ raise RuntimeError(error_msg)
95
+
96
+ def _extract_activity_content(self, act: Dict[str, Any]) -> str:
97
+ if act.get("userMessaged"):
98
+ um = act["userMessaged"]
99
+ if isinstance(um, str):
100
+ return um
101
+ if isinstance(um, dict):
102
+ user_msg = um.get("userMessage", "")
103
+ return user_msg.get("body", "") if isinstance(user_msg, dict) else user_msg
104
+ elif act.get("progressUpdated") and isinstance(act["progressUpdated"], dict):
105
+ return act["progressUpdated"].get("description", "")
106
+ elif act.get("planGenerated") and isinstance(act["planGenerated"], dict):
107
+ plan = act["planGenerated"].get("plan") or {}
108
+ steps = plan.get("steps", []) if isinstance(plan, dict) else []
109
+ return "Generated plan:\n" + "\n".join(
110
+ f"- {s.get('description', '')}" for s in steps if isinstance(s, dict)
111
+ )
112
+ elif act.get("sessionCompleted"):
113
+ return "Session completed successfully."
114
+ return ""
115
+
116
+ def get_messages(self, session_id: str, timeout: int = 10) -> List[Dict[str, Any]]:
117
+ clean_id = self._get_clean_id(session_id, "sessions")
118
+ url = f"{self.base_url}/sessions/{clean_id}/activities"
119
+ response = requests.get(url, headers=self.headers, timeout=timeout)
120
+ response.raise_for_status()
121
+ activities = response.json().get("activities", [])
122
+ messages = []
123
+ for act in activities:
124
+ content = self._extract_activity_content(act)
125
+ if content:
126
+ messages.append(
127
+ {
128
+ "role": "user" if act.get("originator") == "user" else "jules",
129
+ "content": content,
130
+ "time": act.get("createTime"),
131
+ }
132
+ )
133
+ return messages
134
+
135
+ def send_message(self, session_id: Union[str, List[str]], message: str) -> Dict[str, Any]:
136
+ session_ids = [session_id] if isinstance(session_id, str) else session_id
137
+
138
+ if len(session_ids) == 1:
139
+ return self._send_single_message(session_ids[0], message)
140
+
141
+ # The Pydantic model now enforces the BATCH_CAP, but we keep this as a secondary safety
142
+ BATCH_CAP = 50
143
+ if len(session_ids) > BATCH_CAP:
144
+ log_warn(f"Batch size {len(session_ids)} exceeds cap of {BATCH_CAP}. Truncating.")
145
+ session_ids = session_ids[:BATCH_CAP]
146
+
147
+ log_info(f"Batch sending message to {len(session_ids)} sessions...")
148
+
149
+ results = []
150
+ with ThreadPoolExecutor(max_workers=min(len(session_ids), 10)) as executor:
151
+ # Submit all tasks and keep track of futures in order
152
+ futures = [executor.submit(self._send_single_message, sid, message) for sid in session_ids]
153
+
154
+ # Reconstruct results in the original input order by iterating over the futures list
155
+ for sid, future in zip(session_ids, futures):
156
+ try:
157
+ future.result()
158
+ results.append({"sessionId": sid, "status": "success"})
159
+ except Exception as exc:
160
+ log_warn(f"Failed to send message to {sid}: {exc}")
161
+ results.append({"sessionId": sid, "status": "error", "message": str(exc)})
162
+
163
+ return {
164
+ "status": "success" if any(r["status"] == "success" for r in results) else "error",
165
+ "message": f"Batch send completed. {sum(1 for r in results if r['status'] == 'success')}/{len(session_ids)} successful.",
166
+ "results": results,
167
+ }
168
+
169
+ def _send_single_message(self, session_id: str, message: str) -> Dict[str, Any]:
170
+ clean_id = self._get_clean_id(session_id, "sessions")
171
+ url = f"{self.base_url}/sessions/{clean_id}:sendMessage"
172
+ response = requests.post(url, headers=self.headers, json={"prompt": message}, timeout=10)
173
+ response.raise_for_status()
174
+ return {"status": "success", "message": "Message sent successfully"}
175
+
176
+ def cancel_session(self, session_id: str) -> Dict[str, Any]:
177
+ clean_id = self._get_clean_id(session_id, "sessions")
178
+ url = f"{self.base_url}/sessions/{clean_id}"
179
+ response = requests.delete(url, headers=self.headers, timeout=10)
180
+ response.raise_for_status()
181
+ return {"status": "success", "message": "Session cancelled successfully"}
@@ -0,0 +1,199 @@
1
+ # pylint: disable=f-string-without-interpolation,line-too-long,missing-docstring,too-few-public-methods,unused-variable
2
+ import json
3
+ import os
4
+ import re
5
+ import sys
6
+ from typing import Any, Dict, Optional
7
+
8
+ from dev_tools.utils import log_warn
9
+
10
+
11
+ def resolve_file_path(path: str) -> Optional[str]:
12
+ """Resolves path relative to current working directory or via filename lookup."""
13
+ if not path:
14
+ return None
15
+
16
+ # 1. Direct check (if absolute and exists, we take it, but we prefer relative)
17
+ if os.path.isabs(path) and os.path.exists(path):
18
+ # If it's absolute but under current directory, make it relative
19
+ try:
20
+ rel = os.path.relpath(path)
21
+ if not rel.startswith(".."):
22
+ return rel
23
+ except ValueError:
24
+ pass
25
+
26
+ # 2. Try stripping leading common absolute paths (like /app/ or /workspace/)
27
+ # We look for the first part that actually exists in the current directory relative to CWD
28
+ parts = [p for p in path.split(os.sep) if p]
29
+ for i in range(len(parts)):
30
+ subpath = os.path.join(*parts[i:])
31
+ if subpath and os.path.exists(subpath):
32
+ return subpath
33
+
34
+ # 3. Fallback to basename lookup in current directory (recursive)
35
+ filename = os.path.basename(path)
36
+ for root, dirs, files in os.walk("."):
37
+ if filename in files:
38
+ found_path = os.path.join(root, filename)
39
+ if found_path.startswith("./"):
40
+ return found_path[2:]
41
+ return found_path
42
+
43
+ return None
44
+
45
+
46
+ def strip_ansi(text: str) -> str:
47
+ """Strips ANSI color codes from string."""
48
+ ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
49
+ return ansi_escape.sub("", text)
50
+
51
+
52
+ class SignatureExtractor:
53
+ """Extracts file, line, and error signature from logs."""
54
+
55
+ # ESLint stylish: /app/src/App.tsx:10:5: 'unused' is defined but never used. [eslint/no-unused-vars]
56
+ # ESLint compact: /app/src/App.tsx: line 10, col 5, Error - 'unused' is defined but never used. [eslint/no-unused-vars]
57
+ ESLINT_PATTERN = re.compile(r"^(.*?):(\d+):(\d+): (.*) \[(.*)\]$")
58
+ ESLINT_COMPACT_PATTERN = re.compile(r"^(.*?): line (\d+), col (\d+), (?:Error|Warning) - (.*) \[(.*)\]$")
59
+
60
+ # TS example: src/App.tsx:10:5 - error TS2322: Type 'string' is not assignable to type 'number'.
61
+ TS_PATTERN = re.compile(r"^(.*?):(\d+):(\d+) - error (TS\d+): (.*)$")
62
+
63
+ # TS alternate: src/App.tsx(10,5): error TS2322: ...
64
+ TS_ALT_PATTERN = re.compile(r"^(.*?)\((\d+),(\d+)\): error (TS\d+): (.*)$")
65
+
66
+ @staticmethod
67
+ def _parse_ts(match) -> Dict[str, Any]:
68
+ return {
69
+ "file": match.group(1),
70
+ "line": int(match.group(2)),
71
+ "col": int(match.group(3)),
72
+ "message": match.group(5),
73
+ "signature": f"ts/{match.group(4)[2:]}", # Normalize TS2322 to ts/2322
74
+ }
75
+
76
+ @classmethod
77
+ def extract(cls, log_line: str) -> Optional[Dict[str, Any]]:
78
+ log_line = strip_ansi(log_line.strip())
79
+
80
+ # Try ESLint
81
+ match = cls.ESLINT_PATTERN.match(log_line) or cls.ESLINT_COMPACT_PATTERN.match(log_line)
82
+ if match:
83
+ sig = match.group(5)
84
+ if not sig.startswith("eslint/") and "/" not in sig:
85
+ sig = f"eslint/{sig}"
86
+ return {
87
+ "file": match.group(1),
88
+ "line": int(match.group(2)),
89
+ "col": int(match.group(3)),
90
+ "message": match.group(4),
91
+ "signature": sig,
92
+ }
93
+
94
+ # Try TS
95
+ match = cls.TS_PATTERN.match(log_line)
96
+ if match:
97
+ return cls._parse_ts(match)
98
+
99
+ # Try TS Alt
100
+ match = cls.TS_ALT_PATTERN.match(log_line)
101
+ if match:
102
+ return cls._parse_ts(match)
103
+
104
+ return None
105
+
106
+
107
+ class ASTContextualizer:
108
+ """Extracts surrounding code block for a given line."""
109
+
110
+ @staticmethod
111
+ def extract_context(filepath: str, line_number: int, window: int = 15) -> Optional[str]:
112
+ """
113
+ Extracts a deterministic window of code around line_number.
114
+ Uses a fixed window to avoid brittle indentation heuristics.
115
+ """
116
+ if not os.path.exists(filepath):
117
+ return None
118
+
119
+ try:
120
+ with open(filepath, "r", encoding="utf-8") as f:
121
+ lines = f.readlines()
122
+ except Exception as e:
123
+ log_warn(f"Failed to read file {filepath} for context extraction: {e}")
124
+ return None
125
+ if not lines:
126
+ return None
127
+
128
+ # Adjust to 0-indexed
129
+ idx = line_number - 1
130
+ if idx < 0 or idx >= len(lines):
131
+ return None
132
+
133
+ # Use a deterministic window
134
+ start_idx = max(0, idx - window)
135
+ end_idx = min(len(lines) - 1, idx + window)
136
+
137
+ context_lines = lines[start_idx : end_idx + 1]
138
+ return "".join(context_lines)
139
+
140
+
141
+ class RepairService:
142
+ """Coordinates extraction, lookup, and prompt construction."""
143
+
144
+ def __init__(self, knowledge_base_path: Optional[str] = None):
145
+ if knowledge_base_path is None:
146
+ if os.path.exists(".agents/knowledge/errors.json"):
147
+ knowledge_base_path = ".agents/knowledge/errors.json"
148
+ else:
149
+ knowledge_base_path = ".jules/knowledge/errors.json"
150
+ self.knowledge_base_path = knowledge_base_path
151
+ self.knowledge_base = self._load_kb()
152
+
153
+ def _load_kb(self) -> Dict[str, Any]:
154
+ if not os.path.exists(self.knowledge_base_path):
155
+ return {}
156
+ try:
157
+ with open(self.knowledge_base_path, "r", encoding="utf-8") as f:
158
+ return json.load(f)
159
+ except json.JSONDecodeError as e:
160
+ print(f"⚠️ Warning: Knowledge base JSON is invalid: {e}", file=sys.stderr)
161
+ return {}
162
+ except Exception as e:
163
+ print(f"⚠️ Warning: Failed to load knowledge base: {e}", file=sys.stderr)
164
+ return {}
165
+
166
+ def generate_prompt(self, log_line: str) -> Optional[str]:
167
+ extracted = SignatureExtractor.extract(log_line)
168
+ if not extracted:
169
+ return None
170
+
171
+ sig = extracted["signature"]
172
+ strategy_data = self.knowledge_base.get(sig, {})
173
+ strategy = strategy_data.get("strategy", "Investigate the error and apply a fix based on common practices.")
174
+ examples = strategy_data.get("examples", [])
175
+
176
+ # Adjust file path for context extraction
177
+ filepath = resolve_file_path(extracted["file"])
178
+ context = ASTContextualizer.extract_context(filepath, extracted["line"]) if filepath else None
179
+
180
+ prompt = f"### Error Report\n"
181
+ prompt += f"- **File**: `{extracted['file']}`\n"
182
+ prompt += f"- **Line**: {extracted['line']}\n"
183
+ prompt += f"- **Error**: `{extracted['signature']}`: {extracted['message']}\n\n"
184
+
185
+ prompt += f"### Fix Strategy\n"
186
+ prompt += f"{strategy}\n\n"
187
+
188
+ if examples:
189
+ prompt += "### Few-Shot Examples\n"
190
+ for ex in examples:
191
+ prompt += f"**Before:**\n```\n{ex['before']}\n```\n"
192
+ prompt += f"**After:**\n```\n{ex['after']}\n```\n"
193
+ prompt += "\n"
194
+
195
+ if context:
196
+ prompt += "### Code Context\n"
197
+ prompt += f"```tsx\n{context}\n```\n"
198
+
199
+ return prompt
@@ -0,0 +1,82 @@
1
+ # pylint: disable=missing-docstring
2
+ from typing import Any, Dict, List
3
+
4
+ import chromadb
5
+ from chromadb.utils import embedding_functions
6
+
7
+
8
+ class VectorStore:
9
+ def __init__(self, persist_directory: str = "artifacts/chroma_db", collection_name: str = "codebase"):
10
+ self.persist_directory = persist_directory
11
+ self.collection_name = collection_name
12
+ self._client = None
13
+ self._collection = None
14
+ self._embedding_fn = None
15
+
16
+ @property
17
+ def client(self):
18
+ if self._client is None and self.is_available():
19
+ self._client = chromadb.PersistentClient(path=self.persist_directory)
20
+ return self._client
21
+
22
+ @property
23
+ def embedding_fn(self):
24
+ if self._embedding_fn is None and self.is_available():
25
+ self._embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2")
26
+ return self._embedding_fn
27
+
28
+ @property
29
+ def collection(self):
30
+ if self._collection is None:
31
+ client = self.client
32
+ embedding_fn = self.embedding_fn
33
+ if client and embedding_fn:
34
+ self._collection = client.get_or_create_collection(
35
+ name=self.collection_name, embedding_function=embedding_fn
36
+ )
37
+ return self._collection
38
+
39
+ def is_available(self) -> bool:
40
+ """Checks if ChromaDB and dependencies are available."""
41
+ return True
42
+
43
+ def add_documents(self, documents: List[str], metadatas: List[Dict[str, Any]], ids: List[str]):
44
+ """Adds documents to the collection."""
45
+ collection = self.collection
46
+ if collection:
47
+ collection.add(documents=documents, metadatas=metadatas, ids=ids)
48
+
49
+ def query(self, query_text: str, n_results: int = 5) -> List[Dict[str, Any]]:
50
+ """Queries the collection for similar documents."""
51
+ collection = self.collection
52
+ if not collection:
53
+ return []
54
+
55
+ results = collection.query(query_texts=[query_text], n_results=n_results)
56
+
57
+ # Format results for easier consumption
58
+ formatted_results = []
59
+ if results["documents"]:
60
+ for i in range(len(results["documents"][0])):
61
+ formatted_results.append(
62
+ {
63
+ "document": results["documents"][0][i],
64
+ "metadata": results["metadatas"][0][i],
65
+ "id": results["ids"][0][i],
66
+ "distance": results["distances"][0][i] if "distances" in results else None,
67
+ }
68
+ )
69
+ return formatted_results
70
+
71
+ def reset(self):
72
+ """Resets the collection."""
73
+ client = self.client
74
+ embedding_fn = self.embedding_fn
75
+ if not client or not embedding_fn:
76
+ return
77
+
78
+ try:
79
+ client.delete_collection(self.collection_name)
80
+ except Exception:
81
+ pass
82
+ self._collection = client.create_collection(name=self.collection_name, embedding_function=embedding_fn)
@@ -0,0 +1,91 @@
1
+ # pylint: disable=import-outside-toplevel,line-too-long,missing-docstring,no-name-in-module
2
+ import base64
3
+ import json
4
+ import os
5
+ from typing import Dict, List, Optional
6
+
7
+ from dev_tools.config import load_project_config
8
+ from dev_tools.utils import get_github_token, log_error
9
+
10
+ PROJECT_CONFIG = load_project_config()
11
+
12
+
13
+ class VisionService:
14
+ """Vision-based regression audit service via AI API."""
15
+
16
+ def __init__(self, model: Optional[str] = None):
17
+ self.model = model or os.environ.get("VISION_MODEL", PROJECT_CONFIG.ai_vision_model)
18
+ self.token = get_github_token()
19
+
20
+ def call_ai(self, prompt: str, image_paths: List[str]) -> Optional[str]:
21
+ """Calls the AI model with text prompt and images."""
22
+ images = []
23
+ for p in image_paths:
24
+ if os.path.exists(p):
25
+ with open(p, "rb") as f:
26
+ images.append(base64.b64encode(f.read()).decode("utf-8"))
27
+
28
+ if not images:
29
+ return None
30
+
31
+ if not self.token:
32
+ return "No GITHUB_TOKEN found."
33
+
34
+ from dev_tools.utils import _request
35
+
36
+ message_content = [{"type": "text", "text": prompt}]
37
+ for img in images:
38
+ message_content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}})
39
+
40
+ url_target = "https://models.inference.ai.azure.com/chat/completions"
41
+ headers = {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
42
+ payload = {
43
+ "model": self.model,
44
+ "messages": [{"role": "user", "content": message_content}],
45
+ "temperature": 0.7,
46
+ "max_tokens": 2048,
47
+ }
48
+
49
+ try:
50
+ response = _request(
51
+ "POST",
52
+ url_target,
53
+ headers=headers,
54
+ json=payload,
55
+ retry_status_codes=[429, 500, 502, 503, 504],
56
+ )
57
+ if not response:
58
+ return None
59
+ return response.json()["choices"][0]["message"]["content"]
60
+ except Exception as e:
61
+ return f"❌ Vision call failed: {e}"
62
+
63
+ def analyze_visual_changes(self, summary_path: str, project_root: str) -> Dict[str, str]:
64
+ """Analyzes visual changes based on a summary JSON file."""
65
+ if not os.path.exists(summary_path):
66
+ return {}
67
+
68
+ try:
69
+ with open(summary_path, "r", encoding="utf-8") as f:
70
+ data = json.load(f)
71
+ except FileNotFoundError:
72
+ log_error(f"Visual summary file not found: {summary_path}")
73
+ return {}
74
+ except json.JSONDecodeError as e:
75
+ log_error(f"Malformed visual summary JSON: {e}")
76
+ return {}
77
+
78
+ routes = data.get("routes", [])
79
+ results = {}
80
+
81
+ for s in routes:
82
+ before, after = s.get("beforeCroppedPath"), s.get("afterCroppedPath")
83
+ if not (before and after):
84
+ continue
85
+
86
+ prompt = f"Analyze visual changes for {s['route']}. Describe what changed between BEFORE and AFTER. Identify bugs vs improvements. Be concise."
87
+ res = self.call_ai(prompt, [os.path.join(project_root, before), os.path.join(project_root, after)])
88
+ if res:
89
+ results[s["route"]] = res
90
+
91
+ return results
dev_tools/td_cli.py ADDED
@@ -0,0 +1,28 @@
1
+ # pylint: disable=import-outside-toplevel,missing-docstring
2
+ #!/usr/bin/env python3
3
+ """
4
+ td_cli.py - Project Developer CLI Shim
5
+
6
+ This script is a thin wrapper around the unified dev_tools CLI.
7
+ It maintains backward compatibility for existing scripts and CI workflows.
8
+ """
9
+
10
+ import sys
11
+
12
+
13
+ def main():
14
+ """Entry point for the td-cli shim."""
15
+ try:
16
+ from dev_tools.cli import main as cli_main
17
+
18
+ cli_main()
19
+ except ImportError as e:
20
+ # Handle missing dependencies gracefully (e.g. click, pydantic)
21
+ print(f"❌ Error: {e}", file=sys.stderr)
22
+ print(" The environment might not be fully bootstrapped.", file=sys.stderr)
23
+ print(" Run `pip install -e boomtick-pkg/cli/` to install dependencies.", file=sys.stderr)
24
+ sys.exit(1)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()