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
dev_tools/config.py ADDED
@@ -0,0 +1,235 @@
1
+ # pylint: disable=import-outside-toplevel,line-too-long,missing-docstring,too-many-branches,too-many-instance-attributes,too-many-statements,use-maxsplit-arg
2
+ from __future__ import annotations
3
+ import subprocess
4
+ import re
5
+
6
+ import functools
7
+ import json
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ProjectConfig:
15
+ github_repo: str | None = None
16
+ github_token_env: str = "GITHUB_TOKEN"
17
+ jules_api_url: str | None = None
18
+ core_dirs: List[str] = field(default_factory=lambda: ["src/layouts/", "src/components/"])
19
+ monolithic_pr_threshold: int = 3
20
+ base_branch: str = "origin/main"
21
+ vite_base_path: str = "/tech-dancer/"
22
+ gh_path: str = "gh"
23
+ max_diff_chars: int = 40000
24
+ content_scopes: Dict[str, str] = field(
25
+ default_factory=lambda: {
26
+ "resources": "content/resources/",
27
+ "posts": "content/posts/",
28
+ "blog": "content/blog/",
29
+ "studies": "content/studies/",
30
+ }
31
+ )
32
+ ai_synthesis_model: str = "gpt-4o-mini"
33
+ ai_review_model: str = "gpt-4o"
34
+ ai_vision_model: str = "gpt-4o"
35
+ ui_indicators: List[str] = field(
36
+ default_factory=lambda: [
37
+ "src/components",
38
+ "src/pages",
39
+ "src/layouts",
40
+ "src/index.css",
41
+ "tailwind",
42
+ ]
43
+ )
44
+ tailwind_indicators: List[str] = field(default_factory=lambda: ["px-", "py-", "mt-", "flex", "grid", "text-["])
45
+ audit_check_dirs: List[str] = field(
46
+ default_factory=lambda: [
47
+ "src/features",
48
+ "src/pages",
49
+ "src/components",
50
+ "src/layouts",
51
+ "src/App.tsx",
52
+ ]
53
+ )
54
+ cli_alias: str = "td-cli"
55
+ default_limit: int = 10
56
+ allowed_bots: List[str] = field(default_factory=lambda: ["github-actions[bot]"])
57
+ worktree_prefix: str = "bt-repair-"
58
+ pnpm_version: str = "10.28.2"
59
+ infra_file_paths: List[str] = field(
60
+ default_factory=lambda: [
61
+ "scripts/",
62
+ "boomtick-pkg/cli/",
63
+ ".github/",
64
+ "setup-agent.sh",
65
+ "Dockerfile",
66
+ ]
67
+ )
68
+ infra_feedback: str = (
69
+ "- **Infrastructure/Bootstrap Change:** Low-level script changes detected.\n"
70
+ " - *Review focus:* Ensure idempotency, portability (avoid bashisms), and robust error handling (`set -e`, `set -u`).\n"
71
+ " - *Verification:* If full system setup is risky, verify via dry-runs, `bash -n`, or log inspection. Document verification method in the PR.\n"
72
+ )
73
+ temp_file_patterns: List[str] = field(
74
+ default_factory=lambda: [
75
+ r".*\.tmp$",
76
+ r"^[^/]+\.py$",
77
+ r".*audit.*\.md$",
78
+ r".*dump.*\.json$",
79
+ r".*\.jsonl$",
80
+ ]
81
+ )
82
+ temp_file_feedback: str = (
83
+ "- **Stray/Temporary Files:** Suspicious files (scripts, logs, audits) detected. "
84
+ "Verify if these are intended to be committed.\n"
85
+ )
86
+ spec_sections: List[str] = field(
87
+ default_factory=lambda: [
88
+ "Problem Statement",
89
+ "Goal",
90
+ "Non-Goals",
91
+ "Proposed Approach",
92
+ "Alternatives Considered",
93
+ "Architectural Impact",
94
+ "Scope",
95
+ "UNDERSTAND THE ISSUE",
96
+ "DETERMINE APPROACH",
97
+ "SPECIFY SCOPE",
98
+ "DEFINITION OF DONE",
99
+ ]
100
+ )
101
+
102
+ @property
103
+ def base_branch_name(self) -> str:
104
+ """Returns the base branch name without the remote prefix (e.g., 'main' for 'origin/main')."""
105
+ if not self.base_branch:
106
+ return "main"
107
+ return self.base_branch.split("/")[-1]
108
+
109
+ @property
110
+ def context_builder_script(self) -> str:
111
+ """Returns the absolute path to the context builder script."""
112
+ from dev_tools.utils import resolve_resource_path
113
+
114
+ return resolve_resource_path("build-repo-context.py")
115
+
116
+
117
+ @functools.lru_cache()
118
+ def get_config(path: str | Path = "project_config.json") -> ProjectConfig:
119
+ """Returns a cached singleton instance of ProjectConfig."""
120
+ return load_project_config(path)
121
+
122
+
123
+ @functools.lru_cache()
124
+ def _detect_repo_name() -> str | None:
125
+ """Safely detects repository name from git remote."""
126
+ try:
127
+ res = subprocess.run(
128
+ ["git", "config", "--get", "remote.origin.url"],
129
+ capture_output=True,
130
+ text=True,
131
+ check=False,
132
+ )
133
+ if res.returncode != 0:
134
+ return None
135
+ url = res.stdout.strip()
136
+ if not url:
137
+ return None
138
+ match = re.search(r"[:/]([^/]+/[^/.]+)(\.git)?$", url)
139
+ return match.group(1) if match else url
140
+ except Exception:
141
+ return None
142
+
143
+
144
+ def load_project_config(path: str | Path = "project_config.json") -> ProjectConfig:
145
+ p = Path(path)
146
+
147
+ raw: Dict[str, Any] = {}
148
+ if p.exists():
149
+ try:
150
+ raw = json.loads(p.read_text(encoding="utf-8"))
151
+ except (json.JSONDecodeError, IOError):
152
+ pass
153
+ elif path != "project_config.json":
154
+ # If a specific path was requested but doesn't exist, return default
155
+ return ProjectConfig()
156
+ else:
157
+ # Check parent directories for project_config.json if not in CWD
158
+ # This helps when running from subdirectories
159
+ current = Path.cwd()
160
+ for parent in [current] + list(current.parents):
161
+ check_path = parent / "project_config.json"
162
+ if check_path.exists():
163
+ try:
164
+ raw = json.loads(check_path.read_text(encoding="utf-8"))
165
+ break
166
+ except (json.JSONDecodeError, IOError):
167
+ pass
168
+
169
+ def get_list(key: str) -> Optional[List[str]]:
170
+ val = raw.get(key)
171
+ if val is None:
172
+ return None
173
+ if isinstance(val, str):
174
+ return [val]
175
+ if isinstance(val, list):
176
+ return [str(item) for item in val]
177
+ return None
178
+
179
+ def get_dict(key: str) -> Optional[Dict[str, str]]:
180
+ val = raw.get(key)
181
+ if isinstance(val, dict):
182
+ return {str(k): str(v) for k, v in val.items()}
183
+ return None
184
+
185
+ kwargs: Dict[str, Any] = {}
186
+ if "github_repo" in raw or "repo_name" in raw:
187
+ kwargs["github_repo"] = raw.get("github_repo") or raw.get("repo_name")
188
+ else:
189
+ kwargs["github_repo"] = _detect_repo_name() or "arii/tech-dancer"
190
+
191
+ if "vite_base_path" in raw:
192
+ kwargs["vite_base_path"] = raw["vite_base_path"]
193
+ if "gh_path" in raw:
194
+ kwargs["gh_path"] = raw["gh_path"]
195
+ if "github_token_env" in raw:
196
+ kwargs["github_token_env"] = raw["github_token_env"]
197
+ if "jules_api_url" in raw:
198
+ kwargs["jules_api_url"] = raw["jules_api_url"]
199
+ if "monolithic_pr_threshold" in raw:
200
+ kwargs["monolithic_pr_threshold"] = int(raw["monolithic_pr_threshold"])
201
+ if "base_branch" in raw:
202
+ kwargs["base_branch"] = raw["base_branch"]
203
+ if "max_diff_chars" in raw:
204
+ kwargs["max_diff_chars"] = int(raw["max_diff_chars"])
205
+ if "ai_synthesis_model" in raw:
206
+ kwargs["ai_synthesis_model"] = raw["ai_synthesis_model"]
207
+ if "ai_review_model" in raw:
208
+ kwargs["ai_review_model"] = raw["ai_review_model"]
209
+ if "ai_vision_model" in raw:
210
+ kwargs["ai_vision_model"] = raw["ai_vision_model"]
211
+ if "worktree_prefix" in raw:
212
+ kwargs["worktree_prefix"] = raw["worktree_prefix"]
213
+ if "pnpm_version" in raw:
214
+ kwargs["pnpm_version"] = raw["pnpm_version"]
215
+ if "temp_file_feedback" in raw:
216
+ kwargs["temp_file_feedback"] = raw["temp_file_feedback"]
217
+
218
+ for list_key in [
219
+ "core_dirs",
220
+ "ui_indicators",
221
+ "tailwind_indicators",
222
+ "audit_check_dirs",
223
+ "allowed_bots",
224
+ "spec_sections",
225
+ "temp_file_patterns",
226
+ ]:
227
+ val = get_list(list_key)
228
+ if val is not None:
229
+ kwargs[list_key] = val
230
+
231
+ content_scopes = get_dict("content_scopes")
232
+ if content_scopes is not None:
233
+ kwargs["content_scopes"] = content_scopes
234
+
235
+ return ProjectConfig(**kwargs)
dev_tools/constants.py ADDED
@@ -0,0 +1,14 @@
1
+ """Constants for dev_tools."""
2
+
3
+ # Robust placeholder detection using regex to handle minor variants
4
+ REVIEW_PLACEHOLDERS = [
5
+ r"<findings\s*/?>",
6
+ r"<summary\s*/?>",
7
+ r"<filename\s*/?>",
8
+ r"<feedback\s*/?>",
9
+ r"<Approved\s*\|\s*Approved\s*with\s*Minor\s*Changes\s*\|\s*Not\s*Approved>",
10
+ r"##\s+ANTI-AI-SLOP",
11
+ r"##\s+FINDINGS",
12
+ r"##\s+FINAL RECOMMENDATION",
13
+ r"<!--\s*td-review-manager-comment\s*-->",
14
+ ]
dev_tools/daemon.py ADDED
@@ -0,0 +1,146 @@
1
+ # pylint: disable=arguments-differ,attribute-defined-outside-init,line-too-long,logging-fstring-interpolation,missing-docstring,too-few-public-methods
2
+ import logging
3
+ import sys
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from dev_tools.orchestrator import Orchestrator
7
+ from dev_tools.services.github import GitHubClient
8
+ from dev_tools.services.jules import JulesClient
9
+
10
+ logging.basicConfig(level=logging.INFO)
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ExtendedJulesClient(JulesClient):
15
+ """JulesClient subclass with extended timeouts for daemon use."""
16
+
17
+ def get_messages(self, session_id: str, timeout: int = 30) -> List[Dict[str, Any]]:
18
+ # Set default timeout to 30s instead of the default 10s
19
+ return super().get_messages(session_id, timeout=timeout)
20
+
21
+
22
+ class JulesFeedbackDaemon:
23
+ def __init__(self):
24
+ self.jules = ExtendedJulesClient()
25
+ self.github = GitHubClient()
26
+ self.orchestrator = Orchestrator()
27
+ # Ensure the orchestrator uses our extended client
28
+ self.orchestrator.initialize_jules(self.jules)
29
+
30
+ def run(self, limit: int = 10):
31
+ logger.info("Starting Jules Feedback Daemon")
32
+ try:
33
+ sessions = self.jules.list_sessions(pageSize=limit)
34
+ logger.info(f"Found {len(sessions)} sessions")
35
+ except Exception as e:
36
+ logger.error(f"Error fetching sessions: {e}")
37
+ sys.exit(1)
38
+
39
+ self._pr_cache: Dict[int, Any] = {}
40
+ self._session_to_pr_map: Dict[str, Any] = {}
41
+
42
+ # Batch pre-match sessions to PRs where possible to reduce API hits
43
+ self._pre_match_sessions_batch(sessions)
44
+
45
+ for session in sessions:
46
+ self._process_session(session)
47
+
48
+ def _pre_match_sessions_batch(self, sessions: List[Dict[str, Any]]):
49
+ """Batch search for multiple session IDs in one GitHub API call."""
50
+ if not sessions:
51
+ return
52
+
53
+ session_ids = []
54
+ for s in sessions:
55
+ sid = s.get("name", "").split("/")[-1]
56
+ if sid:
57
+ session_ids.append(sid)
58
+
59
+ if not session_ids:
60
+ return
61
+
62
+ # GitHub Search API query construction
63
+ # Example: (ID1 OR ID2 OR ID3) in:body,title state:open
64
+ id_query = " OR ".join([f'"{sid}"' for sid in session_ids])
65
+ full_query = f"({id_query}) in:body,title state:open"
66
+
67
+ try:
68
+ logger.info(f"Performing batch PR search for {len(session_ids)} session IDs")
69
+ found_prs = self.github.search_pull_requests(full_query, limit=50)
70
+
71
+ for pr in found_prs:
72
+ body = (pr.get("body") or "").lower()
73
+ title = (pr.get("title") or "").lower()
74
+ for sid in session_ids:
75
+ if sid.lower() in body or sid.lower() in title:
76
+ self._session_to_pr_map[sid] = pr
77
+ # Cache the PR details as well
78
+ self._pr_cache[pr["number"]] = pr
79
+ except Exception as e:
80
+ logger.warning(f"Batch PR search failed, will fallback to individual lookups: {e}")
81
+
82
+ def _get_pr_for_session(self, session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
83
+ """Optimized PR lookup for a session."""
84
+ session_id = session.get("name", "").replace("sessions/", "")
85
+
86
+ # 0. Check batch pre-match results
87
+ if session_id in self._session_to_pr_map:
88
+ return self._session_to_pr_map[session_id]
89
+
90
+ # 1-3. Delegate to orchestrator logic
91
+ pr_num = self.orchestrator.get_pr_for_session(session)
92
+ if pr_num:
93
+ if pr_num not in self._pr_cache:
94
+ self._pr_cache[pr_num] = self.github.fetch_pr_details(pr_num)
95
+ return self._pr_cache[pr_num]
96
+
97
+ return None
98
+
99
+ def _process_session(self, session: Dict[str, Any]):
100
+ session_id = session.get("name", "").replace("sessions/", "")
101
+ if not session_id:
102
+ return
103
+
104
+ logger.info(f"Processing session {session_id}")
105
+ matched_pr = self._get_pr_for_session(session)
106
+
107
+ if not matched_pr:
108
+ logger.info(f"No matching PR found for session {session_id}")
109
+ return
110
+
111
+ try:
112
+ messages = self.jules.get_messages(session_id)
113
+ except Exception as e:
114
+ logger.error(f"Error getting messages for session {session_id}: {e}")
115
+ return
116
+
117
+ if not messages:
118
+ logger.info(f"No messages for session {session_id}")
119
+ return
120
+
121
+ logger.info(
122
+ f"Matched PR #{matched_pr['number']} ({matched_pr.get('headRefName')}, {matched_pr.get('title')}) for session {session_id}"
123
+ )
124
+
125
+ last_message = messages[-1]
126
+ if last_message.get("role") == "user":
127
+ logger.info(f"Last message from user, skipping feedback to avoid double-feedback for {session_id}")
128
+ return
129
+
130
+ if last_message.get("role") == "jules":
131
+ logger.info(f"Triggering feedback for session {session_id} matching PR #{matched_pr['number']}")
132
+ try:
133
+ # Use orchestrator.trigger_jules_feedback which executes CI validation logic
134
+ res = self.orchestrator.trigger_jules_feedback(session_id)
135
+ logger.info(f"Feedback triggered successfully: {res.get('status', 'unknown')}")
136
+ except Exception as e:
137
+ logger.error(f"Error triggering feedback for {session_id}: {e}")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ try:
142
+ daemon = JulesFeedbackDaemon()
143
+ daemon.run()
144
+ except Exception as e:
145
+ logger.error(f"Daemon crashed: {e}")
146
+ sys.exit(1)
@@ -0,0 +1,108 @@
1
+ import dotenv from "dotenv";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { fileURLToPath } from "url";
5
+ import { execSync } from "child_process";
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ dotenv.config({
8
+ path: path.resolve(__dirname, "../.env"),
9
+ quiet: true
10
+ });
11
+ function getGithubToken() {
12
+ if (process.env.GITHUB_TOKEN) {
13
+ return process.env.GITHUB_TOKEN;
14
+ }
15
+ try {
16
+ const token = execSync("gh auth token", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
17
+ if (token) {
18
+ return token;
19
+ }
20
+ }
21
+ catch (e) { }
22
+ return undefined;
23
+ }
24
+ let cachedDynamicConfig = null;
25
+ /**
26
+ * Explicitly initializes dynamic configuration from the Python CLI.
27
+ * Should be called once during server startup.
28
+ */
29
+ export function initializeConfig() {
30
+ if (cachedDynamicConfig !== null) {
31
+ return cachedDynamicConfig;
32
+ }
33
+ try {
34
+ // Attempt to load core properties from the Python CLI to avoid duplication
35
+ const cmd = `td-cli config view`;
36
+ const output = execSync(cmd, {
37
+ encoding: "utf-8",
38
+ stdio: ["ignore", "pipe", "ignore"]
39
+ });
40
+ cachedDynamicConfig = JSON.parse(output);
41
+ return cachedDynamicConfig;
42
+ }
43
+ catch (e) {
44
+ const message = e instanceof Error ? e.message : String(e);
45
+ const errorPrefix = `CRITICAL: Failed to load dynamic config from Python CLI. Ensure Python 3 is installed and boomtick-pkg/cli is in your PYTHONPATH. Details: ${message}`;
46
+ if (process.env.NODE_ENV === "development" || process.env.CI === "true") {
47
+ throw new Error(errorPrefix);
48
+ }
49
+ else {
50
+ console.warn(errorPrefix);
51
+ }
52
+ // Set an empty object to prevent repeated attempts if initialization fails in non-blocking mode
53
+ cachedDynamicConfig = {};
54
+ }
55
+ return cachedDynamicConfig;
56
+ }
57
+ function findRepoRoot() {
58
+ if (process.env.BOOMTICK_REPO_PATH) {
59
+ return process.env.BOOMTICK_REPO_PATH;
60
+ }
61
+ // Traverse up to find workspace.json or .git
62
+ let current = __dirname;
63
+ while (current !== path.parse(current).root) {
64
+ if (fs.existsSync(path.join(current, "workspace.json")) || fs.existsSync(path.join(current, ".git"))) {
65
+ return current;
66
+ }
67
+ current = path.dirname(current);
68
+ }
69
+ // Fallback to the monolithic relative path if discovery fails
70
+ return path.resolve(__dirname, "../../../");
71
+ }
72
+ export const config = {
73
+ get githubToken() { return getGithubToken(); },
74
+ get githubOwner() {
75
+ if (process.env.GITHUB_OWNER)
76
+ return process.env.GITHUB_OWNER;
77
+ const repoString = cachedDynamicConfig?.github_repo;
78
+ if (typeof repoString !== "string" || !repoString.includes("/")) {
79
+ throw new Error("GITHUB_OWNER must be set via environment variable or project_config.json");
80
+ }
81
+ return repoString.split("/")[0];
82
+ },
83
+ get githubRepo() {
84
+ if (process.env.GITHUB_REPO)
85
+ return process.env.GITHUB_REPO;
86
+ const repoString = cachedDynamicConfig?.github_repo;
87
+ if (typeof repoString !== "string" || !repoString.includes("/")) {
88
+ throw new Error("GITHUB_REPO must be set via environment variable or project_config.json");
89
+ }
90
+ return repoString.split("/")[1];
91
+ },
92
+ get repoPath() {
93
+ return findRepoRoot();
94
+ },
95
+ get defaultBaseBranch() {
96
+ return process.env.DEFAULT_BASE_BRANCH || cachedDynamicConfig?.base_branch?.split("/").pop() || "main";
97
+ },
98
+ get viteBasePath() {
99
+ const path = process.env.VITE_BASE_PATH || cachedDynamicConfig?.vite_base_path;
100
+ if (!path) {
101
+ throw new Error("VITE_BASE_PATH must be set via environment variable or project_config.json");
102
+ }
103
+ return path;
104
+ },
105
+ get ghPath() {
106
+ return process.env.GH_PATH || cachedDynamicConfig?.gh_path || "gh";
107
+ }
108
+ };
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { BoomtickMCPServer } from "./mcp/server.js";
3
+ import { initializeConfig } from "./config.js";
4
+ // Initialize configuration once at startup
5
+ initializeConfig();
6
+ const server = new BoomtickMCPServer();
7
+ server.run().catch((error) => {
8
+ console.error("Fatal error running server:", error);
9
+ process.exit(1);
10
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Sanitizes stderr from CLI commands to prevent implementation detail leakage
3
+ * in error messages returned to the AI agent.
4
+ */
5
+ export function sanitizeError(stderr) {
6
+ // Take first line and truncate to 200 chars to balance detail vs security
7
+ return (stderr.split("\n")[0] || "Unknown error").slice(0, 200);
8
+ }
@@ -0,0 +1,31 @@
1
+ import { runCommand } from "./shell.js";
2
+ import path from "path";
3
+ import fs from "fs/promises";
4
+ function validatePrNumber(value) {
5
+ const prNumber = Number(value);
6
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
7
+ throw new Error("Invalid PR number");
8
+ }
9
+ return prNumber;
10
+ }
11
+ export async function createWorktree(branch, prNumber) {
12
+ const safePrNumber = validatePrNumber(prNumber);
13
+ const workspaceRoot = "/tmp/boomtick-worktrees";
14
+ // nosemgrep
15
+ const worktreePath = path.join(workspaceRoot, `boomtick-mcp-rescue-${safePrNumber}`);
16
+ // Clean up if exists
17
+ try {
18
+ await fs.rm(worktreePath, { recursive: true, force: true });
19
+ await runCommand("git", ["worktree", "prune"]);
20
+ }
21
+ catch (e) { }
22
+ const result = await runCommand("git", ["worktree", "add", "-b", `repair-pr-${prNumber}-${Date.now()}`, worktreePath, branch]);
23
+ if (result.exitCode !== 0) {
24
+ throw new Error(`Failed to create worktree: ${result.stderr}`);
25
+ }
26
+ return worktreePath;
27
+ }
28
+ export async function removeWorktree(worktreePath) {
29
+ await runCommand("git", ["worktree", "remove", "--force", worktreePath]);
30
+ await runCommand("git", ["worktree", "prune"]);
31
+ }
@@ -0,0 +1,21 @@
1
+ export function createSuccessResult(data) {
2
+ return {
3
+ content: [
4
+ {
5
+ type: "text",
6
+ text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
7
+ },
8
+ ],
9
+ };
10
+ }
11
+ export function createErrorResult(message) {
12
+ return {
13
+ content: [
14
+ {
15
+ type: "text",
16
+ text: message,
17
+ },
18
+ ],
19
+ isError: true,
20
+ };
21
+ }
@@ -0,0 +1,91 @@
1
+ import { spawn } from "child_process";
2
+ import { config } from "../config.js";
3
+ import path from "path";
4
+ import fs from "fs";
5
+ export const ALLOWED_COMMANDS = {
6
+ git: "git",
7
+ gh: "gh",
8
+ "td-cli": "td-cli",
9
+ pnpm: "pnpm",
10
+ ls: "ls",
11
+ rm: "rm",
12
+ mkdir: "mkdir",
13
+ cp: "cp",
14
+ python3: "python3"
15
+ };
16
+ export async function runCommand(cmd, args, options = {}) {
17
+ if (!(cmd in ALLOWED_COMMANDS)) {
18
+ throw new Error(`Command not allowed: ${cmd}`);
19
+ }
20
+ const safeCmd = cmd;
21
+ const start = Date.now();
22
+ const timeout = options.timeout || 60000;
23
+ // Resolve path for 'gh' or 'td-cli' if specified
24
+ let finalCmd = ALLOWED_COMMANDS[safeCmd];
25
+ if (safeCmd === "gh") {
26
+ finalCmd = config.ghPath;
27
+ }
28
+ else if (safeCmd === "td-cli") {
29
+ // If we have a repo-relative .venv, prioritize it
30
+ const venvBin = path.join(config.repoPath, ".venv", "bin", "td-cli");
31
+ const localVenvBin = path.join(process.cwd(), ".venv", "bin", "td-cli");
32
+ const parentVenvBin = path.join(config.repoPath, "..", ".venv", "bin", "td-cli");
33
+ // In standalone mode, config.repoPath is the boomtick-pkg root.
34
+ // We check common venv locations to ensure we call the isolated binary.
35
+ if (fs.existsSync(venvBin)) {
36
+ finalCmd = venvBin;
37
+ }
38
+ else if (fs.existsSync(localVenvBin)) {
39
+ finalCmd = localVenvBin;
40
+ }
41
+ else if (fs.existsSync(parentVenvBin)) {
42
+ finalCmd = parentVenvBin;
43
+ }
44
+ }
45
+ const env = {
46
+ ...process.env,
47
+ ...options.env,
48
+ GH_TOKEN: config.githubToken,
49
+ GITHUB_TOKEN: config.githubToken
50
+ };
51
+ return new Promise((resolve, reject) => {
52
+ // nosemgrep
53
+ const child = spawn(finalCmd, args, {
54
+ cwd: options.cwd || config.repoPath,
55
+ env
56
+ });
57
+ let stdout = "";
58
+ let stderr = "";
59
+ child.stdout.on("data", (data) => {
60
+ stdout += data.toString();
61
+ });
62
+ child.stderr.on("data", (data) => {
63
+ stderr += data.toString();
64
+ });
65
+ const timer = setTimeout(() => {
66
+ child.kill();
67
+ reject(new Error(`Command timed out: ${cmd} ${args.join(" ")}`));
68
+ }, timeout);
69
+ child.on("close", (code) => {
70
+ clearTimeout(timer);
71
+ const durationMs = Date.now() - start;
72
+ // Redact token from output
73
+ if (config.githubToken) {
74
+ const redact = (str) => str.replace(new RegExp(config.githubToken, "g"), "REDACTED");
75
+ stdout = redact(stdout);
76
+ stderr = redact(stderr);
77
+ }
78
+ resolve({
79
+ stdout,
80
+ stderr,
81
+ exitCode: code,
82
+ durationMs,
83
+ command: `${cmd} ${args.join(" ")}`
84
+ });
85
+ });
86
+ child.on("error", (err) => {
87
+ clearTimeout(timer);
88
+ reject(err);
89
+ });
90
+ });
91
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import path from "path";
3
+ // Simple test to verify environment and vitest setup
4
+ describe("Basic Environment", () => {
5
+ it("should have a valid repo path", () => {
6
+ const repoPath = process.env.BOOMTICK_REPO_PATH || "/app";
7
+ expect(repoPath).toBeDefined();
8
+ expect(path.isAbsolute(repoPath)).toBe(true);
9
+ });
10
+ });