refactorai-cli 0.4.14__tar.gz → 0.4.16__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.
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/PKG-INFO +2 -2
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/pyproject.toml +2 -2
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/__init__.py +1 -1
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/hook_cmds.py +40 -2
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/run_cmds.py +32 -2
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/watch_cmds.py +139 -3
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/main.py +1 -1
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/watch_state.py +36 -9
- refactorai_cli-0.4.16/refactorai_cli/watch_supervisor.py +234 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/PKG-INFO +2 -2
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/SOURCES.txt +1 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/requires.txt +1 -1
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/README.md +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/auth.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/client.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/cloud_rr.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/__init__.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/account_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/auth_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/branch_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/cloud_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/engine_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/model_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/request_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/rules_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/runtime_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/setup_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/toolchains_cmds.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commit_queue.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/control_plane.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/credentials.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/dotenv_loader.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/git_scope.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/local_constitution.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/local_engine_runtime.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/local_paths.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/model_policy.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/refactor_branch_store.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/review_runner.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/runtime_manager.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/settings.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/setup_flow.py +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/dependency_links.txt +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/entry_points.txt +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/top_level.txt +0 -0
- {refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactorai-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.16
|
|
4
4
|
Summary: Local-first CLI for the refactor platform
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
|
|
|
8
8
|
Requires-Dist: httpx>=0.27.0
|
|
9
9
|
Requires-Dist: rich>=13.7.0
|
|
10
10
|
Requires-Dist: PyYAML>=6.0.1
|
|
11
|
-
Requires-Dist: refactorai-core>=3.2.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.2.9
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "refactorai-cli"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.16"
|
|
4
4
|
description = "Local-first CLI for the refactor platform"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -12,7 +12,7 @@ dependencies = [
|
|
|
12
12
|
"httpx>=0.27.0",
|
|
13
13
|
"rich>=13.7.0",
|
|
14
14
|
"PyYAML>=6.0.1",
|
|
15
|
-
"refactorai-core>=3.2.
|
|
15
|
+
"refactorai-core>=3.2.9",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[project.scripts]
|
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
import typer
|
|
17
17
|
from rich.console import Console
|
|
18
18
|
|
|
19
|
-
from refactorai_cli import commit_queue, git_scope
|
|
19
|
+
from refactorai_cli import commit_queue, git_scope, watch_supervisor
|
|
20
20
|
|
|
21
21
|
console = Console()
|
|
22
22
|
app = typer.Typer(
|
|
@@ -137,7 +137,9 @@ def install(
|
|
|
137
137
|
f"[green]Installed[/green] post-commit hook -> {result['hook_file']}\n"
|
|
138
138
|
f"[dim]core.hooksPath = {result['rel_dir']}[/dim]\n"
|
|
139
139
|
"Every commit now enqueues a local review event. "
|
|
140
|
-
"Open the Refactor Terminal with `refactor watch
|
|
140
|
+
"Open the Refactor Terminal with `refactor watch`.\n"
|
|
141
|
+
"[dim]Tip: set watch.auto_open + watch.terminal (or REFACTOR_WATCH_AUTO_OPEN / "
|
|
142
|
+
"REFACTOR_WATCH_TERMINAL) to open it automatically on each commit.[/dim]"
|
|
141
143
|
)
|
|
142
144
|
|
|
143
145
|
|
|
@@ -195,6 +197,42 @@ def enqueue(
|
|
|
195
197
|
else:
|
|
196
198
|
console.print(f"[dim]hook: not enqueued ({reason}).[/dim]")
|
|
197
199
|
|
|
200
|
+
# Opt-in auto-open: mirror how a dev server opens its own terminal. Best-effort
|
|
201
|
+
# and fail-open so it never affects the commit flow (docs/42 `R29`, Phase 4).
|
|
202
|
+
if enqueued:
|
|
203
|
+
_maybe_auto_open(project_root, verbose)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _watch_settings(project_root: Path) -> dict:
|
|
207
|
+
"""Best-effort project-config settings dict (never raises)."""
|
|
208
|
+
try:
|
|
209
|
+
from refactor_core.constitution import (
|
|
210
|
+
CONFIG_FILENAME,
|
|
211
|
+
find_project_files,
|
|
212
|
+
load_config,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
consti_path, _ = find_project_files(project_root)
|
|
216
|
+
if consti_path is None:
|
|
217
|
+
return {}
|
|
218
|
+
cfg = load_config(consti_path.parent / CONFIG_FILENAME)
|
|
219
|
+
return dict(getattr(cfg, "settings", {}) or {})
|
|
220
|
+
except Exception:
|
|
221
|
+
return {}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _maybe_auto_open(project_root: Path, verbose: bool) -> None:
|
|
225
|
+
try:
|
|
226
|
+
config = _watch_settings(project_root)
|
|
227
|
+
if not watch_supervisor.auto_open_enabled(config):
|
|
228
|
+
return
|
|
229
|
+
result = watch_supervisor.ensure_watch_open(project_root, config=config)
|
|
230
|
+
if verbose:
|
|
231
|
+
console.print(f"[dim]hook: watch ensure -> {result.get('action')}.[/dim]")
|
|
232
|
+
except Exception as exc: # fail-open
|
|
233
|
+
if verbose:
|
|
234
|
+
console.print(f"[yellow]hook: auto-open skipped ({exc}).[/yellow]")
|
|
235
|
+
|
|
198
236
|
|
|
199
237
|
@app.command("status")
|
|
200
238
|
def status() -> None:
|
|
@@ -64,11 +64,13 @@ from refactor_core.refactor_requests import (
|
|
|
64
64
|
DEFERRED,
|
|
65
65
|
RequestSuppression,
|
|
66
66
|
RequestWarning,
|
|
67
|
+
clear_open_requests,
|
|
67
68
|
clear_requests,
|
|
68
69
|
create_requests,
|
|
69
70
|
list_requests,
|
|
70
71
|
load_request,
|
|
71
72
|
pending_requests,
|
|
73
|
+
pending_requests_for_paths,
|
|
72
74
|
save_request,
|
|
73
75
|
)
|
|
74
76
|
from refactor_core.refactor_ops import remediation_hint
|
|
@@ -2764,6 +2766,8 @@ def review(
|
|
|
2764
2766
|
compliance_canary_percent=compliance_canary_pct,
|
|
2765
2767
|
compliance_resolution=compliance_resolution,
|
|
2766
2768
|
artifact=artifact,
|
|
2769
|
+
scope_paths=scope_paths,
|
|
2770
|
+
scoped_replace=True,
|
|
2767
2771
|
)
|
|
2768
2772
|
_apply_compliance_gate_to_artifact(artifact)
|
|
2769
2773
|
_apply_merge_contract_to_artifact(artifact, project_root, project_config)
|
|
@@ -2998,7 +3002,20 @@ def code(
|
|
|
2998
3002
|
# *creating* a large request set; when we are resolving an existing pending
|
|
2999
3003
|
# queue (the common `[r]` resume case) there is nothing to confirm, so a root
|
|
3000
3004
|
# commit no longer re-prompts every run (docs/42 `R29`, Part L).
|
|
3001
|
-
|
|
3005
|
+
#
|
|
3006
|
+
# Reuse is *commit-scoped*: a change-scoped `code --commit`/`[r]` only resumes
|
|
3007
|
+
# pending RRs whose file lives in THIS commit's scope, so a leftover pending RR
|
|
3008
|
+
# from an unrelated commit can never bleed into this commit's resolution
|
|
3009
|
+
# (docs/41 `R28`, Phase 1). A full-project scope (root commit) has no path
|
|
3010
|
+
# restriction, so it resumes the whole pending queue as before.
|
|
3011
|
+
if code_scope and refresh_requests:
|
|
3012
|
+
reused_requests = []
|
|
3013
|
+
elif code_scope and code_scope.get("scope_paths") is not None:
|
|
3014
|
+
reused_requests = pending_requests_for_paths(
|
|
3015
|
+
project_root, code_scope.get("scope_paths")
|
|
3016
|
+
)
|
|
3017
|
+
else:
|
|
3018
|
+
reused_requests = pending_requests(project_root)
|
|
3002
3019
|
will_generate_requests = bool(refresh_requests) or not reused_requests
|
|
3003
3020
|
if will_generate_requests:
|
|
3004
3021
|
_confirm_full_project_scope(code_scope, command="code", assume_yes=yes_full_project)
|
|
@@ -3154,6 +3171,8 @@ def code(
|
|
|
3154
3171
|
compliance_canary_percent=compliance_canary_pct,
|
|
3155
3172
|
compliance_resolution=compliance_resolution,
|
|
3156
3173
|
artifact=review_artifact,
|
|
3174
|
+
scope_paths=code_scope_paths,
|
|
3175
|
+
scoped_replace=True,
|
|
3157
3176
|
)
|
|
3158
3177
|
_apply_compliance_gate_to_artifact(review_artifact)
|
|
3159
3178
|
_apply_merge_contract_to_artifact(review_artifact, project_root, project_config)
|
|
@@ -3706,8 +3725,19 @@ def _replace_requests_with_framework_guardrails(
|
|
|
3706
3725
|
compliance_canary_percent: int = 20,
|
|
3707
3726
|
compliance_resolution: CompliancePackResolution | None = None,
|
|
3708
3727
|
artifact: RunArtifact | None = None,
|
|
3728
|
+
scope_paths: list[str] | None = None,
|
|
3729
|
+
scoped_replace: bool = False,
|
|
3709
3730
|
) -> list:
|
|
3710
|
-
|
|
3731
|
+
# A fresh review supersedes the OPEN requests it re-derives, but must preserve
|
|
3732
|
+
# terminal (applied/denied/failed/...) history so an already-resolved finding is
|
|
3733
|
+
# not re-suggested on a later commit (docs/42 `R29`, Phase 2). A commit-scoped
|
|
3734
|
+
# review clears only its own scope's open queue; a full-project review clears all
|
|
3735
|
+
# open requests. ``scoped_replace`` opts into this history-preserving path so
|
|
3736
|
+
# callers that still want a total reset can pass ``scoped_replace=False``.
|
|
3737
|
+
if scoped_replace:
|
|
3738
|
+
clear_open_requests(project_root, scope_paths)
|
|
3739
|
+
else:
|
|
3740
|
+
clear_requests(project_root)
|
|
3711
3741
|
requests, suppressions, warnings = create_requests(
|
|
3712
3742
|
project_root,
|
|
3713
3743
|
findings,
|
|
@@ -25,7 +25,13 @@ from typing import Callable
|
|
|
25
25
|
import typer
|
|
26
26
|
from rich.console import Console
|
|
27
27
|
|
|
28
|
-
from refactorai_cli import
|
|
28
|
+
from refactorai_cli import (
|
|
29
|
+
commit_queue,
|
|
30
|
+
git_scope,
|
|
31
|
+
refactor_branch_store,
|
|
32
|
+
watch_state,
|
|
33
|
+
watch_supervisor,
|
|
34
|
+
)
|
|
29
35
|
from refactorai_cli.auth import AuthError, ensure_authenticated
|
|
30
36
|
from refactorai_cli.review_runner import ReviewRunner, SubprocessReviewRunner
|
|
31
37
|
from refactorai_cli.watch_state import (
|
|
@@ -638,9 +644,38 @@ def _dispatch_key(live, project_root: Path, key: str, state: WatchState) -> None
|
|
|
638
644
|
_run_action(live, project_root, "diff", node, state)
|
|
639
645
|
return
|
|
640
646
|
if not can_review:
|
|
647
|
+
preferred = next(
|
|
648
|
+
(
|
|
649
|
+
n
|
|
650
|
+
for n in state.nodes_newest_first()
|
|
651
|
+
if n.state == watch_state.REVIEWED
|
|
652
|
+
and (n.phase == watch_state.PHASE_REVIEW or n.sha in state.forced_review)
|
|
653
|
+
and n.rr_count > 0
|
|
654
|
+
),
|
|
655
|
+
None,
|
|
656
|
+
)
|
|
657
|
+
fallback = preferred or next(
|
|
658
|
+
(
|
|
659
|
+
n
|
|
660
|
+
for n in state.nodes_newest_first()
|
|
661
|
+
if n.state == watch_state.REVIEWED
|
|
662
|
+
and (n.phase == watch_state.PHASE_REVIEW or n.sha in state.forced_review)
|
|
663
|
+
),
|
|
664
|
+
None,
|
|
665
|
+
)
|
|
666
|
+
if fallback is not None and fallback.sha != node.sha:
|
|
667
|
+
state.selected_sha = fallback.sha
|
|
668
|
+
console.print(
|
|
669
|
+
f"[dim]watch: selected {node.short_sha} ({node.branch or 'detached'} / "
|
|
670
|
+
f"{node.phase}, requests={node.rr_count}) is not reviewable; moved "
|
|
671
|
+
f"selection to {fallback.short_sha} (requests={fallback.rr_count}). "
|
|
672
|
+
f"Press [{key}] again.[/dim]"
|
|
673
|
+
)
|
|
674
|
+
return
|
|
641
675
|
console.print(
|
|
642
|
-
f"[dim]watch:
|
|
643
|
-
f"
|
|
676
|
+
f"[dim]watch: selected {node.short_sha} ({node.branch or 'detached'} / "
|
|
677
|
+
f"phase={node.phase}, requests={node.rr_count}) has nothing to "
|
|
678
|
+
"review. Use [↑/↓] to select a commit with open requests.[/dim]"
|
|
644
679
|
)
|
|
645
680
|
return
|
|
646
681
|
if node.state == watch_state.REVIEWED:
|
|
@@ -695,7 +730,17 @@ def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> Non
|
|
|
695
730
|
live.stop()
|
|
696
731
|
|
|
697
732
|
|
|
733
|
+
watch_app = typer.Typer(
|
|
734
|
+
name="watch",
|
|
735
|
+
help="Open the persistent Refactor Terminal (docs/41). Requires a valid key.",
|
|
736
|
+
invoke_without_command=True,
|
|
737
|
+
add_completion=False,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
@watch_app.callback(invoke_without_command=True)
|
|
698
742
|
def watch(
|
|
743
|
+
ctx: typer.Context,
|
|
699
744
|
json_output: bool = typer.Option(
|
|
700
745
|
False,
|
|
701
746
|
"--json",
|
|
@@ -714,6 +759,10 @@ def watch(
|
|
|
714
759
|
),
|
|
715
760
|
) -> None:
|
|
716
761
|
"""Open the persistent Refactor Terminal (docs/41). Requires a valid key."""
|
|
762
|
+
# A subcommand (e.g. `watch ensure`) was invoked -> let it run instead.
|
|
763
|
+
if ctx.invoked_subcommand is not None:
|
|
764
|
+
return
|
|
765
|
+
|
|
717
766
|
project_root = _resolve_repo(Path.cwd())
|
|
718
767
|
_ensure_auth_in_window(json_output)
|
|
719
768
|
|
|
@@ -743,8 +792,95 @@ def watch(
|
|
|
743
792
|
console.print(f"[dim]watch: reviewed {reviewed} commit(s).[/dim]")
|
|
744
793
|
return
|
|
745
794
|
|
|
795
|
+
_open_watch_window(project_root, state, runner)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _open_watch_window(
|
|
799
|
+
project_root: Path, state: WatchState, runner: ReviewRunner
|
|
800
|
+
) -> None:
|
|
801
|
+
"""Run the interactive TUI while holding the single-instance lock.
|
|
802
|
+
|
|
803
|
+
The lock lets ``watch ensure`` (auto-open) detect an already-open window for
|
|
804
|
+
this repo and never spawn a duplicate (docs/42 Phase 4).
|
|
805
|
+
"""
|
|
806
|
+
my_pid = os.getpid()
|
|
807
|
+
watch_supervisor.write_lock(project_root, my_pid)
|
|
746
808
|
try:
|
|
747
809
|
_run_tui(project_root, state, runner)
|
|
748
810
|
except KeyboardInterrupt:
|
|
749
811
|
pass
|
|
812
|
+
finally:
|
|
813
|
+
watch_supervisor.release_lock(project_root, pid=my_pid)
|
|
750
814
|
console.print("[dim]watch: closed (the commit hook/queue are unaffected).[/dim]")
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _load_watch_config(project_root: Path) -> dict:
|
|
818
|
+
"""Best-effort project-config settings dict (never raises)."""
|
|
819
|
+
try:
|
|
820
|
+
from refactor_core.constitution import (
|
|
821
|
+
CONFIG_FILENAME,
|
|
822
|
+
find_project_files,
|
|
823
|
+
load_config,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
consti_path, _ = find_project_files(project_root)
|
|
827
|
+
if consti_path is None:
|
|
828
|
+
return {}
|
|
829
|
+
cfg = load_config(consti_path.parent / CONFIG_FILENAME)
|
|
830
|
+
return dict(getattr(cfg, "settings", {}) or {})
|
|
831
|
+
except Exception:
|
|
832
|
+
return {}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@watch_app.command("ensure")
|
|
836
|
+
def ensure(
|
|
837
|
+
force: bool = typer.Option(
|
|
838
|
+
False, "--force", help="Open even if auto-open is disabled in config/env."
|
|
839
|
+
),
|
|
840
|
+
foreground: bool = typer.Option(
|
|
841
|
+
False,
|
|
842
|
+
"--foreground",
|
|
843
|
+
"-f",
|
|
844
|
+
help="Run the Refactor Terminal inline instead of spawning a new window.",
|
|
845
|
+
),
|
|
846
|
+
) -> None:
|
|
847
|
+
"""Open the Refactor Terminal if enabled and not already running (auto-open).
|
|
848
|
+
|
|
849
|
+
Invoked by the ``post-commit`` hook when auto-open is on, mirroring how a dev
|
|
850
|
+
server opens its own terminal. Fail-open and idempotent: a single-instance lock
|
|
851
|
+
prevents duplicate windows.
|
|
852
|
+
"""
|
|
853
|
+
project_root = _resolve_repo(Path.cwd())
|
|
854
|
+
|
|
855
|
+
if foreground:
|
|
856
|
+
if watch_supervisor.is_watch_running(project_root) and not force:
|
|
857
|
+
console.print("[dim]watch: a Refactor Terminal is already open.[/dim]")
|
|
858
|
+
return
|
|
859
|
+
_ensure_auth_in_window(False)
|
|
860
|
+
state = WatchState(repo_name=project_root.name)
|
|
861
|
+
try:
|
|
862
|
+
state.current_branch = git_scope.current_branch(project_root) or ""
|
|
863
|
+
except Exception:
|
|
864
|
+
state.current_branch = ""
|
|
865
|
+
for event in _load_initial_events(project_root, None, 20):
|
|
866
|
+
sha = str(event.get("sha") or "")
|
|
867
|
+
if sha and not state.has(sha):
|
|
868
|
+
state.upsert_from_event(event)
|
|
869
|
+
runner: ReviewRunner = SubprocessReviewRunner(project_root)
|
|
870
|
+
_open_watch_window(project_root, state, runner)
|
|
871
|
+
return
|
|
872
|
+
|
|
873
|
+
config = _load_watch_config(project_root)
|
|
874
|
+
result = watch_supervisor.ensure_watch_open(
|
|
875
|
+
project_root, config=config, force=force
|
|
876
|
+
)
|
|
877
|
+
action = result.get("action")
|
|
878
|
+
message = result.get("message", "")
|
|
879
|
+
if action == "launched":
|
|
880
|
+
console.print(f"[green]watch:[/green] {message}")
|
|
881
|
+
elif action in ("already_running", "disabled"):
|
|
882
|
+
console.print(f"[dim]watch: {message}.[/dim]")
|
|
883
|
+
elif action == "no_launcher":
|
|
884
|
+
console.print(f"[yellow]watch:[/yellow] {message}.")
|
|
885
|
+
else: # launch_failed / unknown
|
|
886
|
+
console.print(f"[yellow]watch: {message}.[/yellow]")
|
|
@@ -156,7 +156,7 @@ app.add_typer(toolchains_cmds.app, name="toolchains")
|
|
|
156
156
|
app.add_typer(hook_cmds.app, name="hook")
|
|
157
157
|
|
|
158
158
|
# Persistent Refactor Terminal (R28 Phase 3, docs/41).
|
|
159
|
-
app.
|
|
159
|
+
app.add_typer(watch_cmds.watch_app, name="watch")
|
|
160
160
|
|
|
161
161
|
# Local refactor branch + per-request commits (R29 Phase 3, docs/42).
|
|
162
162
|
app.add_typer(branch_cmds.app, name="branch")
|
|
@@ -447,8 +447,9 @@ def _render_review_view(state: WatchState, *, now: datetime | None = None, width
|
|
|
447
447
|
age = humanize_age(node.enqueued_at, now=now)
|
|
448
448
|
gate = _gate_markup(node)
|
|
449
449
|
selected = node.sha == state.selected_sha
|
|
450
|
+
node_lines: list[Text] = []
|
|
450
451
|
# Row 1: node marker + branch + age (+ gate, right aligned). The selected
|
|
451
|
-
# node gets a filled marker
|
|
452
|
+
# node gets a filled marker + a visual box so `[r]` targets are explicit.
|
|
452
453
|
if selected:
|
|
453
454
|
left = Text.from_markup(
|
|
454
455
|
f"[bold bright_cyan]●[/bold bright_cyan] [bold bright_cyan]{node.branch or '(detached)'}[/bold bright_cyan]"
|
|
@@ -461,25 +462,25 @@ def _render_review_view(state: WatchState, *, now: datetime | None = None, width
|
|
|
461
462
|
pad = max(1, inner_width - len(left.plain) - len(gate_text.plain))
|
|
462
463
|
left.append(" " * pad)
|
|
463
464
|
left.append_text(gate_text)
|
|
464
|
-
|
|
465
|
+
node_lines.append(left)
|
|
465
466
|
# Row 2: pipe + sha + subject.
|
|
466
467
|
row2 = Text.from_markup(f"[cyan]|[/cyan] [yellow]{node.short_sha}[/yellow] ")
|
|
467
468
|
row2.append(node.subject or "(no subject)")
|
|
468
|
-
|
|
469
|
+
node_lines.append(row2)
|
|
469
470
|
# Row 3: pipe + status.
|
|
470
471
|
row3 = Text.from_markup("[cyan]|[/cyan] ")
|
|
471
472
|
row3.append_text(Text.from_markup(_status_line(node)))
|
|
472
|
-
|
|
473
|
+
node_lines.append(row3)
|
|
473
474
|
if node.state == REVIEWED and node.rr_preview:
|
|
474
475
|
for item in node.rr_preview:
|
|
475
476
|
req = Text.from_markup("[cyan]|[/cyan] ")
|
|
476
477
|
req.append(f"- {item}", style="magenta")
|
|
477
|
-
|
|
478
|
+
node_lines.append(req)
|
|
478
479
|
# Row 4: intent summary (only once reviewed and present).
|
|
479
480
|
if node.state in (REVIEWED,) and node.intent_summary:
|
|
480
481
|
intent = Text.from_markup("[cyan]|[/cyan] ")
|
|
481
482
|
intent.append_text(Text.from_markup(f"[dim]intent: {node.intent_summary}[/dim]"))
|
|
482
|
-
|
|
483
|
+
node_lines.append(intent)
|
|
483
484
|
# Once a commit is resolved onto a live refactor branch, hint the finalize
|
|
484
485
|
# view rather than the (now empty) review actions (docs/43 Part D).
|
|
485
486
|
if node.phase == PHASE_FINALIZE and node.finalize is not None:
|
|
@@ -489,7 +490,7 @@ def _render_review_view(state: WatchState, *, now: datetime | None = None, width
|
|
|
489
490
|
"-> select + press [m] to finalize",
|
|
490
491
|
style="green",
|
|
491
492
|
)
|
|
492
|
-
|
|
493
|
+
node_lines.append(hint)
|
|
493
494
|
elif node.phase == PHASE_DONE and node.finalize is not None:
|
|
494
495
|
done = Text.from_markup("[cyan]|[/cyan] ")
|
|
495
496
|
if node.finalize.outcome == "merged":
|
|
@@ -500,10 +501,36 @@ def _render_review_view(state: WatchState, *, now: datetime | None = None, width
|
|
|
500
501
|
done.append("refactor branch dropped", style="dim")
|
|
501
502
|
else:
|
|
502
503
|
done.append("refactor branch finalized", style="dim")
|
|
503
|
-
|
|
504
|
-
|
|
504
|
+
node_lines.append(done)
|
|
505
|
+
node_lines.append(Text.from_markup("[cyan]|[/cyan]"))
|
|
506
|
+
|
|
507
|
+
if selected and node_lines:
|
|
508
|
+
# Strong visual target: render the selected commit in a distinct box so
|
|
509
|
+
# `[r]`/`[f]` actions are unambiguous in long commit stacks.
|
|
510
|
+
for idx, row in enumerate(node_lines):
|
|
511
|
+
if idx == 0:
|
|
512
|
+
glyph = "┏ "
|
|
513
|
+
elif idx == len(node_lines) - 1:
|
|
514
|
+
glyph = "┗ "
|
|
515
|
+
else:
|
|
516
|
+
glyph = "┃ "
|
|
517
|
+
wrapped = Text(glyph, style="bold bright_cyan")
|
|
518
|
+
wrapped.append_text(row)
|
|
519
|
+
lines.append(_with_pad(wrapped))
|
|
520
|
+
else:
|
|
521
|
+
for row in node_lines:
|
|
522
|
+
lines.append(_with_pad(row))
|
|
505
523
|
lines.append(_with_pad(Text.from_markup("[cyan]+[/cyan]")))
|
|
506
524
|
lines.append(_with_pad(Text("")))
|
|
525
|
+
selected = state.selected()
|
|
526
|
+
if selected is not None:
|
|
527
|
+
context = Text.from_markup("[bold bright_cyan]selected[/bold bright_cyan]: ")
|
|
528
|
+
context.append(selected.short_sha, style="yellow")
|
|
529
|
+
context.append(f" {selected.subject or '(no subject)'}", style="dim")
|
|
530
|
+
context.append(f" phase={selected.phase}", style="dim")
|
|
531
|
+
context.append(f" requests={selected.rr_count}", style="dim")
|
|
532
|
+
lines.append(_with_pad(context))
|
|
533
|
+
lines.append(_with_pad(Text("")))
|
|
507
534
|
lines.append(
|
|
508
535
|
_with_pad(
|
|
509
536
|
Text.from_markup(
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Auto-open supervisor for the Refactor Terminal (docs/42 `R29`, Phase 4).
|
|
2
|
+
|
|
3
|
+
Opt-in: after a commit is enqueued, the ``post-commit`` hook can call
|
|
4
|
+
``refactor watch ensure`` to open the Refactor Terminal in a new terminal window
|
|
5
|
+
when one is not already running -- so review results appear automatically (like a
|
|
6
|
+
Metro / dev-server window) instead of the developer having to remember to run
|
|
7
|
+
``refactor watch``.
|
|
8
|
+
|
|
9
|
+
Everything here is best-effort and fail-open: it must never delay or fail a commit.
|
|
10
|
+
Auto-open is disabled unless the developer opts in, and opening a *new* window
|
|
11
|
+
needs a configured terminal launcher (there is no reliable cross-platform default),
|
|
12
|
+
so both are surfaced as an env var and a matching ``watch`` config key.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shlex
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from refactorai_cli import commit_queue
|
|
26
|
+
|
|
27
|
+
LOCK_FILENAME = "watch.lock"
|
|
28
|
+
|
|
29
|
+
# Env knobs (project-config keys under a ``watch`` table mirror these names).
|
|
30
|
+
ENV_AUTO_OPEN = "REFACTOR_WATCH_AUTO_OPEN" # "1"/"true" to enable auto-open
|
|
31
|
+
ENV_TERMINAL = "REFACTOR_WATCH_TERMINAL" # launcher template, uses ``{cmd}``
|
|
32
|
+
|
|
33
|
+
_TRUE_TOKENS = {"1", "true", "yes", "on"}
|
|
34
|
+
# A freshly-launched window that has not yet written its own pid still counts as
|
|
35
|
+
# "running" for this many seconds, so two commits in quick succession do not each
|
|
36
|
+
# spawn a window before the first has come up.
|
|
37
|
+
_LAUNCH_GRACE_SECONDS = 45
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now_iso() -> str:
|
|
41
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _as_bool(value: object) -> bool:
|
|
45
|
+
return str(value).strip().lower() in _TRUE_TOKENS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def lock_path(project_root: Path | str) -> Path:
|
|
49
|
+
"""Per-repo lock file. Lives beside the commit queue (never tracked)."""
|
|
50
|
+
directory = commit_queue.runs_dir(project_root)
|
|
51
|
+
if directory is None:
|
|
52
|
+
# Outside a git dir (or tests): fall back to the central project store.
|
|
53
|
+
from refactorai_cli.local_paths import project_store_dir
|
|
54
|
+
|
|
55
|
+
directory = project_store_dir(project_root)
|
|
56
|
+
return Path(directory) / LOCK_FILENAME
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pid_alive(pid: int) -> bool:
|
|
60
|
+
if pid <= 0:
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
os.kill(pid, 0)
|
|
64
|
+
except ProcessLookupError:
|
|
65
|
+
return False
|
|
66
|
+
except PermissionError:
|
|
67
|
+
return True
|
|
68
|
+
except OSError:
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_lock(project_root: Path | str) -> dict | None:
|
|
74
|
+
path = lock_path(project_root)
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except (json.JSONDecodeError, OSError):
|
|
80
|
+
return None
|
|
81
|
+
return data if isinstance(data, dict) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_lock(project_root: Path | str, pid: int, *, launching: bool = False) -> None:
|
|
85
|
+
path = lock_path(project_root)
|
|
86
|
+
try:
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
path.write_text(
|
|
89
|
+
json.dumps({"pid": int(pid), "launching": bool(launching), "at": _now_iso()}),
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def release_lock(project_root: Path | str, *, pid: int | None = None) -> None:
|
|
97
|
+
"""Remove the lock. If ``pid`` is given, only remove our own lock."""
|
|
98
|
+
data = read_lock(project_root)
|
|
99
|
+
if data is None:
|
|
100
|
+
return
|
|
101
|
+
if pid is not None and int(data.get("pid", 0) or 0) != int(pid):
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
lock_path(project_root).unlink()
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _within_grace(data: dict) -> bool:
|
|
110
|
+
if not data.get("launching"):
|
|
111
|
+
return False
|
|
112
|
+
stamp = str(data.get("at", "") or "")
|
|
113
|
+
if not stamp:
|
|
114
|
+
return False
|
|
115
|
+
try:
|
|
116
|
+
started = datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
117
|
+
except ValueError:
|
|
118
|
+
return False
|
|
119
|
+
return (datetime.now(timezone.utc) - started).total_seconds() < _LAUNCH_GRACE_SECONDS
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_watch_running(project_root: Path | str) -> bool:
|
|
123
|
+
"""True if a Refactor Terminal is (believed to be) open for this repo."""
|
|
124
|
+
data = read_lock(project_root)
|
|
125
|
+
if not data:
|
|
126
|
+
return False
|
|
127
|
+
if _pid_alive(int(data.get("pid", 0) or 0)):
|
|
128
|
+
return True
|
|
129
|
+
# A just-launched window may not have replaced the launch lock with its own pid
|
|
130
|
+
# yet; treat the launch marker as live for a short grace window.
|
|
131
|
+
return _within_grace(data)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _watch_config(config: dict | None) -> dict:
|
|
135
|
+
if not isinstance(config, dict):
|
|
136
|
+
return {}
|
|
137
|
+
watch = config.get("watch")
|
|
138
|
+
return watch if isinstance(watch, dict) else {}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def auto_open_enabled(config: dict | None = None, env: dict | None = None) -> bool:
|
|
142
|
+
"""Whether auto-open is enabled (env wins over config; default off)."""
|
|
143
|
+
env = os.environ if env is None else env
|
|
144
|
+
raw = env.get(ENV_AUTO_OPEN)
|
|
145
|
+
if raw is not None and str(raw).strip() != "":
|
|
146
|
+
return _as_bool(raw)
|
|
147
|
+
return bool(_watch_config(config).get("auto_open", False))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_launcher(config: dict | None = None, env: dict | None = None) -> str | None:
|
|
151
|
+
"""Terminal launcher template, or ``None`` if unconfigured.
|
|
152
|
+
|
|
153
|
+
The template opens a new terminal window running a command. ``{cmd}`` is
|
|
154
|
+
substituted with the (shell-quoted) ``refactor watch`` invocation; if absent the
|
|
155
|
+
command is appended. Examples::
|
|
156
|
+
|
|
157
|
+
gnome-terminal -- bash -lc {cmd}
|
|
158
|
+
xterm -e {cmd}
|
|
159
|
+
wt.exe {cmd}
|
|
160
|
+
"""
|
|
161
|
+
env = os.environ if env is None else env
|
|
162
|
+
raw = env.get(ENV_TERMINAL)
|
|
163
|
+
if raw and str(raw).strip():
|
|
164
|
+
return str(raw).strip()
|
|
165
|
+
template = _watch_config(config).get("terminal")
|
|
166
|
+
return str(template).strip() if template and str(template).strip() else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def watch_command(executable: str = "refactor") -> list[str]:
|
|
170
|
+
"""The inner command a launched window should run."""
|
|
171
|
+
return [executable, "watch"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def build_launch_command(template: str, inner_cmd: list[str]) -> list[str]:
|
|
175
|
+
"""Render a launcher ``template`` + inner command into an argv list."""
|
|
176
|
+
inner = " ".join(shlex.quote(part) for part in inner_cmd)
|
|
177
|
+
rendered = template.replace("{cmd}", inner) if "{cmd}" in template else f"{template} {inner}"
|
|
178
|
+
return shlex.split(rendered)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def spawn_detached(cmd: list[str], cwd: Path | str) -> int | None:
|
|
182
|
+
"""Launch ``cmd`` fully detached from the current process. Returns its pid."""
|
|
183
|
+
try:
|
|
184
|
+
proc = subprocess.Popen(
|
|
185
|
+
cmd,
|
|
186
|
+
cwd=str(cwd),
|
|
187
|
+
stdin=subprocess.DEVNULL,
|
|
188
|
+
stdout=subprocess.DEVNULL,
|
|
189
|
+
stderr=subprocess.DEVNULL,
|
|
190
|
+
start_new_session=True,
|
|
191
|
+
)
|
|
192
|
+
except (OSError, ValueError):
|
|
193
|
+
return None
|
|
194
|
+
return proc.pid
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def ensure_watch_open(
|
|
198
|
+
project_root: Path | str,
|
|
199
|
+
*,
|
|
200
|
+
config: dict | None = None,
|
|
201
|
+
env: dict | None = None,
|
|
202
|
+
executable: str = "refactor",
|
|
203
|
+
force: bool = False,
|
|
204
|
+
) -> dict:
|
|
205
|
+
"""Open a Refactor Terminal if auto-open is enabled and none is running.
|
|
206
|
+
|
|
207
|
+
Pure-ish orchestration returning a result dict with ``action`` in
|
|
208
|
+
``{disabled, already_running, no_launcher, launched, launch_failed}`` plus a
|
|
209
|
+
human ``message``. ``force`` bypasses the auto-open gate (used by an explicit
|
|
210
|
+
``watch ensure --force``).
|
|
211
|
+
"""
|
|
212
|
+
if not force and not auto_open_enabled(config, env):
|
|
213
|
+
return {"action": "disabled", "message": "auto-open is disabled"}
|
|
214
|
+
if is_watch_running(project_root):
|
|
215
|
+
return {"action": "already_running", "message": "Refactor Terminal already open"}
|
|
216
|
+
|
|
217
|
+
launcher = resolve_launcher(config, env)
|
|
218
|
+
if not launcher:
|
|
219
|
+
return {
|
|
220
|
+
"action": "no_launcher",
|
|
221
|
+
"message": (
|
|
222
|
+
f"set {ENV_TERMINAL} (or watch.terminal) to a terminal launcher "
|
|
223
|
+
"template to auto-open the Refactor Terminal"
|
|
224
|
+
),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
cmd = build_launch_command(launcher, watch_command(executable))
|
|
228
|
+
pid = spawn_detached(cmd, project_root)
|
|
229
|
+
if pid is None:
|
|
230
|
+
return {"action": "launch_failed", "message": f"could not launch: {' '.join(cmd)}"}
|
|
231
|
+
# Record a launch marker so a rapid second commit does not double-open before the
|
|
232
|
+
# window comes up and writes its own pid.
|
|
233
|
+
write_lock(project_root, pid, launching=True)
|
|
234
|
+
return {"action": "launched", "message": f"opened Refactor Terminal (pid {pid})", "pid": pid}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactorai-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.16
|
|
4
4
|
Summary: Local-first CLI for the refactor platform
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
|
|
|
8
8
|
Requires-Dist: httpx>=0.27.0
|
|
9
9
|
Requires-Dist: rich>=13.7.0
|
|
10
10
|
Requires-Dist: PyYAML>=6.0.1
|
|
11
|
-
Requires-Dist: refactorai-core>=3.2.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.2.9
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
@@ -20,6 +20,7 @@ refactorai_cli/runtime_manager.py
|
|
|
20
20
|
refactorai_cli/settings.py
|
|
21
21
|
refactorai_cli/setup_flow.py
|
|
22
22
|
refactorai_cli/watch_state.py
|
|
23
|
+
refactorai_cli/watch_supervisor.py
|
|
23
24
|
refactorai_cli.egg-info/PKG-INFO
|
|
24
25
|
refactorai_cli.egg-info/SOURCES.txt
|
|
25
26
|
refactorai_cli.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli/commands/runtime_proxy_cmds.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{refactorai_cli-0.4.14 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|