refactorai-cli 0.7.18__tar.gz → 0.7.20__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/PKG-INFO +1 -1
  2. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/pyproject.toml +1 -1
  3. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/run_cmds.py +16 -2
  5. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/watch_cmds.py +70 -5
  6. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/review_runner.py +62 -10
  7. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/watch_state.py +17 -1
  8. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli.egg-info/PKG-INFO +1 -1
  9. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/README.md +0 -0
  10. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/auth.py +0 -0
  11. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/client.py +0 -0
  12. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/cloud_rr.py +0 -0
  13. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/__init__.py +0 -0
  14. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/account_cmds.py +0 -0
  15. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/auth_cmds.py +0 -0
  16. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/branch_cmds.py +0 -0
  17. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/cloud_cmds.py +0 -0
  18. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/engine_cmds.py +0 -0
  19. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/hook_cmds.py +0 -0
  20. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/model_cmds.py +0 -0
  21. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/policy_cmds.py +0 -0
  22. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/pre_push_cmds.py +0 -0
  23. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/request_cmds.py +0 -0
  24. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/rules_cmds.py +0 -0
  25. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/runtime_cmds.py +0 -0
  26. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  27. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/setup_cmds.py +0 -0
  28. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  29. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commands/workspace_cmds.py +0 -0
  30. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commit_queue.py +0 -0
  31. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/commit_telemetry.py +0 -0
  32. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/control_plane.py +0 -0
  33. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/credentials.py +0 -0
  34. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/dotenv_loader.py +0 -0
  35. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/git_scope.py +0 -0
  36. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/local_constitution.py +0 -0
  37. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/local_engine_runtime.py +0 -0
  38. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/local_paths.py +0 -0
  39. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/main.py +0 -0
  40. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/mode_sync.py +0 -0
  41. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/model_policy.py +0 -0
  42. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/pre_push_gate.py +0 -0
  43. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/refactor_branch_store.py +0 -0
  44. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/runtime_manager.py +0 -0
  45. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/settings.py +0 -0
  46. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/setup_flow.py +0 -0
  47. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/watch_ledger.py +0 -0
  48. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli/watch_supervisor.py +0 -0
  49. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli.egg-info/SOURCES.txt +0 -0
  50. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  51. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli.egg-info/entry_points.txt +0 -0
  52. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli.egg-info/requires.txt +0 -0
  53. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/refactorai_cli.egg-info/top_level.txt +0 -0
  54. {refactorai_cli-0.7.18 → refactorai_cli-0.7.20}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.7.18
3
+ Version: 0.7.20
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.7.18"
3
+ version = "0.7.20"
4
4
  description = "Local-first CLI for the refactor platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -5,4 +5,4 @@ the shared `refactor_core` pipeline from a project folder while staying
5
5
  authenticated to the hosted platform via a developer key.
