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/cli.py ADDED
@@ -0,0 +1,1542 @@
1
+ # pylint: disable=f-string-without-interpolation,function-redefined,import-outside-toplevel,line-too-long,missing-docstring,no-value-for-parameter,protected-access,redefined-outer-name,subprocess-run-check,too-many-arguments,too-many-lines,too-many-locals,too-many-positional-arguments,unused-argument,use-dict-literal
2
+ import glob
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from dataclasses import asdict
9
+ from typing import Any, Dict, List
10
+
11
+ import click
12
+ from dev_tools.config import get_config
13
+ from dev_tools.utils import (
14
+ CLIError,
15
+ get_or_create_log_dir,
16
+ log_error,
17
+ log_info,
18
+ log_warn,
19
+ mask_sensitive_data,
20
+ run_command,
21
+ verify_ci_metrics,
22
+ )
23
+
24
+
25
+ class LazyOrchestrator:
26
+ """Lazy proxy for the Orchestrator to defer heavy imports and initialization."""
27
+
28
+ def __init__(self, factory):
29
+ # Use super().__setattr__ to avoid recursion during initialization
30
+ super().__setattr__("_factory", factory)
31
+ super().__setattr__("_instance", None)
32
+
33
+ def _get_instance(self):
34
+ if self._instance is None:
35
+ super().__setattr__("_instance", self._factory())
36
+ return self._instance
37
+
38
+ def __getattr__(self, name):
39
+ return getattr(self._get_instance(), name)
40
+
41
+ def __setattr__(self, name, value):
42
+ if name in ("_instance", "_factory"):
43
+ super().__setattr__(name, value)
44
+ else:
45
+ setattr(self._get_instance(), name, value)
46
+
47
+ def __dir__(self):
48
+ return dir(self._get_instance())
49
+
50
+
51
+ PROJECT_CONFIG = get_config()
52
+ DEFAULT_GH_API_LIMIT = PROJECT_CONFIG.default_limit
53
+
54
+
55
+ def limit_option(default_val=DEFAULT_GH_API_LIMIT, help_text="Limit the number of items to process"):
56
+ def decorator(f):
57
+ return click.option("--limit", type=int, default=default_val, help=help_text)(f)
58
+
59
+ return decorator
60
+
61
+
62
+ def json_option(f):
63
+ """
64
+ Decorator to add --json option to subcommands.
65
+ Allows --json to be placed after the command (e.g., 'td command --json').
66
+ Automatically updates ctx.obj['JSON'] if the option is provided.
67
+ """
68
+ import functools
69
+
70
+ f = click.option("--json/--no-json", "json_output", default=None, help="Output results in JSON format")(f)
71
+
72
+ @click.pass_context
73
+ @functools.wraps(f)
74
+ def wrapper(ctx, *args, **kwargs):
75
+ json_output = kwargs.pop("json_output", None)
76
+ if json_output is not None and ctx.obj is not None:
77
+ ctx.obj["JSON"] = json_output
78
+ return ctx.invoke(f, *args, **kwargs)
79
+
80
+ return wrapper
81
+
82
+
83
+ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"], ignore_unknown_options=True, allow_extra_args=True)
84
+
85
+ # CLI Group
86
+
87
+
88
+ @click.group(context_settings=CONTEXT_SETTINGS)
89
+ @click.option("--json/--no-json", "json_output", default=True, help="Output results in JSON format")
90
+ @click.option("--no-cache", is_flag=True, default=False, help="Bypass the disk cache for GitHub API calls")
91
+ @click.pass_context
92
+ def cli(ctx, json_output, no_cache):
93
+ """Unified Tech-Dancer DevTools CLI"""
94
+ ctx.ensure_object(dict)
95
+
96
+ # If the user explicitly passed --no-json (if supported) or we want to detect if it's a TTY
97
+ # But for now, we follow the requirement to be JSON by default for machine consumption.
98
+ ctx.obj["JSON"] = json_output
99
+ ctx.obj["NO_CACHE"] = no_cache
100
+
101
+ def orchestrator_factory():
102
+ from dev_tools.orchestrator import Orchestrator
103
+
104
+ return Orchestrator(no_cache=no_cache)
105
+
106
+ ctx.obj["ORCHESTRATOR"] = LazyOrchestrator(orchestrator_factory)
107
+
108
+
109
+ # --- Utility Helpers ---
110
+
111
+
112
+ def out(ctx, msg, data=None):
113
+ if ctx.obj["JSON"]:
114
+ payload = {"status": "success"}
115
+ if data:
116
+ payload.update(data)
117
+ click.echo(json.dumps(payload, indent=2))
118
+ else:
119
+ click.echo(msg)
120
+
121
+
122
+ def err(ctx, msg, code=1, data=None):
123
+ # Ensure sensitive data is masked in the error message
124
+ masked_msg = mask_sensitive_data(msg)
125
+ if ctx.obj["JSON"]:
126
+ payload = {"status": "error", "message": masked_msg, "code": code}
127
+ if data:
128
+ payload.update({"data": data})
129
+ click.echo(json.dumps(payload, indent=2))
130
+ else:
131
+ click.echo(f"❌ Error: {masked_msg}", err=True)
132
+ sys.exit(code)
133
+
134
+
135
+ def _handle_unexpected_error(ctx, command_name, error):
136
+ """Centralized error handling for unexpected exceptions."""
137
+ message = f"Unexpected error in {command_name}: {str(error)}"
138
+ err(ctx, message, code=1)
139
+
140
+
141
+ def _get_body_content(ctx, orch, file, body):
142
+ if file and body:
143
+ err(ctx, "Provide --file or --body, not both")
144
+ if not file and not body:
145
+ err(ctx, "Provide either --file or --body")
146
+
147
+ content = body if body is not None else (orch._read_safe_file(file) if file else None)
148
+ if content is None:
149
+ err(ctx, "Provide --file or --body")
150
+ return content
151
+
152
+
153
+ # ==========================================
154
+ # CONFIG COMMAND GROUP
155
+ # ==========================================
156
+
157
+
158
+ @cli.group()
159
+ def config():
160
+ """Configuration Operations"""
161
+
162
+
163
+ @config.command(name="view")
164
+ @json_option
165
+ @click.pass_context
166
+ def config_view(ctx):
167
+ """View the current project configuration as JSON."""
168
+ # ctx.obj might be None if the command is called directly or during unit tests
169
+ is_json = ctx.obj.get("JSON", True) if ctx.obj is not None else True
170
+
171
+ if is_json:
172
+ click.echo(json.dumps(asdict(PROJECT_CONFIG), indent=2))
173
+ else:
174
+ click.echo("Current configuration:")
175
+ click.echo(json.dumps(asdict(PROJECT_CONFIG), indent=2))
176
+
177
+
178
+ # ==========================================
179
+ # REPO COMMAND GROUP
180
+ # ==========================================
181
+
182
+
183
+ @cli.group()
184
+ def repo():
185
+ """Repository Operations"""
186
+
187
+
188
+ @repo.command()
189
+ @click.option("--grep")
190
+ @click.option("--worktree")
191
+ @click.pass_context
192
+ def run_playwright(ctx, grep, worktree):
193
+ orch = ctx.obj["ORCHESTRATOR"]
194
+ res = orch.run_playwright(grep=grep, worktree_path=worktree)
195
+ out(ctx, f"Playwright run complete.", data=res)
196
+
197
+
198
+ @repo.command()
199
+ @click.argument("pr_number", type=int)
200
+ @click.option("--all", "include_all", is_flag=True, help="Include logs for successful runs")
201
+ @click.pass_context
202
+ def ci_logs(ctx, pr_number, include_all):
203
+ orch = ctx.obj["ORCHESTRATOR"]
204
+ res = orch.get_ci_logs(pr_number, include_all=include_all)
205
+ out(ctx, f"Fetched CI logs for PR #{pr_number}", data=res)
206
+
207
+
208
+ @repo.command()
209
+ @click.argument("pr_number", type=int)
210
+ @click.option("--grep", help="Filter logs by pattern")
211
+ @click.pass_context
212
+ def logs(ctx, pr_number, grep):
213
+ orch = ctx.obj["ORCHESTRATOR"]
214
+ logs_content = orch.stream_ci_logs(pr_number, grep=grep)
215
+ out(ctx, f"Fetched logs for PR #{pr_number}", data={"logs": logs_content})
216
+
217
+
218
+ @repo.command()
219
+ @click.argument("filepath")
220
+ @click.option("--patch-file")
221
+ @click.option("--patch-body")
222
+ @click.pass_context
223
+ def apply_patch(ctx, filepath, patch_file, patch_body):
224
+ """Apply a patch to a file using resilient git apply."""
225
+ from dev_tools.utils import apply_patch as _apply
226
+ from dev_tools.utils import sanitize_path
227
+
228
+ orch = ctx.obj["ORCHESTRATOR"]
229
+
230
+ # Security: sanitize inputs
231
+ filepath = sanitize_path(filepath)
232
+ if not filepath:
233
+ err(ctx, "Invalid filepath provided.")
234
+ return # Explicit return for security
235
+
236
+ content = patch_body
237
+ if patch_file:
238
+ safe_patch_path = sanitize_path(patch_file)
239
+ if not safe_patch_path:
240
+ err(ctx, "Invalid patch-file path provided.")
241
+ return # Explicit return for security
242
+ try:
243
+ # Use Orchestrator helper for safe file reading
244
+ content = orch._read_safe_file(safe_patch_path)
245
+ except Exception as e:
246
+ err(ctx, f"Failed to read patch file: {str(e)}")
247
+
248
+ if not content:
249
+ err(ctx, "Provide either --patch-file or --patch-body")
250
+ return # Explicit return for security
251
+
252
+ # Basic diff format validation
253
+ if not any(marker in content for marker in ["--- ", "+++ ", "@@ "]):
254
+ err(ctx, "Invalid patch format. Content does not appear to be a unified diff.")
255
+ return
256
+
257
+ try:
258
+ _apply(filepath, content)
259
+ out(ctx, f"✅ Applied patch to {filepath}")
260
+ except Exception as e:
261
+ err(ctx, f"Failed to apply patch: {str(e)}")
262
+
263
+
264
+ # ==========================================
265
+ # GH COMMAND GROUP
266
+ # ==========================================
267
+
268
+
269
+ @cli.group()
270
+ def gh():
271
+ """GitHub Operations"""
272
+
273
+
274
+ @gh.command()
275
+ @json_option
276
+ @click.option("--state", default="open")
277
+ @limit_option(help_text="Limit the number of PRs to process")
278
+ @click.option("--include-drafts/--no-include-drafts", default=True)
279
+ @click.option("--labels")
280
+ @click.pass_context
281
+ def search_prs(ctx, state, limit, include_drafts, labels):
282
+ orch = ctx.obj["ORCHESTRATOR"]
283
+ label_list = [l.strip() for l in labels.split(",")] if labels else None
284
+
285
+ res = orch.list_prs(state=state, limit=limit, includeDrafts=include_drafts, labels=label_list)
286
+ out(ctx, f"Found {len(res['prs'])} PRs.", data=res)
287
+
288
+
289
+ @gh.command()
290
+ @click.argument("pr_number", type=int)
291
+ @click.option("--base", default=PROJECT_CONFIG.base_branch_name)
292
+ @click.pass_context
293
+ def merge_conflicts(ctx, pr_number, base):
294
+ orch = ctx.obj["ORCHESTRATOR"]
295
+ res = orch.get_merge_conflicts(pr_number, base_branch=base)
296
+ out(ctx, f"Checked merge conflicts for PR #{pr_number}", data=res)
297
+
298
+
299
+ @gh.command()
300
+ @click.argument("pr_number", type=int)
301
+ @click.pass_context
302
+ def sync_pr(ctx, pr_number):
303
+ """Reliably pull the latest remote PR state to local, overwriting messy rebases."""
304
+ orch = ctx.obj["ORCHESTRATOR"]
305
+ res = orch.sync_pr(pr_number)
306
+ out(ctx, res["message"], data=res)
307
+
308
+
309
+ @gh.command()
310
+ @click.argument("pr_number", type=int)
311
+ @click.pass_context
312
+ def pr_diff(ctx, pr_number):
313
+ orch = ctx.obj["ORCHESTRATOR"]
314
+ res = orch.get_pr_diff_shapen(pr_number)
315
+ out(ctx, f"Fetched diff for PR #{pr_number}", data=res)
316
+
317
+
318
+ @gh.command()
319
+ @click.argument("pr_number", type=int)
320
+ @click.pass_context
321
+ def view(ctx, pr_number):
322
+ orch = ctx.obj["ORCHESTRATOR"]
323
+ pr = orch.github.fetch_pr_details(pr_number)
324
+ # Normalize for tool consumption
325
+ normalized_pr = {
326
+ "number": pr.get("number"),
327
+ "title": pr.get("title"),
328
+ "state": pr.get("state"),
329
+ "headRefName": pr.get("head", {}).get("ref"),
330
+ "baseRefName": pr.get("base", {}).get("ref"),
331
+ "html_url": pr.get("html_url"),
332
+ }
333
+ out(
334
+ ctx,
335
+ f"PR #{normalized_pr['number']}: {normalized_pr['title']}\nState: {normalized_pr['state']}",
336
+ data={"pr": normalized_pr},
337
+ )
338
+
339
+
340
+ @gh.command()
341
+ @click.argument("file", required=False)
342
+ @click.option("--base")
343
+ @click.pass_context
344
+ def resolve(ctx, file, base):
345
+ orch = ctx.obj["ORCHESTRATOR"]
346
+ if file:
347
+ if orch.resolve_conflict(file):
348
+ out(ctx, f"✅ Resolved conflicts in {file}", data={"resolved_file": file})
349
+ else:
350
+ err(ctx, f"Failed to resolve conflicts in {file}")
351
+ else:
352
+ resolved = orch.resolve_conflicts_headless()
353
+ out(ctx, f"✅ Resolved {len(resolved)} files.", data={"resolved": resolved})
354
+
355
+
356
+ @gh.command()
357
+ @click.option(
358
+ "--check-dirs",
359
+ default=os.environ.get("AUDIT_CHECK_DIRS", ",".join(PROJECT_CONFIG.audit_check_dirs)),
360
+ help="Comma-separated list of directories to audit",
361
+ )
362
+ @click.pass_context
363
+ def audit(ctx, check_dirs):
364
+ """Run a headless UI audit on the codebase."""
365
+ orch = ctx.obj["ORCHESTRATOR"]
366
+ res = orch.get_audit_results(targets=check_dirs.split(","))
367
+ out(ctx, "Headless audit complete.", data=res)
368
+
369
+
370
+ @gh.command()
371
+ @json_option
372
+ @click.argument("pr_number", type=int)
373
+ @click.option("--fetch", is_flag=True)
374
+ @click.option("--audit", "run_audit", is_flag=True)
375
+ @click.option("--submit", is_flag=True)
376
+ @click.option("--cleanup", is_flag=True)
377
+ @click.option("--dry-run/--execute", default=True)
378
+ @click.option("--base")
379
+ @click.option("--event")
380
+ @click.pass_context
381
+ def audit_pr(ctx, pr_number, fetch, run_audit, submit, cleanup, dry_run, base, event):
382
+ orch = ctx.obj["ORCHESTRATOR"]
383
+ res = orch.audit_pr(
384
+ pr_number,
385
+ fetch=fetch,
386
+ audit=run_audit,
387
+ submit=submit,
388
+ cleanup=cleanup,
389
+ dry_run=dry_run,
390
+ event=event,
391
+ )
392
+ out(ctx, f"✅ Audit PR #{pr_number} action complete.", data=res)
393
+
394
+
395
+ @gh.command()
396
+ @click.option("--title", required=True, help="Issue title")
397
+ @click.option("--file", help="Path to file containing issue body")
398
+ @click.option("--body", help="Literal body text")
399
+ @click.pass_context
400
+ def create_issue(ctx, title, file, body):
401
+ """Create a new GitHub issue."""
402
+ orch = ctx.obj["ORCHESTRATOR"]
403
+
404
+ content = _get_body_content(ctx, orch, file, body)
405
+ res = orch.create_issue(title, content)
406
+ out(ctx, f"✅ Successfully created issue: {res['issue'].get('html_url')}", data=res)
407
+
408
+
409
+ @gh.command()
410
+ @click.argument("issue_number", type=int)
411
+ @click.pass_context
412
+ def issue_view(ctx, issue_number):
413
+ """View details of a GitHub issue."""
414
+ orch = ctx.obj["ORCHESTRATOR"]
415
+ issue = orch.get_issue_details(issue_number)
416
+ msg = f"Issue #{issue.get('number')}: {issue.get('title')}\nState: {issue.get('state')}\n\n{issue.get('body')}"
417
+ out(ctx, msg, data={"issue": issue})
418
+
419
+
420
+ @gh.command()
421
+ @click.argument("issue_number", type=int)
422
+ @click.option("--file", help="Path to file containing new issue body")
423
+ @click.option("--body", help="Literal body text")
424
+ @click.option("--labels", help="Comma-separated list of labels to set (replaces existing labels)")
425
+ @click.option("--add-labels", help="Comma-separated list of labels to add")
426
+ @click.option("--remove-labels", help="Comma-separated list of labels to remove")
427
+ @click.option("--state", type=click.Choice(["open", "closed"]))
428
+ @click.pass_context
429
+ def issue_update(ctx, issue_number, file, body, labels, add_labels, remove_labels, state):
430
+ """Update a GitHub issue's body, labels, and/or state."""
431
+ orch = ctx.obj["ORCHESTRATOR"]
432
+
433
+ label_list = [l.strip() for l in labels.split(",")] if labels else None
434
+ add_label_list = [l.strip() for l in add_labels.split(",")] if add_labels else None
435
+ remove_label_list = [l.strip() for l in remove_labels.split(",")] if remove_labels else None
436
+
437
+ content = None
438
+ if file or body:
439
+ content = _get_body_content(ctx, orch, file, body)
440
+
441
+ res = orch.update_issue(
442
+ issueNumber=issue_number,
443
+ body=content,
444
+ labels=label_list,
445
+ addLabels=add_label_list,
446
+ removeLabels=remove_label_list,
447
+ state=state,
448
+ )
449
+ out(ctx, f"✅ Successfully updated issue #{issue_number}", data=res)
450
+
451
+
452
+ @gh.command()
453
+ @click.argument("issue_number", type=int)
454
+ @click.option("--file", help="Path to file containing comment body")
455
+ @click.option("--body", help="Literal body text")
456
+ @click.pass_context
457
+ def issue_comment(ctx, issue_number, file, body):
458
+ """Post a comment to a GitHub issue."""
459
+ orch = ctx.obj["ORCHESTRATOR"]
460
+ content = _get_body_content(ctx, orch, file, body)
461
+ res = orch.post_comment(issue_number, content)
462
+ out(ctx, f"✅ Successfully posted comment to issue #{issue_number}", data={"comment": res})
463
+
464
+
465
+ @gh.command()
466
+ @click.option("--issue-number", type=int)
467
+ @click.option("--all-open", is_flag=True)
468
+ @click.option("--post-comments", is_flag=True)
469
+ @click.option("--dry-run/--execute", default=True)
470
+ @click.pass_context
471
+ def validate_issue(ctx, issue_number, all_open, post_comments, dry_run):
472
+ orch = ctx.obj["ORCHESTRATOR"]
473
+ res = orch.validate_issue(
474
+ issue_number=issue_number, all_open=all_open, post_comments=post_comments, dry_run=dry_run
475
+ )
476
+ if not ctx.obj["JSON"]:
477
+ for issue in res["issues"]:
478
+ click.echo(f"{'✅' if not issue['findings'] else '❌'} #{issue['number']}: {issue['title'][:60]}")
479
+ for f in issue["findings"]:
480
+ click.echo(f" ❌ {f}")
481
+ for w in issue["warnings"]:
482
+ click.echo(f" ⚠️ {w}")
483
+ if res["status"] == "error":
484
+ err(ctx, f"Found {res['total_findings']} blocking findings.", data=res)
485
+ else:
486
+ out(ctx, "✅ Issue validation complete.", data=res)
487
+
488
+
489
+ @gh.command()
490
+ @click.option("--title", required=True)
491
+ @click.option("--body", required=True)
492
+ @click.option("--head", required=True)
493
+ @click.option("--base", default=PROJECT_CONFIG.base_branch_name)
494
+ @click.option("--draft", is_flag=True)
495
+ @click.pass_context
496
+ def create_pr(ctx, title, body, head, base, draft):
497
+ """Create a new pull request."""
498
+ orch = ctx.obj["ORCHESTRATOR"]
499
+ res = orch.github.create_pull_request(title, body, head, base, draft=draft)
500
+ out(ctx, f"✅ Created PR: {res.get('html_url')}", data={"pr": res})
501
+
502
+
503
+ @gh.command()
504
+ @click.argument("branch")
505
+ @click.pass_context
506
+ def checkout(ctx, branch):
507
+ """Checkout a branch."""
508
+ run_command(["git", "checkout", branch])
509
+ out(ctx, f"✅ Checked out branch {branch}")
510
+
511
+
512
+ @gh.command()
513
+ @click.argument("target_branch")
514
+ @click.argument("pr_numbers", nargs=-1, type=int)
515
+ @click.pass_context
516
+ def aggregate(ctx, target_branch, pr_numbers):
517
+ """Aggregate multiple PRs into a single branch."""
518
+ if not pr_numbers:
519
+ err(ctx, "Provide at least one PR number to aggregate.")
520
+ orch = ctx.obj["ORCHESTRATOR"]
521
+ res = orch.aggregate_prs(target_branch, list(pr_numbers))
522
+ out(ctx, res["message"], data=res)
523
+
524
+
525
+ def _render_conflicts(ctx, conflicts):
526
+ if not ctx.obj["JSON"]:
527
+ if not conflicts:
528
+ click.echo("✅ No potential merge conflicts detected.")
529
+ for c in conflicts:
530
+ prs_str = ' ↔ '.join(f'#{p}' for p in c['prs'])
531
+ files_count = len(c['files'])
532
+ click.echo(f"⚠️ {prs_str} share {files_count} file(s):")
533
+ for f in sorted(c["files"])[:10]:
534
+ click.echo(f" - {f}")
535
+ out(ctx, f"Found {len(conflicts)} potential conflicts.", data={"conflicts": conflicts})
536
+
537
+
538
+ @gh.command()
539
+ @click.pass_context
540
+ def conflicts(ctx):
541
+ orch = ctx.obj["ORCHESTRATOR"]
542
+ conflicts: List[Dict[str, Any]] = orch.handle_detect_conflicts()
543
+ _render_conflicts(ctx, conflicts)
544
+
545
+
546
+ @gh.command()
547
+ @click.option("--pr", required=True, type=int, help="The PR number to resolve conflicts for.")
548
+ @click.option("--allow-unrelated", is_flag=True, help="Allow merging unrelated histories.")
549
+ @click.option(
550
+ "--strategy",
551
+ type=click.Choice(["ours", "theirs"]),
552
+ help="Merge strategy option (-X ours/theirs).",
553
+ )
554
+ @click.option("--push", is_flag=True, help="Automatically push the resolution to origin.")
555
+ @click.option(
556
+ "--continue",
557
+ "continue_resolve",
558
+ is_flag=True,
559
+ help="Finalize and push an in-progress conflict resolution.",
560
+ )
561
+ @click.pass_context
562
+ def resolve_conflicts(ctx, pr, allow_unrelated, strategy, push, continue_resolve):
563
+ """Resolve merge conflicts for a PR in a separate worktree."""
564
+ orch = ctx.obj["ORCHESTRATOR"]
565
+ try:
566
+ res = orch.resolve_pr_conflicts(
567
+ pr,
568
+ allow_unrelated=allow_unrelated,
569
+ strategy=strategy,
570
+ push=push,
571
+ continue_resolve=continue_resolve,
572
+ )
573
+ out(ctx, res["message"], data=res)
574
+ except CLIError as e:
575
+ err(ctx, str(e), code=e.code)
576
+
577
+
578
+ @gh.command()
579
+ @click.argument("diff_input", required=False)
580
+ @click.pass_context
581
+ def verify_versions(ctx, diff_input):
582
+ """Verify version changes in a diff for downgrades or hard blocks."""
583
+ from dev_tools.utils import resolve_resource_path
584
+
585
+ try:
586
+ script_path = resolve_resource_path("verify_versions.py")
587
+ except FileNotFoundError:
588
+ # Absolute fallback if packaging failed
589
+ script_path = os.path.join(os.path.dirname(__file__), "verify_versions.py")
590
+
591
+ if not diff_input:
592
+ # If no input provided, try to get diff against main
593
+ try:
594
+ diff_input = run_command(["git", "diff", PROJECT_CONFIG.base_branch])
595
+ except Exception as e:
596
+ _handle_unexpected_error(ctx, "check-diff", e)
597
+
598
+ # Use a temporary file to avoid E2BIG/ARG_MAX issues with large diffs
599
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp:
600
+ tmp.write(diff_input)
601
+ tmp_path = tmp.name
602
+
603
+ cmd = [sys.executable, script_path, tmp_path]
604
+ try:
605
+ proc = subprocess.run(cmd, capture_output=True, text=True)
606
+ os.unlink(tmp_path)
607
+ if proc.stdout:
608
+ try:
609
+ findings = json.loads(proc.stdout)
610
+ if findings:
611
+ status = "error" if any(f["severity"] == "error" for f in findings) else "success"
612
+ out(
613
+ ctx,
614
+ f"Found {len(findings)} version issues.",
615
+ data={"status": status, "findings": findings},
616
+ )
617
+ if status == "error":
618
+ sys.exit(1)
619
+ else:
620
+ out(
621
+ ctx,
622
+ "✅ No version issues detected.",
623
+ data={"status": "success", "findings": []},
624
+ )
625
+ except json.JSONDecodeError:
626
+ err(ctx, f"Invalid validator output: {proc.stdout}")
627
+ else:
628
+ err(ctx, f"Validator failed: {proc.stderr}")
629
+ except Exception as e:
630
+ _handle_unexpected_error(ctx, "verify-versions", e)
631
+
632
+
633
+ @gh.command()
634
+ @click.option("--pr", type=int)
635
+ @click.pass_context
636
+ def detect_conflicts(ctx, pr):
637
+ orch = ctx.obj["ORCHESTRATOR"]
638
+ conflicts = orch.handle_detect_conflicts(pr_num=pr)
639
+ _render_conflicts(ctx, conflicts)
640
+
641
+
642
+ @gh.command()
643
+ @click.option("--body", help="The comment body text.")
644
+ @click.option("--author-association", required=True, help="The author association (e.g. OWNER, MEMBER).")
645
+ @click.pass_context
646
+ def parse_comment(ctx, body, author_association):
647
+ """Parse a comment body and return intended actions."""
648
+ orch = ctx.obj["ORCHESTRATOR"]
649
+ # Security: Allow reading body from COMMENT_BODY env var to avoid shell injection in CI
650
+ content = body if body else os.environ.get("COMMENT_BODY")
651
+ if not content:
652
+ err(ctx, "Comment body is required (use --body or COMMENT_BODY env var)")
653
+
654
+ actions = orch.parse_comment(content, author_association)
655
+ out(ctx, "Comment parsed successfully.", data={"actions": actions})
656
+
657
+
658
+ @gh.command()
659
+ @click.option("--pr", required=True, type=int, help="The PR number to comment on.")
660
+ @click.option("--file", type=str, help="Path to the file containing the comment body.")
661
+ @click.option("--body", type=str, help="Literal comment text.")
662
+ @click.pass_context
663
+ def post_comment(ctx, pr, file, body):
664
+ """Post a comment to a PR."""
665
+ orch = ctx.obj["ORCHESTRATOR"]
666
+ content = _get_body_content(ctx, orch, file, body)
667
+ res = orch.post_comment(pr, content)
668
+ out(ctx, f"✅ Successfully posted comment to PR #{pr}", data=res)
669
+
670
+
671
+ @gh.command(name="read-pr-comments")
672
+ @click.option("--pr-number", required=True, type=int, help="The PR number to fetch comments for.")
673
+ @click.pass_context
674
+ def read_pr_comments(ctx, pr_number):
675
+ """Fetch and display standard and review comments for a PR."""
676
+ if pr_number <= 0:
677
+ err(ctx, "PR number must be a positive integer.")
678
+
679
+ orch = ctx.obj["ORCHESTRATOR"]
680
+
681
+ res = orch.get_pr_comments(prNumber=pr_number)
682
+
683
+ # Response contract validation
684
+
685
+ if ctx.obj["JSON"]:
686
+ out(ctx, f"Fetched comments for PR #{pr_number}", data=res)
687
+ else:
688
+ pr = res["pr"]
689
+ click.echo(f"PR #{pr['number']}: {pr['title']}")
690
+ click.echo("--- Comments ---")
691
+ for comment in res.get("comments", []):
692
+ body = click.unstyle(comment.get("body", ""))
693
+ click.echo(f"[{comment['user']}]: {body}")
694
+ click.echo("-" * 20)
695
+
696
+ click.echo("\n--- Review Comments ---")
697
+ for comment in res.get("review_comments", []):
698
+ path = comment.get("path")
699
+ line = comment.get("line")
700
+ location = f"{path}:{line}" if line else path
701
+ body = click.unstyle(comment.get("body", ""))
702
+ click.echo(f"[{comment['user']}] in {location}: {body}")
703
+ click.echo("-" * 20)
704
+
705
+
706
+ @gh.command()
707
+ @limit_option(help_text="Limit the number of open PRs to process")
708
+ @click.pass_context
709
+ def status_board(ctx, limit):
710
+ orch = ctx.obj["ORCHESTRATOR"]
711
+ prs = orch.handle_status_board(limit=limit)
712
+ if not ctx.obj["JSON"]:
713
+ click.echo("# Active Agent Work Board\n| Branch | Issue | Status |")
714
+ for pr in prs:
715
+ click.echo(f"| {pr['branch']} | {pr['issue']} | {pr['status']} |")
716
+ out(ctx, f"Found {len(prs)} open PRs.", data={"work": prs})
717
+
718
+
719
+ @gh.command()
720
+ @click.option("--find")
721
+ @click.option("--migrate", nargs=2, type=str)
722
+ @click.option("--dry-run/--execute", default=True)
723
+ @click.pass_context
724
+ def migrate_tokens(ctx, find, migrate, dry_run):
725
+ orch = ctx.obj["ORCHESTRATOR"]
726
+ matches = orch.migrate_tokens(find=find, migrate=migrate, dry_run=dry_run)
727
+ out(ctx, f"Found {len(matches)} matches.", data={"matches": matches})
728
+
729
+
730
+ @gh.command()
731
+ @click.option("--dry-run/--execute", default=True)
732
+ @click.pass_context
733
+ def update_issues(ctx, dry_run):
734
+ orch = ctx.obj["ORCHESTRATOR"]
735
+ updates = orch.update_issues(dry_run=dry_run)
736
+ out(ctx, f"Found {len(updates)} updates.", data={"updates": updates})
737
+
738
+
739
+ @gh.command()
740
+ @click.option("--check-responses", is_flag=True)
741
+ @click.option("--cleanup-comments", is_flag=True)
742
+ @click.option("--dry-run/--execute", default=True)
743
+ @limit_option(help_text="Limit the number of PRs to process")
744
+ @click.pass_context
745
+ def manage_reviews(ctx, check_responses, cleanup_comments, dry_run, limit):
746
+ orch = ctx.obj["ORCHESTRATOR"]
747
+ prs = orch.manage_reviews(
748
+ check_responses=check_responses,
749
+ cleanup_comments=cleanup_comments,
750
+ dry_run=dry_run,
751
+ limit=limit,
752
+ )
753
+ out(ctx, f"Checked {len(prs)} PRs.", data={"prs": prs})
754
+
755
+
756
+ @gh.command()
757
+ @click.pass_context
758
+ def audit_gate(ctx):
759
+ orch = ctx.obj["ORCHESTRATOR"]
760
+ res = orch.handle_audit_gate()
761
+ msg = f"UI Anti-Pattern Audit: Current={res['current']}, Baseline={res['baseline']}"
762
+ if res["status"] == "error":
763
+ err(ctx, msg, data=res)
764
+ else:
765
+ out(ctx, msg, data=res)
766
+
767
+
768
+ @gh.command()
769
+ @click.option("--pr", required=True, type=int)
770
+ @click.option("--status", required=True)
771
+ @click.option("--auditor", required=True)
772
+ @click.option("--dry-run/--execute", default=True)
773
+ @click.pass_context
774
+ def track_review(ctx, pr, status, auditor, dry_run):
775
+ orch = ctx.obj["ORCHESTRATOR"]
776
+ res = orch.track_review(pr, status, auditor, dry_run=dry_run)
777
+ out(ctx, f"✅ Updated tracking for PR #{pr}", data=res)
778
+
779
+
780
+ @cli.command(name="schema")
781
+ @click.argument("command_path", required=False)
782
+ @click.pass_context
783
+ def schema_cmd(ctx, command_path):
784
+ """Retrieve the schema for a specific subcommand or all commands."""
785
+ # Sanitize and validate command_path to prevent injection
786
+ # Allowed format: words separated by single spaces (no special shell characters)
787
+ if command_path:
788
+ import re
789
+
790
+ # Stricter regex: words containing only lowercase alphanumeric, hyphens, and underscores,
791
+ # separated by single spaces. No leading/trailing spaces.
792
+ # This prevents any shell character injection and restricts to valid command tokens.
793
+ word = r"[a-z0-9-_]+"
794
+ pattern = f"^{word}( {word})*$"
795
+ if not re.match(pattern, command_path):
796
+ err(
797
+ ctx,
798
+ "Invalid command path. Only lowercase alphanumeric, hyphens, and underscores separated by single spaces are allowed.",
799
+ )
800
+
801
+ from dev_tools.schema_utils import collect_commands, get_command_by_path
802
+
803
+ target_cmd = get_command_by_path(cli, command_path)
804
+
805
+ # If the path matches a group but not a leaf command, target_cmd will be the group.
806
+ # get_command_by_path returns None if not found at all.
807
+
808
+ if not target_cmd:
809
+ msg = "Command path not found."
810
+ if command_path and " " not in command_path:
811
+ # Provide a small hint for common top-level groups
812
+ if command_path in ["gh", "repo", "config", "ux", "agent", "jules"]:
813
+ msg += f" Did you mean 'td-cli schema {command_path}' to see subcommands?"
814
+ err(ctx, msg)
815
+ return
816
+
817
+ # Use a safe depth limit (5) for granular lookups to maintain performance
818
+ res = collect_commands(target_cmd, prefix=command_path or "", max_depth=5)
819
+ out(ctx, f"Schema for {command_path or 'root'}", data={"schema": res})
820
+
821
+
822
+ def _handle_gate(ctx, res, label):
823
+ msg = f"{label}: Current={res.get('current', res.get('size_kb'))}, Baseline={res.get('baseline', res.get('baseline_kb'))}"
824
+ if res["status"] == "error":
825
+ err(ctx, msg, data=res)
826
+ else:
827
+ out(ctx, msg, data=res)
828
+
829
+
830
+ @gh.command()
831
+ @click.option("--baseline-file")
832
+ @click.option("--update", is_flag=True)
833
+ @click.option("--dry-run/--execute", default=True)
834
+ @click.pass_context
835
+ def ratchet_any(ctx, baseline_file, update, dry_run):
836
+ orch = ctx.obj["ORCHESTRATOR"]
837
+ res = orch.ratchet_any(update=update, baseline_file=baseline_file, dry_run=dry_run)
838
+ _handle_gate(ctx, res, "TypeScript 'any' Ratchet")
839
+
840
+
841
+ @gh.command()
842
+ @click.option("--baseline-file")
843
+ @click.option("--threshold", type=int, default=50)
844
+ @click.option("--update", is_flag=True)
845
+ @click.option("--dry-run/--execute", default=True)
846
+ @click.pass_context
847
+ def bundle_size(ctx, baseline_file, threshold, update, dry_run):
848
+ orch = ctx.obj["ORCHESTRATOR"]
849
+ res = orch.check_bundle_size(update=update, baseline_file=baseline_file, threshold=threshold, dry_run=dry_run)
850
+ _handle_gate(ctx, res, "Bundle Size Check")
851
+
852
+
853
+ @cli.command()
854
+ @click.pass_context
855
+ def doctor(ctx):
856
+ """Runtime Consistency Check"""
857
+ orch = ctx.obj["ORCHESTRATOR"]
858
+ res = orch.runtime_check()
859
+ out(ctx, f"✅ Runtime OK: node {res['node']}, pnpm {res['pnpm']}", data=res)
860
+
861
+
862
+ @gh.command()
863
+ @click.pass_context
864
+ def verify_metrics(ctx):
865
+ """Verify CI metrics against established thresholds."""
866
+ res = verify_ci_metrics()
867
+ if res["status"] == "error":
868
+ err(ctx, res["message"], data=res)
869
+ else:
870
+ out(ctx, res["message"], data=res)
871
+
872
+
873
+ @gh.command()
874
+ @click.pass_context
875
+ def summary_report(ctx):
876
+ """Generate a markdown report of CI metrics for GHA Step Summary."""
877
+ orch = ctx.obj["ORCHESTRATOR"]
878
+ report = orch.generate_ci_summary_report()
879
+ # Always print as raw text to stdout for GHA redirection
880
+ click.echo(report)
881
+
882
+
883
+ @gh.command()
884
+ @click.pass_context
885
+ def pre_submit(ctx):
886
+ orch = ctx.obj["ORCHESTRATOR"]
887
+ res = orch.pre_submit_checks()
888
+ out(ctx, "Pre-submit checks complete.", data={"results": res})
889
+
890
+
891
+ @gh.command()
892
+ @limit_option(help_text="Limit the number of open PRs to process")
893
+ @click.pass_context
894
+ def overlaps(ctx, limit):
895
+ """Identify and propose consolidation of PRs with high functional or structural overlap."""
896
+ from dev_tools.utils import resolve_resource_path
897
+
898
+ try:
899
+ script_path = resolve_resource_path("pr_overlap.py")
900
+ except FileNotFoundError:
901
+ # Absolute fallback if packaging failed
902
+ script_path = os.path.join(os.path.dirname(__file__), "pr_overlap.py")
903
+ cmd = [sys.executable, script_path, "--limit", str(limit)]
904
+
905
+ # Check global NO_CACHE from context object
906
+ if ctx.obj.get("NO_CACHE"):
907
+ cmd.append("--no-cache")
908
+
909
+ try:
910
+ subprocess.run(cmd, check=True)
911
+ except subprocess.CalledProcessError as e:
912
+ err(ctx, f"pr_overlap.py failed with exit code {e.returncode}")
913
+
914
+
915
+ # ==========================================
916
+ # UX COMMAND GROUP
917
+ # ==========================================
918
+
919
+
920
+ @cli.group()
921
+ def ux():
922
+ """UX Audit Operations"""
923
+
924
+
925
+ @ux.command(name="audit")
926
+ @click.option("--route", help="Specific route to audit")
927
+ @click.option("--all-routes", is_flag=True, help="Audit all discovered routes")
928
+ @click.option("--desktop", is_flag=True, help="Audit desktop viewports")
929
+ @click.option("--mobile", is_flag=True, help="Audit mobile viewports")
930
+ @click.pass_context
931
+ def ux_audit(ctx, route, all_routes, desktop, mobile):
932
+ orch = ctx.obj["ORCHESTRATOR"]
933
+ res = orch.run_ux_audit(route=route, all_routes=all_routes, desktop=desktop, mobile=mobile)
934
+ out(ctx, "UX Audit complete.", data=res)
935
+
936
+
937
+ @ux.command()
938
+ @click.pass_context
939
+ def report(ctx):
940
+ orch = ctx.obj["ORCHESTRATOR"]
941
+ res = orch.generate_ux_report()
942
+ out(ctx, "UX Report generated.", data=res)
943
+
944
+
945
+ @ux.command()
946
+ @click.option("--route", help="Specific route for Lighthouse")
947
+ @click.pass_context
948
+ def lighthouse(ctx, route):
949
+ orch = ctx.obj["ORCHESTRATOR"]
950
+ res = orch.run_lighthouse(route=route)
951
+ out(ctx, "Lighthouse audit complete.", data=res)
952
+
953
+
954
+ @ux.command()
955
+ @click.option("--route", help="Specific route for screenshots")
956
+ @click.pass_context
957
+ def screenshots(ctx, route):
958
+ orch = ctx.obj["ORCHESTRATOR"]
959
+ res = orch.run_ux_audit(route=route, screenshots_only=True)
960
+ out(ctx, "Screenshots captured.", data=res)
961
+
962
+
963
+ @ux.command()
964
+ @click.option("--route", help="Specific route for image audit")
965
+ @click.pass_context
966
+ def images(ctx, route):
967
+ orch = ctx.obj["ORCHESTRATOR"]
968
+ res = orch.run_ux_audit(route=route, images_only=True)
969
+ out(ctx, "Image audit complete.", data=res)
970
+
971
+
972
+ @ux.command()
973
+ @click.option("--route", help="Specific route for contrast check")
974
+ @click.pass_context
975
+ def contrast(ctx, route):
976
+ orch = ctx.obj["ORCHESTRATOR"]
977
+ res = orch.run_ux_audit(route=route, contrast_only=True)
978
+ out(ctx, "Contrast check complete.", data=res)
979
+
980
+
981
+ @ux.command()
982
+ @click.option("--route", help="Specific route for overflow check")
983
+ @click.pass_context
984
+ def overflow(ctx, route):
985
+ orch = ctx.obj["ORCHESTRATOR"]
986
+ res = orch.run_ux_audit(route=route, overflow_only=True)
987
+ out(ctx, "Overflow check complete.", data=res)
988
+
989
+
990
+ @cli.command()
991
+ @click.pass_context
992
+ def build(ctx):
993
+ """Build the project after runtime check"""
994
+ orch = ctx.obj["ORCHESTRATOR"]
995
+ orch.runtime_check()
996
+ run_command(["pnpm", "run", "build"])
997
+ out(ctx, "✅ Build complete")
998
+
999
+
1000
+ @cli.command()
1001
+ @click.pass_context
1002
+ def lint(ctx):
1003
+ """Lint the project after runtime check"""
1004
+ orch = ctx.obj["ORCHESTRATOR"]
1005
+ orch.runtime_check()
1006
+ run_command(["pnpm", "run", "lint"])
1007
+ out(ctx, "✅ Lint complete")
1008
+
1009
+
1010
+ @cli.group()
1011
+ def test():
1012
+ """Testing Operations"""
1013
+
1014
+
1015
+ @test.command(name="cli")
1016
+ @click.pass_context
1017
+ def test_cli(ctx):
1018
+ """Run CLI package tests"""
1019
+ orch = ctx.obj["ORCHESTRATOR"]
1020
+ orch.runtime_check()
1021
+ # pytest options are now in pyproject.toml
1022
+ # Get the directory of the current file to find the cli package root
1023
+ cli_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
1024
+ try:
1025
+ subprocess.run([sys.executable, "-m", "pytest", "tests/"], cwd=cli_dir, check=True)
1026
+ out(ctx, "✅ CLI tests passed")
1027
+ except subprocess.CalledProcessError as e:
1028
+ err(ctx, f"CLI tests failed with exit code {e.returncode}")
1029
+
1030
+
1031
+ # ==========================================
1032
+ # AI COMMAND GROUP
1033
+ # ==========================================
1034
+
1035
+
1036
+ @cli.group()
1037
+ def ai():
1038
+ """AI Operations"""
1039
+
1040
+
1041
+ @ai.command()
1042
+ @click.argument("pr_number", type=int)
1043
+ @click.option(
1044
+ "--no-cache",
1045
+ is_flag=True,
1046
+ default=False,
1047
+ help="Bust the diff cache and force a fresh review call",
1048
+ )
1049
+ @click.pass_context
1050
+ def review(ctx, pr_number, no_cache):
1051
+ # Optionally bust the review cache so stale results are not silently returned
1052
+ if no_cache:
1053
+ review_dir = get_or_create_log_dir("reviews")
1054
+ pattern = os.path.join(review_dir, f"review_cache_{pr_number}_*.json")
1055
+ removed = glob.glob(pattern)
1056
+ for f in removed:
1057
+ os.remove(f)
1058
+ if removed:
1059
+ log_info(f"🗑 Removed {len(removed)} cached diff file(s): {removed}")
1060
+ else:
1061
+ log_info("ℹ️ No cache files found to remove.")
1062
+
1063
+ orch = ctx.obj["ORCHESTRATOR"]
1064
+ res = orch.review_pr(pr_number)
1065
+
1066
+ # Surface errors clearly
1067
+ if (
1068
+ isinstance(res, dict)
1069
+ and res.get("recommendation") == "Not Approved"
1070
+ and not res.get("reviewComment", "").strip().startswith("CI")
1071
+ ):
1072
+ # Likely an error result – dump full dict to stderr for diagnosis
1073
+ log_info(f"""⚠️ Review returned 'Not Approved' (may indicate an error).
1074
+ recommendation : {res.get('recommendation')}
1075
+ reviewComment : {res.get('reviewComment', '')[:500]}""")
1076
+
1077
+ out(ctx, f"✅ Generated review for PR #{pr_number}", data=res)
1078
+
1079
+
1080
+ @ai.command(name="get-context")
1081
+ @click.pass_context
1082
+ def ai_get_context(ctx):
1083
+ """Retrieve dependency and semantic context for a set of changed files."""
1084
+ try:
1085
+ raw_data = sys.stdin.read()
1086
+ if not raw_data.strip():
1087
+ err(ctx, "Empty input from stdin")
1088
+ input_data = json.loads(raw_data)
1089
+ except Exception as e:
1090
+ _handle_unexpected_error(ctx, "ai batch-context", e)
1091
+
1092
+ if not isinstance(input_data, dict):
1093
+ err(ctx, "Input JSON must be a dictionary")
1094
+
1095
+ if "files" not in input_data:
1096
+ err(ctx, "Input JSON missing required 'files' key")
1097
+
1098
+ files_data = input_data.get("files", [])
1099
+ if not files_data:
1100
+ click.echo(json.dumps([]))
1101
+ return
1102
+
1103
+ orch = ctx.obj["ORCHESTRATOR"]
1104
+ results = []
1105
+
1106
+ ai_client = orch.ai
1107
+ graph = ai_client.dependency_graph
1108
+ store = ai_client.vector_store
1109
+
1110
+ for item in files_data:
1111
+ filepath = item.get("path")
1112
+ diff_text = item.get("diff")
1113
+ if not filepath:
1114
+ continue
1115
+
1116
+ context = {
1117
+ "path": filepath,
1118
+ "dependencies": graph.get_dependencies(filepath),
1119
+ "dependents": graph.get_dependents(filepath),
1120
+ "semantic": [],
1121
+ }
1122
+
1123
+ if diff_text and store.is_available():
1124
+ try:
1125
+ semantic_results = store.query(diff_text, n_results=3)
1126
+ for res in semantic_results:
1127
+ if res["metadata"].get("path") != filepath:
1128
+ context["semantic"].append({"path": res["metadata"].get("path"), "document": res["document"]})
1129
+ except Exception as e:
1130
+ log_warn(f"Error querying vector store for {filepath}: {e}")
1131
+
1132
+ results.append(context)
1133
+
1134
+ # Output raw JSON list for compatibility with existing consumers
1135
+ click.echo(json.dumps(results))
1136
+
1137
+
1138
+ @ai.command()
1139
+ @click.argument("file")
1140
+ @click.pass_context
1141
+ def analyze(ctx, file):
1142
+ orch = ctx.obj["ORCHESTRATOR"]
1143
+ res = orch.analyze_file(file)
1144
+ out(ctx, f"✅ Analyzed {file}", data={"result": res})
1145
+
1146
+
1147
+ @ai.command()
1148
+ @click.option("--pr", required=True, type=int)
1149
+ @click.option("--command", required=True)
1150
+ @click.option("--comment-id")
1151
+ @click.pass_context
1152
+ def comment(ctx, pr, command, comment_id):
1153
+ orch = ctx.obj["ORCHESTRATOR"]
1154
+ res = orch.handle_comment_command(pr, command, comment_id)
1155
+ if res.get("status") == "error":
1156
+ err(ctx, res.get("message"), data=res)
1157
+ else:
1158
+ out(ctx, res.get("message"), data=res)
1159
+
1160
+
1161
+ # ==========================================
1162
+ # AGENT COMMAND GROUP
1163
+ # ==========================================
1164
+
1165
+
1166
+ @cli.group(name="agent")
1167
+ def agent_group():
1168
+ """Agent Operations"""
1169
+
1170
+
1171
+ @agent_group.command()
1172
+ @click.argument("branch")
1173
+ @click.argument("task")
1174
+ @click.pass_context
1175
+ def dispatch(ctx, branch, task):
1176
+ """
1177
+ Initialize a Jules session for a specific branch and task.
1178
+ Note: Use 'main' branch for PR consolidation tasks to avoid rebasing issues.
1179
+ """
1180
+ orch = ctx.obj["ORCHESTRATOR"]
1181
+ res = orch.dispatch_jules_review(branch, task)
1182
+ out(ctx, f"✅ Dispatched task on branch {branch}", data=res)
1183
+
1184
+
1185
+ @agent_group.command()
1186
+ @limit_option(help_text="Limit the number of sessions to retrieve")
1187
+ @click.pass_context
1188
+ def sync(ctx, limit):
1189
+ """Sync active agent sessions."""
1190
+ orch = ctx.obj["ORCHESTRATOR"]
1191
+ sessions = orch.jules.list_sessions(pageSize=limit)
1192
+
1193
+ if not ctx.obj["JSON"]:
1194
+ if not sessions:
1195
+ click.echo("No active agent sessions found.")
1196
+ else:
1197
+ click.echo(f"{'Session ID':<20} | {'Status':<15} | {'Created':<25}")
1198
+ click.echo("-" * 65)
1199
+ for s in sessions:
1200
+ sid = s.get("name", "N/A").split("/")[-1]
1201
+ state = s.get("state", "UNKNOWN")
1202
+ created = s.get("createTime", "N/A")
1203
+ click.echo(f"{sid:<20} | {state:<15} | {created:<25}")
1204
+
1205
+ out(ctx, "Agent sync complete.", data={"sessions": sessions})
1206
+
1207
+ @agent_group.command()
1208
+ @click.option("--pr-number", type=int)
1209
+ @click.option("--issue-number", type=int)
1210
+ @click.option("--branch")
1211
+ @click.option("--api-key")
1212
+ @click.option("--dry-run/--execute", default=True)
1213
+ @click.pass_context
1214
+ def fix_ci(ctx, pr_number, issue_number, branch, api_key, dry_run):
1215
+ orch = ctx.obj["ORCHESTRATOR"]
1216
+ res = orch.fix_ci(
1217
+ pr_number=pr_number,
1218
+ issue_number=issue_number,
1219
+ branch=branch,
1220
+ api_key=api_key,
1221
+ dry_run=dry_run,
1222
+ )
1223
+ agent_name = res.get("agent_name", "Jules")
1224
+ out(ctx, f"🚀 Initialized {agent_name} session for branch `{res['branch']}`", data=res)
1225
+
1226
+
1227
+ @agent_group.command()
1228
+ @click.option("--log")
1229
+ @click.option("--file")
1230
+ @click.option("--pr", type=int)
1231
+ @click.pass_context
1232
+ def repair_context(ctx, log, file, pr):
1233
+ orch = ctx.obj["ORCHESTRATOR"]
1234
+ res = orch.repair_context(log=log, log_file=file, pr_number=pr)
1235
+ out(ctx, f"Generated {len(res)} prompts.", data={"prompts": res})
1236
+
1237
+
1238
+ @agent_group.command()
1239
+ @click.option("--logs")
1240
+ @click.option("--stdin", is_flag=True)
1241
+ @click.option("--worktree", is_flag=True)
1242
+ @click.pass_context
1243
+ def repair(ctx, logs, stdin, worktree):
1244
+ orch = ctx.obj["ORCHESTRATOR"]
1245
+ res = orch.repair_local(logs_path=logs, stdin=stdin, worktree=worktree)
1246
+ if res["status"] == "success":
1247
+ out(ctx, res["message"], data=res)
1248
+ else:
1249
+ err(ctx, res["message"], data=res)
1250
+
1251
+
1252
+ @agent_group.command()
1253
+ @click.argument("session_id")
1254
+ @click.pass_context
1255
+ def cancel(ctx, session_id):
1256
+ """Cancel a Jules session."""
1257
+ orch = ctx.obj["ORCHESTRATOR"]
1258
+ res = orch.jules.cancel_session(session_id)
1259
+ out(ctx, f"✅ Session {session_id} cancelled", data=res)
1260
+
1261
+
1262
+ @agent_group.command()
1263
+ @click.argument("session_id")
1264
+ @click.pass_context
1265
+ def get_session(ctx, session_id):
1266
+ """Get details of a Jules session."""
1267
+ orch = ctx.obj["ORCHESTRATOR"]
1268
+ session = orch.jules.get_session(session_id)
1269
+ out(ctx, f"Session {session_id} details retrieved", data={"session": session})
1270
+
1271
+
1272
+ @agent_group.command()
1273
+ @click.argument("session_id")
1274
+ @click.pass_context
1275
+ def trigger_feedback(ctx, session_id):
1276
+ """Trigger CI feedback for a Jules session."""
1277
+ orch = ctx.obj["ORCHESTRATOR"]
1278
+ res = orch.trigger_jules_feedback(session_id)
1279
+ out(ctx, "Feedback triggered", data=res)
1280
+
1281
+
1282
+ @agent_group.command()
1283
+ @click.argument("session_id")
1284
+ @click.pass_context
1285
+ def messages(ctx, session_id):
1286
+ """Get message history for a Jules session."""
1287
+ orch = ctx.obj["ORCHESTRATOR"]
1288
+ msgs = orch.jules.get_messages(session_id)
1289
+ if not ctx.obj["JSON"]:
1290
+ if not msgs:
1291
+ click.echo(f"No messages found for session {session_id}")
1292
+ else:
1293
+ for m in msgs:
1294
+ role = m["role"].upper()
1295
+ click.echo(f"[{m['time']}] {role}:")
1296
+ click.echo(m["content"])
1297
+ click.echo("-" * 40)
1298
+ out(ctx, f"Messages retrieved for {session_id}", data={"messages": msgs})
1299
+
1300
+
1301
+ @agent_group.command()
1302
+ @click.argument("session_ids")
1303
+ @click.argument("message")
1304
+ @click.pass_context
1305
+ def send(ctx, session_ids, message):
1306
+ """Send a message to active Jules session(s) (supports comma-separated IDs)."""
1307
+ orch = ctx.obj["ORCHESTRATOR"]
1308
+
1309
+ # Support comma-separated IDs for batching
1310
+ target_ids = [s.strip() for s in session_ids.split(",")] if "," in session_ids else session_ids
1311
+
1312
+ from dev_tools.models import JulesSendMessageInput
1313
+ from pydantic import ValidationError
1314
+
1315
+ try:
1316
+ JulesSendMessageInput(sessionId=target_ids, message=message)
1317
+ except ValidationError as e:
1318
+ # Extract specific error message for better feedback
1319
+ msg = f"Validation failed: {e.errors()[0]['msg']}"
1320
+ err(ctx, msg, code=1)
1321
+ except Exception as e:
1322
+ _handle_unexpected_error(ctx, "agent send", e)
1323
+
1324
+ res = orch.jules.send_message(target_ids, message)
1325
+
1326
+ # Provide clear summary for batch operations
1327
+ summary = res.get("message", f"Message sent to session(s) {session_ids}")
1328
+ if not summary.startswith("✅"):
1329
+ summary = f"✅ {summary}"
1330
+
1331
+ out(ctx, summary, data=res)
1332
+
1333
+
1334
+ @agent_group.command(name="plan-review")
1335
+ @click.option("--pr", "pr_number", required=True, type=int, help="Pull Request number")
1336
+ @click.option("--issue", "issue_number", type=int, help="Issue number")
1337
+ @click.pass_context
1338
+ def plan_review(ctx, pr_number, issue_number):
1339
+ """Generate a deterministic review workflow plan for an agent."""
1340
+ orch = ctx.obj["ORCHESTRATOR"]
1341
+ try:
1342
+ res = orch.generate_review_workflow(pr_number, issue_number)
1343
+ out(ctx, f"Workflow plan generated: {res['plan_path']}", data=res)
1344
+ except Exception as e:
1345
+ _handle_unexpected_error(ctx, "agent plan-review", e)
1346
+
1347
+
1348
+ @agent_group.command(name="plan-aggregation")
1349
+ @click.option(
1350
+ "--pr",
1351
+ "--prs",
1352
+ "pr_numbers",
1353
+ required=True,
1354
+ type=int,
1355
+ multiple=True,
1356
+ help="PR numbers to aggregate",
1357
+ )
1358
+ @click.option("--target", required=True, help="Target branch name for aggregation")
1359
+ @click.pass_context
1360
+ def plan_aggregation(ctx, pr_numbers, target):
1361
+ """Generate a deterministic aggregation workflow plan for an agent."""
1362
+ orch = ctx.obj["ORCHESTRATOR"]
1363
+ try:
1364
+ res = orch.generate_aggregation_workflow(list(pr_numbers), target)
1365
+ out(ctx, f"Workflow plan generated: {res['plan_path']}", data=res)
1366
+ except Exception as e:
1367
+ _handle_unexpected_error(ctx, "agent plan-aggregation", e)
1368
+
1369
+
1370
+ @agent_group.command(name="plan-issue-audit")
1371
+ @json_option
1372
+ @click.option(
1373
+ "--issue",
1374
+ "--issues",
1375
+ "issue_numbers",
1376
+ multiple=True,
1377
+ type=int,
1378
+ help="Issue number(s) to audit (e.g. --issue 1 --issue 2)",
1379
+ )
1380
+ @click.option("--all-open", is_flag=True, help="Audit all open issues")
1381
+ @limit_option(help_text="Limit the number of open issues to process")
1382
+ @click.pass_context
1383
+ def plan_issue_audit(ctx, issue_numbers, all_open, limit):
1384
+ """Generate deterministic roadmap and checklist for auditing open issues."""
1385
+ orch = ctx.obj["ORCHESTRATOR"]
1386
+ try:
1387
+ res = orch.plan_issue_audit(issue_numbers=list(issue_numbers), all_open=all_open, limit=limit)
1388
+ out(ctx, f"Issue audit plan generated. Summary: {res['status_file']}", data=res)
1389
+ except Exception as e:
1390
+ _handle_unexpected_error(ctx, "agent plan-issue-audit", e)
1391
+
1392
+
1393
+ @agent_group.command(name="plan-workflow-audit")
1394
+ @click.option("--workflow", help="Specific workflow file to audit")
1395
+ @click.pass_context
1396
+ def plan_workflow_audit(ctx, workflow):
1397
+ """Generate deterministic roadmap and checklist for auditing GitHub workflows."""
1398
+ orch = ctx.obj["ORCHESTRATOR"]
1399
+ try:
1400
+ res = orch.plan_workflow_audit(workflow=workflow)
1401
+ out(ctx, f"Workflow audit plan generated. Summary: {res['status_file']}", data=res)
1402
+ except Exception as e:
1403
+ _handle_unexpected_error(ctx, "agent plan-workflow-audit", e)
1404
+
1405
+
1406
+ @agent_group.command(name="run-feedback-check")
1407
+ @limit_option(help_text="Limit the number of active sessions to check")
1408
+ @click.pass_context
1409
+ def run_feedback_check(ctx, limit):
1410
+ """Run a one-shot Automated Agent Feedback Check to trigger CI feedback for active sessions."""
1411
+ try:
1412
+ from dev_tools.daemon import JulesFeedbackDaemon
1413
+
1414
+ daemon_instance = JulesFeedbackDaemon()
1415
+ daemon_instance.run(limit=limit)
1416
+ out(ctx, "Feedback check execution completed.", data={"status": "success"})
1417
+ except Exception as e:
1418
+ _handle_unexpected_error(ctx, "agent run-feedback-check", e)
1419
+
1420
+
1421
+ @cli.command(name="context-warm")
1422
+ @click.option("--force", is_flag=True, help="Force re-generation of context even if not stale")
1423
+ @click.pass_context
1424
+ def context_warm(ctx, force):
1425
+ """Pre-warm the .agent-context.json repository index."""
1426
+ context_file = ".agent-context.json"
1427
+
1428
+ # Determine if the context file is stale or missing
1429
+ is_stale = not os.path.exists(context_file) or force
1430
+
1431
+ if not is_stale:
1432
+ try:
1433
+ # Compare the last commit timestamp of the context file with the HEAD commit
1434
+ # This handles branch switches where the working directory might be clean but context is old
1435
+ res = subprocess.run(
1436
+ ["git", "log", "-1", "--format=%ct", "--", context_file],
1437
+ capture_output=True,
1438
+ text=True,
1439
+ check=True,
1440
+ )
1441
+ context_ts = int(res.stdout.strip() or 0)
1442
+
1443
+ res_head = subprocess.run(["git", "log", "-1", "--format=%ct"], capture_output=True, text=True, check=True)
1444
+ head_ts = int(res_head.stdout.strip() or 0)
1445
+
1446
+ if head_ts > context_ts:
1447
+ is_stale = True
1448
+ else:
1449
+ # Also check for uncommitted changes in the working directory
1450
+ res_diff = subprocess.run(
1451
+ ["git", "diff", "--name-only", "HEAD"],
1452
+ capture_output=True,
1453
+ text=True,
1454
+ check=True,
1455
+ )
1456
+ is_stale = bool(res_diff.stdout.strip())
1457
+ except Exception as e:
1458
+ log_warn(f"Error checking context staleness via git: {e}")
1459
+ # Fallback to simple mtime check if git fails
1460
+ mtime = os.path.getmtime(context_file)
1461
+ if os.path.exists("package.json") and os.path.getmtime("package.json") > mtime:
1462
+ is_stale = True
1463
+
1464
+ if is_stale:
1465
+ log_info("🧊 Context is stale or missing. Warming up...")
1466
+ try:
1467
+ # Use dynamic resolution from ProjectConfig for the context builder script
1468
+ script_path = PROJECT_CONFIG.context_builder_script
1469
+ if not os.path.exists(script_path):
1470
+ err(ctx, f"Context builder script not found at {script_path}")
1471
+
1472
+ with open(context_file, "w", encoding="utf-8") as f:
1473
+ try:
1474
+ subprocess.run([sys.executable, script_path], stdout=f, check=True)
1475
+ except subprocess.CalledProcessError as e:
1476
+ err(
1477
+ ctx,
1478
+ f"Context builder script failed (exit {e.returncode}): {e.stderr or str(e)}",
1479
+ )
1480
+
1481
+ out(ctx, "✅ Context pre-warmed successfully.", data={"path": context_file})
1482
+ except Exception as e:
1483
+ _handle_unexpected_error(ctx, "context-warm", e)
1484
+ else:
1485
+ out(ctx, "✅ Context is up-to-date.", data={"path": context_file})
1486
+
1487
+
1488
+ # Register aliases for backwards compatibility
1489
+
1490
+
1491
+ @cli.group(name="jules")
1492
+ def jules_group():
1493
+ """Agent Operations (alias for agent)"""
1494
+
1495
+
1496
+ for group in [jules_group]:
1497
+ group.add_command(dispatch)
1498
+ group.add_command(sync)
1499
+ group.add_command(fix_ci)
1500
+ group.add_command(repair_context)
1501
+ group.add_command(repair)
1502
+ group.add_command(cancel)
1503
+ group.add_command(get_session)
1504
+ group.add_command(trigger_feedback)
1505
+ group.add_command(messages)
1506
+ group.add_command(send)
1507
+ group.add_command(plan_review)
1508
+ group.add_command(plan_aggregation)
1509
+
1510
+ for group in [agent_group, jules_group]:
1511
+ group.add_command(get_session, name="session")
1512
+ group.add_command(sync, name="list")
1513
+ group.add_command(sync, name="list-sessions")
1514
+
1515
+
1516
+ def main():
1517
+ # click entry point automatically handles sys.argv
1518
+ try:
1519
+ cli(obj={})
1520
+ except Exception as e:
1521
+ # If we are in JSON mode, we should ideally output JSON error.
1522
+ # Detecting JSON mode from sys.argv since click context isn't available here yet if it failed early.
1523
+ # Note: {PROJECT_CONFIG.cli_alias} subcommands are JSON by default.
1524
+ is_json = "--no-json" not in sys.argv
1525
+
1526
+ if is_json:
1527
+ error_payload = {"status": "error", "message": str(e), "type": e.__class__.__name__}
1528
+ # CLIError and some others might have a custom 'code' attribute
1529
+ code = getattr(e, "code", 1)
1530
+ error_payload["code"] = code
1531
+ # JSON errors remain on stdout to maintain the contract for piped machine consumers
1532
+ # (e.g. boomtick-mcp) which may discard stderr via 2>/dev/null.
1533
+ print(json.dumps(error_payload, indent=2))
1534
+ else:
1535
+ log_error(str(e))
1536
+ code = getattr(e, "code", 1)
1537
+
1538
+ sys.exit(code)
1539
+
1540
+
1541
+ if __name__ == "__main__":
1542
+ main()