6
6
  """
7
7
 
8
- __version__ = "0.7.18"
8
+ __version__ = "0.7.20"
@@ -1846,8 +1846,18 @@ def _project_languages(project_root: Path) -> set[str]:
1846
1846
 
1847
1847
 
1848
1848
  def _required_lsp_commands(project_root: Path) -> dict[str, str]:
1849
- """Return required LSP command -> friendly name based on inferred languages."""
1850
- from refactor_core.codeintel.lsp import LSP_SERVERS
1849
+ """Return required LSP command -> friendly name based on inferred languages.
1850
+
1851
+ The LSP server registry lives in the engine-only ``codeintel`` subpackage,
1852
+ which is absent from a public client install. On such installs (every
1853
+ server-engine variant: local_byok / local_managed / cloud_*), LSP preflight
1854
+ is simply skipped -- it is best-effort sandbox enrichment, not required for a
1855
+ remote-engine run -- rather than crashing the command.
1856
+ """
1857
+ try:
1858
+ from refactor_core.codeintel.lsp import LSP_SERVERS
1859
+ except ModuleNotFoundError:
1860
+ return {}
1851
1861
 
1852
1862
  required: dict[str, str] = {}
1853
1863
  for language in sorted(_project_languages(project_root)):
@@ -3398,6 +3408,10 @@ def review(
3398
3408
  # must propagate as-is, not be re-wrapped into a generic failure.
3399
3409
  raise
3400
3410
  except Exception as exc: # provider/transport errors should not crash the CLI
3411
+ if os.environ.get("REFACTOR_DEBUG"):
3412
+ import traceback
3413
+
3414
+ console.print("[dim]" + traceback.format_exc() + "[/dim]")
3401
3415
  console.print(f"[red]Review failed:[/red] {_sanitize_runtime_error_message(exc)}")
3402
3416
  raise typer.Exit(code=1) from exc
3403
3417
  finally:
@@ -14,8 +14,10 @@ headless). The heavy lifting (state machine, rendering, review parsing) lives in
14
14
  from __future__ import annotations
15
15
 
16
16
  import contextlib
17
+ import inspect
17
18
  import json
18
19
  import os
20
+ import re
19
21
  import select
20
22
  import subprocess
21
23
  import sys
@@ -461,6 +463,53 @@ def _report_refactor_requests(project_root: Path) -> None:
461
463
  pass
462
464
 
463
465
 
466
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
467
+
468
+ # Map the review's stable stdout milestones to short, live sub-step labels shown
469
+ # on the reviewing node. Ordered: first match wins. Kept tolerant (substring /
470
+ # simple regex) so minor wording changes degrade to "no update" rather than
471
+ # crashing. Each entry is (compiled pattern, formatter(match) -> label).
472
+ _PROGRESS_PATTERNS: list[tuple[re.Pattern[str], Callable[[re.Match[str]], str]]] = [
473
+ (re.compile(r"batch\s+(\d+)\s*/\s*(\d+)", re.I), lambda m: f"reviewing batch {m.group(1)}/{m.group(2)}"),
474
+ (re.compile(r"split into\s+(\d+)\s+batch", re.I), lambda m: f"planning {m.group(1)} batch(es)"),
475
+ (re.compile(r"sandbox ready", re.I), lambda m: "sandbox ready"),
476
+ (re.compile(r"ensuring sandbox|sandbox:", re.I), lambda m: "preparing sandbox"),
477
+ (re.compile(r"review scope", re.I), lambda m: "resolving scope"),
478
+ (re.compile(r"security baseline", re.I), lambda m: "security scan"),
479
+ (re.compile(r"Created\s+\d+\s+refactor request", re.I), lambda m: "creating requests"),
480
+ (re.compile(r"\bgate:\s*", re.I), lambda m: "finalizing gate"),
481
+ (re.compile(r"\bindex", re.I), lambda m: "indexing files"),
482
+ ]
483
+
484
+
485
+ def _summarize_progress(line: str) -> str:
486
+ """Reduce one raw review stdout line to a short sub-step label, or ``""``.
487
+
488
+ Returns ``""`` for lines that carry no useful milestone so the caller can
489
+ skip a redundant repaint.
490
+ """
491
+ text = _ANSI_RE.sub("", line or "").strip()
492
+ if not text:
493
+ return ""
494
+ for pattern, fmt in _PROGRESS_PATTERNS:
495
+ match = pattern.search(text)
496
+ if match:
497
+ return fmt(match)
498
+ return ""
499
+
500
+
501
+ def _runner_supports_on_progress(runner: ReviewRunner) -> bool:
502
+ """True when ``runner.run`` accepts an ``on_progress`` keyword.
503
+
504
+ Lets streaming be opt-in per runner so simple/test runners with the older
505
+ signature keep working unchanged.
506
+ """
507
+ try:
508
+ return "on_progress" in inspect.signature(runner.run).parameters
509
+ except (TypeError, ValueError):
510
+ return False
511
+
512
+
464
513
  def process_pending(
465
514
  project_root: Path,
466
515
  state: WatchState,
@@ -500,11 +549,27 @@ def process_pending(
500
549
  on_update(state)
501
550
  _report_node(project_root, node)
502
551
  intent = intent_for(node.sha) if intent_for else None
503
- outcome = runner.run(
504
- sha=node.sha,
505
- intent=intent,
506
- yes_full_project=(node.scope_mode == "full"),
507
- )
552
+ run_kwargs: dict = {
553
+ "sha": node.sha,
554
+ "intent": intent,
555
+ "yes_full_project": (node.scope_mode == "full"),
556
+ }
557
+ # Stream live sub-steps into the reviewing node when the runner supports
558
+ # it, so a long review shows batch-by-batch progress instead of a static
559
+ # "analyzing..." spinner that looks hung.
560
+ if _runner_supports_on_progress(runner):
561
+ reviewing_sha = node.sha
562
+
563
+ def _on_progress(line: str, _sha: str = reviewing_sha) -> None:
564
+ label = _summarize_progress(line)
565
+ if not label:
566
+ return
567
+ state.set_substep(_sha, label)
568
+ if on_update:
569
+ on_update(state)
570
+
571
+ run_kwargs["on_progress"] = _on_progress
572
+ outcome = runner.run(**run_kwargs)
508
573
  if outcome.run_id:
509
574
  outcome.rr_preview = _preview_for_run(project_root, outcome.run_id)
510
575
  state.apply_outcome(node.sha, outcome)
@@ -14,12 +14,15 @@ machine without spawning a subprocess or needing the engine.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import contextlib
17
18
  import os
18
19
  import re
20
+ import signal
19
21
  import subprocess
22
+ import threading
20
23
  from dataclasses import dataclass, field
21
24
  from pathlib import Path
22
- from typing import Protocol
25
+ from typing import Callable, Protocol
23
26
 
24
27
  _ANSI = re.compile(r"\x1b\[[0-9;]*m")
25
28
  _SCOPE_COUNTS = re.compile(r"(\d+)\s+changed\s*\+\s*(\d+)\s+associated")
@@ -58,6 +61,7 @@ class ReviewRunner(Protocol):
58
61
  sha: str,
59
62
  intent: str | None = None,
60
63
  yes_full_project: bool = False,
64
+ on_progress: Callable[[str], None] | None = None,
61
65
  ) -> ReviewOutcome: ...
62
66
 
63
67
 
@@ -123,6 +127,7 @@ class SubprocessReviewRunner:
123
127
  sha: str,
124
128
  intent: str | None = None,
125
129
  yes_full_project: bool = False,
130
+ on_progress: Callable[[str], None] | None = None,
126
131
  ) -> ReviewOutcome:
127
132
  cmd = [self.executable, "review", "--commit", sha, *self.extra_args]
128
133
  if intent and intent.strip():
@@ -132,23 +137,70 @@ class SubprocessReviewRunner:
132
137
  # non-interactive confirmation so the loop never blocks on a hidden prompt.
133
138
  cmd.append("--yes-full-project")
134
139
  # Wide, non-interactive output so rich does not wrap the status lines the
135
- # parser depends on.
136
- env = dict(os.environ, COLUMNS="200", NO_COLOR="1")
140
+ # parser depends on. ``PYTHONUNBUFFERED`` forces line-buffered child output
141
+ # so progress lines reach us (and the terminal UI) live instead of only at
142
+ # process exit -- the difference between a spinner that looks hung and one
143
+ # that shows real sub-steps.
144
+ env = dict(os.environ, COLUMNS="200", NO_COLOR="1", PYTHONUNBUFFERED="1")
137
145
  try:
138
- proc = subprocess.run(
146
+ proc = subprocess.Popen( # noqa: S603 - fixed argv, no shell
139
147
  cmd,
140
148
  cwd=str(self.project_root),
141
- capture_output=True,
149
+ stdout=subprocess.PIPE,
150
+ stderr=subprocess.STDOUT,
142
151
  text=True,
143
- timeout=self.timeout,
152
+ bufsize=1,
144
153
  env=env,
154
+ # New session/process group so the timeout can kill the whole tree
155
+ # (review spawns grandchildren, e.g. the sandbox runtime). Killing
156
+ # only the direct child would leave a grandchild holding the stdout
157
+ # pipe open, so the streaming read loop would block until it exits
158
+ # and the timeout would not take effect promptly.
159
+ start_new_session=True,
145
160
  )
146
161
  except FileNotFoundError:
147
162
  return ReviewOutcome(sha=sha, error=f"'{self.executable}' not found on PATH")
148
- except subprocess.TimeoutExpired:
149
- return ReviewOutcome(sha=sha, error=f"review timed out after {int(self.timeout)}s")
150
163
  except Exception as exc: # never let a review failure crash the terminal
151
164
  return ReviewOutcome(sha=sha, error=f"review invocation failed: {exc}")
152
165
 
153
- combined = (proc.stdout or "") + "\n" + (proc.stderr or "")
154
- return parse_review_output(sha, combined, proc.returncode)
166
+ def _kill_tree() -> None:
167
+ """Kill the child and all of its descendants (best-effort)."""
168
+ with contextlib.suppress(Exception):
169
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
170
+ with contextlib.suppress(Exception):
171
+ proc.kill()
172
+
173
+ # Enforce the overall timeout even while streaming: a watchdog kills the
174
+ # process group at the deadline (the streaming read loop then unblocks
175
+ # because every pipe write-end is closed).
176
+ done = threading.Event()
177
+ timed_out = threading.Event()
178
+
179
+ def _watchdog() -> None:
180
+ if not done.wait(self.timeout):
181
+ timed_out.set()
182
+ _kill_tree()
183
+
184
+ watchdog = threading.Thread(target=_watchdog, name="review-timeout", daemon=True)
185
+ watchdog.start()
186
+
187
+ lines: list[str] = []
188
+ try:
189
+ if proc.stdout is not None:
190
+ for raw in proc.stdout:
191
+ lines.append(raw)
192
+ if on_progress is not None:
193
+ # A progress-callback error must never abort the review.
194
+ with contextlib.suppress(Exception):
195
+ on_progress(raw.rstrip("\n"))
196
+ proc.wait()
197
+ except Exception as exc: # never let a streaming/read error crash the terminal
198
+ done.set()
199
+ _kill_tree()
200
+ return ReviewOutcome(sha=sha, error=f"review invocation failed: {exc}")
201
+ finally:
202
+ done.set()
203
+
204
+ if timed_out.is_set():
205
+ return ReviewOutcome(sha=sha, error=f"review timed out after {int(self.timeout)}s")
206
+ return parse_review_output(sha, "".join(lines), proc.returncode or 0)
@@ -121,6 +121,9 @@ class CommitNode:
121
121
  intent_summary: str = ""
122
122
  run_id: str = ""
123
123
  error: str = ""
124
+ # Live sub-step label while REVIEWING (e.g. "batch 2/5", "security scan").
125
+ # Volatile UI-only detail; not part of the persisted node contract.
126
+ substep: str = ""
124
127
  finalize: "FinalizeInfo | None" = None
125
128
 
126
129
  @property
@@ -271,6 +274,17 @@ class WatchState:
271
274
  node = self._nodes.get(sha)
272
275
  if node:
273
276
  node.state = REVIEWING
277
+ node.substep = ""
278
+
279
+ def set_substep(self, sha: str, label: str) -> None:
280
+ """Update the live sub-step label for a reviewing node (UI-only).
281
+
282
+ No-op unless the node is currently ``REVIEWING`` so a stale progress line
283
+ can never overwrite a finished node's status.
284
+ """
285
+ node = self._nodes.get(sha)
286
+ if node and node.state == REVIEWING:
287
+ node.substep = label
274
288
 
275
289
  def requeue(self, sha: str) -> None:
276
290
  """Reset a node to ``queued`` so the next pass re-reviews it (post-resolve)."""
@@ -282,6 +296,7 @@ class WatchState:
282
296
  node.rr_count = 0
283
297
  node.rr_preview = []
284
298
  node.error = ""
299
+ node.substep = ""
285
300
  node.finalize = None
286
301
  self.forced_review.discard(sha)
287
302
 
@@ -443,7 +458,8 @@ def _status_line(node: CommitNode) -> str:
443
458
  if node.state == REVIEWING:
444
459
  scope = f" ({node.changed} files)" if node.changed else ""
445
460
  spinner = _spinner_frame()
446
- return f"[cyan]{spinner}[/cyan] [dim]analyzing changed+associated{scope}...[/dim]"
461
+ detail = node.substep or "analyzing changed+associated"
462
+ return f"[cyan]{spinner}[/cyan] [dim]{detail}{scope}...[/dim]"
447
463
  if node.state == ERROR:
448
464
  return f"[red]review error:[/red] {node.error or 'unknown'}"
449
465
  # reviewed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.7.18
3
+ Version: 0.7.20
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown