refactorai-cli 0.3.4__tar.gz → 0.3.5__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 (35) hide show
  1. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/PKG-INFO +1 -1
  2. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/pyproject.toml +1 -1
  3. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/client.py +18 -0
  5. refactorai_cli-0.3.5/refactorai_cli/commands/account_cmds.py +76 -0
  6. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/auth_cmds.py +3 -0
  7. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/run_cmds.py +254 -6
  8. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/runtime_proxy_cmds.py +7 -0
  9. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/main.py +47 -0
  10. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/PKG-INFO +1 -1
  11. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  12. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/README.md +0 -0
  13. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/auth.py +0 -0
  14. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/cloud_rr.py +0 -0
  15. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/__init__.py +0 -0
  16. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/cloud_cmds.py +0 -0
  17. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/engine_cmds.py +0 -0
  18. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/model_cmds.py +0 -0
  19. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/rules_cmds.py +0 -0
  20. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/runtime_cmds.py +0 -0
  21. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/commands/setup_cmds.py +0 -0
  22. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/control_plane.py +0 -0
  23. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/credentials.py +0 -0
  24. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/local_constitution.py +0 -0
  25. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/local_engine_runtime.py +0 -0
  26. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/local_paths.py +0 -0
  27. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/model_policy.py +0 -0
  28. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/runtime_manager.py +0 -0
  29. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/settings.py +0 -0
  30. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli/setup_flow.py +0 -0
  31. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  32. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/entry_points.txt +0 -0
  33. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/requires.txt +0 -0
  34. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/top_level.txt +0 -0
  35. {refactorai_cli-0.3.4 → refactorai_cli-0.3.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.4
3
+ Version: 0.3.5
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.3.4"
3
+ version = "0.3.5"
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.3.4"
8
+ __version__ = "0.3.5"
@@ -44,3 +44,21 @@ class PlatformClient:
44
44
  f"Key validation failed ({response.status_code})", status_code=response.status_code
45
45
  )
46
46
  return response.json()
47
+
48
+ def list_key_accounts(self, developer_key: str) -> list[dict]:
49
+ try:
50
+ response = httpx.get(
51
+ f"{self.base_url}/v1/keys/accounts",
52
+ headers={"Authorization": f"Bearer {developer_key}"},
53
+ timeout=self.timeout,
54
+ )
55
+ except httpx.HTTPError as exc:
56
+ raise PlatformError(f"Could not reach platform at {self.base_url}: {exc}") from exc
57
+ if response.status_code == 401:
58
+ raise PlatformError("Developer key is invalid or revoked", status_code=401)
59
+ if response.status_code >= 400:
60
+ raise PlatformError(
61
+ f"Account discovery failed ({response.status_code})", status_code=response.status_code
62
+ )
63
+ payload = response.json()
64
+ return payload if isinstance(payload, list) else []
@@ -0,0 +1,76 @@
1
+ """`refactor account` commands for account context selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from refactorai_cli.auth import AuthError, ensure_authenticated
10
+ from refactorai_cli.client import PlatformClient, PlatformError
11
+ from refactorai_cli.credentials import load_credentials, save_credentials
12
+
13
+ console = Console()
14
+ app = typer.Typer(
15
+ name="account",
16
+ help="List and switch account context for the current developer key.",
17
+ no_args_is_help=True,
18
+ add_completion=False,
19
+ )
20
+
21
+
22
+ def _discover_accounts() -> tuple[list[dict], dict]:
23
+ try:
24
+ auth_ctx = ensure_authenticated(force_remote=True)
25
+ except AuthError as exc:
26
+ console.print(f"[red]Not authenticated:[/red] {exc}")
27
+ raise typer.Exit(code=1) from exc
28
+ client = PlatformClient()
29
+ try:
30
+ rows = client.list_key_accounts(auth_ctx.key.key)
31
+ except PlatformError as exc:
32
+ console.print(f"[red]Could not load accounts:[/red] {exc}")
33
+ raise typer.Exit(code=1) from exc
34
+ return rows, load_credentials()
35
+
36
+
37
+ @app.command("list")
38
+ def account_list() -> None:
39
+ rows, creds = _discover_accounts()
40
+ active_slug = str(creds.get("active_account_slug") or "")
41
+ if not rows:
42
+ console.print("[dim]No accounts available for this key.[/dim]")
43
+ return
44
+ table = Table(title="Accounts")
45
+ table.add_column("current")
46
+ table.add_column("slug")
47
+ table.add_column("name")
48
+ table.add_column("type")
49
+ table.add_column("role")
50
+ for row in rows:
51
+ slug = str(row.get("slug") or "")
52
+ table.add_row("*" if slug and slug == active_slug else "", slug, str(row.get("display_name") or ""), str(row.get("type") or ""), str(row.get("role") or ""))
53
+ console.print(table)
54
+
55
+
56
+ @app.command("use")
57
+ def account_use(
58
+ slug: str = typer.Argument(..., help="Account slug to set active."),
59
+ ) -> None:
60
+ rows, creds = _discover_accounts()
61
+ selected = next((row for row in rows if str(row.get("slug") or "").lower() == slug.lower()), None)
62
+ if selected is None:
63
+ console.print(f"[red]Account not available for this key:[/red] {slug}")
64
+ raise typer.Exit(code=1)
65
+ creds["active_account_id"] = selected.get("id")
66
+ creds["active_account_slug"] = selected.get("slug")
67
+ save_credentials(creds)
68
+ console.print(f"[green]Active account set:[/green] {selected.get('slug')} ({selected.get('display_name')})")
69
+
70
+
71
+ @app.command("current")
72
+ def account_current() -> None:
73
+ creds = load_credentials()
74
+ slug = creds.get("active_account_slug") or "<unset>"
75
+ account_id = creds.get("active_account_id") or "<unset>"
76
+ console.print(f"[bold]active_account_slug[/bold]: {slug}\n[bold]active_account_id[/bold]: {account_id}")
@@ -43,6 +43,8 @@ def login(
43
43
  {
44
44
  "developer_key": developer_key,
45
45
  "account_id": payload.get("account_id"),
46
+ "active_account_id": payload.get("account_id"),
47
+ "active_account_slug": payload.get("account_slug"),
46
48
  "platform_url": platform_url(),
47
49
  "masked_key": mask_key(developer_key),
48
50
  }
@@ -80,6 +82,7 @@ def whoami() -> None:
80
82
  suffix = " (cached)" if ctx.cached else ""
81
83
  console.print(
82
84
  f"[bold]account[/bold]: {ctx.account_id}\n"
85
+ f"[bold]active_account_slug[/bold]: {load_credentials().get('active_account_slug') or 'unknown'}\n"
83
86
  f"[bold]key[/bold]: {mask_key(ctx.key.key)} (source: {source})\n"
84
87
  f"[bold]quota_remaining[/bold]: {quota}{suffix}"
85
88
  )
@@ -53,6 +53,13 @@ from refactor_core.apply import (
53
53
  from refactor_core.budgeting import compute_budget
54
54
  from refactor_core.capabilities import resolve_capabilities
55
55
  from refactor_core.gate import evaluate_gate
56
+ from refactor_core.merge_gate import (
57
+ build_merge_contract,
58
+ merge_contract_result,
59
+ resolve_merge_gate_settings,
60
+ run_l4_adapter,
61
+ should_run_l4,
62
+ )
56
63
  from refactor_core.refactor_requests import (
57
64
  DEFERRED,
58
65
  RequestSuppression,
@@ -68,7 +75,11 @@ from refactor_core.refactor_ops import remediation_hint
68
75
  from refactor_core.indexing import index_target
69
76
  from refactor_core.models import Finding, GeneratedTest, PatchGroup, RunArtifact
70
77
  from refactor_core.rules import annotate_documents_with_rules, filter_documents_by_rules
71
- from refactor_core.security import merge_refactor_findings, suppress_findings_for_rr
78
+ from refactor_core.security import (
79
+ compute_semgrep_security_summary,
80
+ merge_refactor_findings,
81
+ suppress_findings_for_rr,
82
+ )
72
83
  from refactor_core.security.semgrep_runner import EMBEDDED_SEMGREP_SENTINEL
73
84
  from refactor_core.security.suppression import SecuritySuppression
74
85
  from refactor_core.store import (
@@ -635,7 +646,8 @@ def _cloud_inference(
635
646
  model_hint: str | None = None,
636
647
  findings: list | None = None,
637
648
  ) -> RunArtifact:
638
- max_alerts = int(constitution.get_setting("max_alerts_per_run", 5) or 5)
649
+ _raw_alerts = constitution.get_setting("max_alerts_per_run", 5)
650
+ max_alerts = int(_raw_alerts if _raw_alerts is not None else 5)
639
651
  gate_mode = str(constitution.get_setting("gate_mode", "strict") or "strict")
640
652
  excludes = set(constitution.get_setting("exclude", []) or [])
641
653
  documents = index_target(Path(project_root), target, excludes=excludes)
@@ -709,13 +721,40 @@ def _cloud_inference(
709
721
  raise RuntimeError(f"Cloud inference failed ({response.status_code}): {response.text[:500]}")
710
722
 
711
723
  data = response.json() if response.content else {}
712
- findings = [Finding.model_validate(item) for item in (data.get("findings") or [])][:max_alerts]
713
- patches = [PatchGroup.model_validate(item) for item in (data.get("patches") or [])][:max_alerts]
724
+ _no_cap = max_alerts <= 0
725
+ findings = [Finding.model_validate(item) for item in (data.get("findings") or [])]
726
+ patches = [PatchGroup.model_validate(item) for item in (data.get("patches") or [])]
714
727
  generated_tests = [
715
728
  GeneratedTest.model_validate(item) for item in (data.get("generated_tests") or [])
716
- ][:max_alerts]
729
+ ]
730
+ if not _no_cap:
731
+ findings = findings[:max_alerts]
732
+ patches = patches[:max_alerts]
733
+ generated_tests = generated_tests[:max_alerts]
717
734
  gate = evaluate_gate(findings, [], mode=gate_mode)
718
735
 
736
+ # Optional security-scan enrichment (R19). Semgrep is an additive signal and
737
+ # strictly fail-open: it never blocks the core cloud run and the gate stays
738
+ # driven by model findings only. Prefer the server-side baseline (Phase 2)
739
+ # when the platform ran it; otherwise fall back to a client-side scan against
740
+ # the local checkout (Phase 1) so parity holds against older backends.
741
+ security_summary: dict = {}
742
+ security_notes: list[str] = []
743
+ server_summary = data.get("security_summary")
744
+ if isinstance(server_summary, dict) and server_summary.get("enabled"):
745
+ security_summary = server_summary
746
+ else:
747
+ try:
748
+ security_summary, security_notes = compute_semgrep_security_summary(
749
+ project_root=Path(project_root),
750
+ constitution=constitution,
751
+ target=target,
752
+ base_findings=findings,
753
+ )
754
+ except Exception as exc: # optional feature must never fail the cloud run
755
+ security_summary = {}
756
+ security_notes = [f"Security baseline scan skipped (unexpected error): {exc}"]
757
+
719
758
  run_id = uuid.uuid4().hex[:12]
720
759
  if stage == "review":
721
760
  summary = (
@@ -736,9 +775,11 @@ def _cloud_inference(
736
775
  summary=summary,
737
776
  constitution_hash=constitution.content_hash,
738
777
  model_id=str(data.get("model_id") or payload["model_hint"]),
778
+ notes=security_notes,
739
779
  rule_source=rule_meta.get("rule_source", ""),
740
780
  rule_pattern=rule_meta.get("rule_pattern", ""),
741
781
  rule_hash=rule_meta.get("rule_hash", ""),
782
+ security_summary=security_summary,
742
783
  )
743
784
 
744
785
 
@@ -1893,6 +1934,7 @@ def review(
1893
1934
  artifact=artifact,
1894
1935
  )
1895
1936
  _apply_compliance_gate_to_artifact(artifact)
1937
+ _apply_merge_contract_to_artifact(artifact, project_root, project_config)
1896
1938
  run_store.write_artifact(artifact, target=target, status="completed")
1897
1939
  if compliance_frameworks:
1898
1940
  summary = dict(getattr(artifact, "compliance_summary", {}) or {})
@@ -2109,6 +2151,7 @@ def code(
2109
2151
  artifact=review_artifact,
2110
2152
  )
2111
2153
  _apply_compliance_gate_to_artifact(review_artifact)
2154
+ _apply_merge_contract_to_artifact(review_artifact, project_root, project_config)
2112
2155
  if compliance_frameworks:
2113
2156
  summary = dict(getattr(review_artifact, "compliance_summary", {}) or {})
2114
2157
  console.print(
@@ -2685,6 +2728,94 @@ def _apply_compliance_gate_to_artifact(artifact: RunArtifact) -> None:
2685
2728
  artifact.gate.blocking_reasons["reasons"] = reasons
2686
2729
 
2687
2730
 
2731
+ def _detect_changed_files(project_root: Path) -> list[str] | None:
2732
+ """Best-effort list of changed files via git (staged + unstaged + untracked).
2733
+
2734
+ Returns ``None`` when git is unavailable or the path is not a repo, so the
2735
+ change-size guardrail degrades to "unknown" rather than a false value.
2736
+ """
2737
+ try:
2738
+ proc = subprocess.run(
2739
+ ["git", "status", "--porcelain"],
2740
+ cwd=str(project_root),
2741
+ capture_output=True,
2742
+ text=True,
2743
+ timeout=15,
2744
+ check=False,
2745
+ )
2746
+ except (OSError, subprocess.SubprocessError):
2747
+ return None
2748
+ if proc.returncode != 0:
2749
+ return None
2750
+ files: list[str] = []
2751
+ for line in (proc.stdout or "").splitlines():
2752
+ name = line[3:].strip() if len(line) > 3 else line.strip()
2753
+ if " -> " in name: # rename: take the destination path
2754
+ name = name.split(" -> ", 1)[1].strip()
2755
+ if name:
2756
+ files.append(name)
2757
+ return files
2758
+
2759
+
2760
+ def _agent_meta_from_env() -> dict:
2761
+ """Resolve agent-authorship metadata from the environment (CI-friendly)."""
2762
+ authored = os.environ.get("REFACTOR_AGENT_AUTHORED", "").strip().lower() in {
2763
+ "1",
2764
+ "true",
2765
+ "yes",
2766
+ "on",
2767
+ }
2768
+ risk_tier = os.environ.get("REFACTOR_AGENT_RISK_TIER", "").strip().lower()
2769
+ return {"authored": authored, "risk_tier": risk_tier}
2770
+
2771
+
2772
+ def _apply_merge_contract_to_artifact(
2773
+ artifact: RunArtifact, project_root: Path, project_config
2774
+ ) -> None:
2775
+ """Assemble the R24 merge contract from existing run outcomes.
2776
+
2777
+ Runs identically for every execution mode (local/cloud_managed/cloud_byok):
2778
+ the CLI always assembles the artifact and persists the report locally, so the
2779
+ contract is computed once here from the gate/compliance/security signals.
2780
+
2781
+ The expensive real-system (L4) layer runs only when it is required (strict
2782
+ profile, agent policy, or an explicit ``REFACTOR_MERGE_RUN_L4`` opt-in) so the
2783
+ fast inner loop stays cheap (R24 Phase 2).
2784
+ """
2785
+ settings = resolve_merge_gate_settings(
2786
+ dict(getattr(project_config, "settings", {}) or {})
2787
+ )
2788
+ agent_meta = _agent_meta_from_env()
2789
+ changed_files = _detect_changed_files(project_root)
2790
+ changed_count = len(changed_files) if isinstance(changed_files, list) else None
2791
+
2792
+ l4_verdict = None
2793
+ if should_run_l4(settings, agent_authored=bool(agent_meta.get("authored"))):
2794
+ console.print("[dim]merge gate: running real-system (L4) validation...[/dim]")
2795
+ try:
2796
+ l4_verdict = run_l4_adapter(
2797
+ settings,
2798
+ project_root=project_root,
2799
+ run_id=str(getattr(artifact, "run_id", "") or ""),
2800
+ constitution_hash=str(getattr(artifact, "constitution_hash", "") or ""),
2801
+ changed_files=changed_files or [],
2802
+ )
2803
+ except Exception as exc: # adapter must never crash the run
2804
+ l4_verdict = {"status": "error", "reason_code": f"l4_adapter_error: {exc}"[:200]}
2805
+
2806
+ contract = build_merge_contract(
2807
+ gate=artifact.gate.model_dump(),
2808
+ compliance_summary=dict(getattr(artifact, "compliance_summary", {}) or {}),
2809
+ security_summary=dict(getattr(artifact, "security_summary", {}) or {}),
2810
+ settings=settings,
2811
+ constitution_hash=str(getattr(artifact, "constitution_hash", "") or ""),
2812
+ agent_meta=agent_meta,
2813
+ l4_verdict=l4_verdict,
2814
+ changed_files_count=changed_count,
2815
+ )
2816
+ artifact.merge_contract = contract
2817
+
2818
+
2688
2819
  def _report_framework_suppressions(
2689
2820
  suppressions: list[RequestSuppression], *, artifact: RunArtifact | None = None
2690
2821
  ) -> None:
@@ -3002,6 +3133,11 @@ def check(
3002
3133
  "--compliance-gate",
3003
3134
  help="Also enforce latest run compliance gate status (for CI/PR pipelines).",
3004
3135
  ),
3136
+ merge_gate: bool = typer.Option(
3137
+ False,
3138
+ "--merge-gate",
3139
+ help="Also enforce the latest run merge contract result (for CI/PR pipelines).",
3140
+ ),
3005
3141
  ) -> None:
3006
3142
  """Check the constitution for committed secrets (developer/provider keys)."""
3007
3143
  files = find_project_files()
@@ -3013,6 +3149,7 @@ def check(
3013
3149
  config = load_config(config_path)
3014
3150
  findings = raw_secret_matches(constitution) + raw_secret_matches_config(config)
3015
3151
  compliance_failed = False
3152
+ merge_failed = False
3016
3153
  if compliance_gate:
3017
3154
  run_dir = head_run_dir(consti_path.parent)
3018
3155
  if run_dir is None:
@@ -3031,6 +3168,29 @@ def check(
3031
3168
  )
3032
3169
  else:
3033
3170
  console.print("[green]Compliance gate[/green]: passed")
3171
+ if merge_gate:
3172
+ run_dir = head_run_dir(consti_path.parent)
3173
+ if run_dir is None:
3174
+ console.print("[yellow]Merge gate check skipped:[/yellow] no runs yet.")
3175
+ else:
3176
+ report = read_report(run_dir) or {}
3177
+ contract = report.get("merge_contract", {}) if isinstance(report, dict) else {}
3178
+ result = merge_contract_result(contract)
3179
+ if result == "blocked":
3180
+ merge_failed = True
3181
+ reasons = contract.get("blocking_reasons") or ["merge_contract_blocked"]
3182
+ console.print(
3183
+ "[red]Merge gate blocked[/red] "
3184
+ f"(profile={contract.get('profile', 'unknown')}): "
3185
+ + ", ".join(str(r) for r in reasons)
3186
+ )
3187
+ elif result == "passed":
3188
+ console.print(
3189
+ f"[green]Merge gate[/green]: passed "
3190
+ f"(profile={contract.get('profile', 'unknown')})"
3191
+ )
3192
+ else:
3193
+ console.print("[yellow]Merge gate[/yellow]: no contract in latest run.")
3034
3194
  if findings:
3035
3195
  kinds = ", ".join(sorted({kind for kind, _ in findings}))
3036
3196
  console.print(
@@ -3038,11 +3198,99 @@ def check(
3038
3198
  f"{consti_path} or {config_path}. Replace secrets with `${{ENV_VAR}}` references."
3039
3199
  )
3040
3200
  raise typer.Exit(code=1 if strict else 0)
3041
- if compliance_failed:
3201
+ if compliance_failed or merge_failed:
3042
3202
  raise typer.Exit(code=1 if strict else 0)
3043
3203
  console.print("[green]OK[/green]: no committed secret-like literals detected.")
3044
3204
 
3045
3205
 
3206
+ def gate() -> None:
3207
+ """Show the resolved merge contract for the latest run (R24, read-only)."""
3208
+ _require_auth()
3209
+ project_root, _constitution, project_config = _load_project()
3210
+ settings = resolve_merge_gate_settings(
3211
+ dict(getattr(project_config, "settings", {}) or {})
3212
+ )
3213
+ console.print("[bold]Merge gate[/bold]")
3214
+ console.print(
3215
+ f" profile: {settings.profile} "
3216
+ f"security_threshold={settings.security_threshold} "
3217
+ f"require_compliance_pass={settings.require_compliance_pass} "
3218
+ f"l4_adapter={settings.real_system_adapter}"
3219
+ )
3220
+
3221
+ run_dir = head_run_dir(project_root)
3222
+ report = read_report(run_dir) if run_dir else {}
3223
+ contract = (
3224
+ dict((report or {}).get("merge_contract", {}) or {})
3225
+ if isinstance(report, dict)
3226
+ else {}
3227
+ )
3228
+ if not contract:
3229
+ console.print(
3230
+ "[yellow]No merge contract in the latest run.[/yellow] "
3231
+ "Run `refactor review` to produce one."
3232
+ )
3233
+ return
3234
+
3235
+ layers = contract.get("layers", {}) if isinstance(contract.get("layers"), dict) else {}
3236
+ table = Table(title="Layers of confidence", show_lines=False)
3237
+ table.add_column("layer", no_wrap=True)
3238
+ table.add_column("enforcement", no_wrap=True)
3239
+ table.add_column("status", no_wrap=True)
3240
+ table.add_column("reason", no_wrap=True)
3241
+ for layer_id in ("well_formed", "internally_correct", "boundary_compatible", "system_correct"):
3242
+ entry = layers.get(layer_id, {}) if isinstance(layers.get(layer_id), dict) else {}
3243
+ code = str(entry.get("code", "")) or layer_id
3244
+ table.add_row(
3245
+ f"{code} {layer_id}",
3246
+ str(entry.get("class", "off")),
3247
+ str(entry.get("status", "unknown")),
3248
+ str(entry.get("reason_code", "") or "-"),
3249
+ )
3250
+ console.print(table)
3251
+
3252
+ # Real-system (L4) verdict details, when the adapter ran.
3253
+ l4 = layers.get("system_correct", {}) if isinstance(layers.get("system_correct"), dict) else {}
3254
+ nf = l4.get("non_functional", {}) if isinstance(l4.get("non_functional"), dict) else {}
3255
+ if l4.get("status") in {"passed", "failed", "error"} or nf.get("within_budget") is not None:
3256
+ parts = [f"status={l4.get('status', 'unknown')}"]
3257
+ if nf.get("p95_latency_ms") is not None:
3258
+ parts.append(f"p95={nf.get('p95_latency_ms')}ms")
3259
+ if nf.get("error_rate_pct") is not None:
3260
+ parts.append(f"error_rate={nf.get('error_rate_pct')}%")
3261
+ if nf.get("within_budget") is not None:
3262
+ parts.append(f"within_budget={nf.get('within_budget')}")
3263
+ console.print(" L4 real-system: " + " ".join(parts))
3264
+ evidence = str(l4.get("evidence_url", "") or "")
3265
+ if evidence:
3266
+ console.print(f" evidence: {evidence}")
3267
+
3268
+ agent = contract.get("agent", {}) if isinstance(contract.get("agent"), dict) else {}
3269
+ if agent.get("authored"):
3270
+ console.print(
3271
+ f" agent: authored=true risk_tier={agent.get('risk_tier', '') or 'unknown'} "
3272
+ f"require_l4={agent.get('require_l4', False)}"
3273
+ )
3274
+ changed = contract.get("changed_files_count")
3275
+ if isinstance(changed, int):
3276
+ console.print(f" changed_files={changed}")
3277
+
3278
+ result = merge_contract_result(contract)
3279
+ result_color = "green" if result == "passed" else ("red" if result == "blocked" else "yellow")
3280
+ console.print(
3281
+ f" quality_gate={contract.get('quality_gate', 'unknown')} "
3282
+ f"compliance={contract.get('compliance', 'unknown')} "
3283
+ f"security_findings={int((contract.get('security', {}) or {}).get('normalized_count', 0) or 0)}"
3284
+ )
3285
+ console.print(f" result: [{result_color}]{result}[/{result_color}]")
3286
+ reasons = contract.get("blocking_reasons") or []
3287
+ if reasons:
3288
+ console.print(" blocking_reasons: " + ", ".join(str(r) for r in reasons))
3289
+ consti_hash = str(contract.get("constitution_hash", "") or "")
3290
+ if consti_hash:
3291
+ console.print(f" [dim]constitution_hash={consti_hash}[/dim]")
3292
+
3293
+
3046
3294
  def _mask_secret(value: str) -> str:
3047
3295
  value = value.strip()
3048
3296
  if len(value) <= 8:
@@ -219,3 +219,10 @@ def config(ctx: typer.Context) -> None:
219
219
  def compliance(ctx: typer.Context) -> None:
220
220
  """Inspect compliance packs/coverage via proprietary runtime."""
221
221
  _exec_runtime("compliance", list(ctx.args))
222
+
223
+
224
+ def gate(ctx: typer.Context) -> None:
225
+ """Show the latest run's merge contract (read-only; handled locally)."""
226
+ # The merge contract is read from the local run report and needs no
227
+ # proprietary runtime, so it is handled locally for every execution mode.
228
+ _delegate_to_run_cmds("gate", list(ctx.args))
@@ -2,10 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+ import sys
7
+
5
8
  import typer
6
9
 
7
10
  from refactorai_cli import __version__
8
11
  from refactorai_cli.commands import (
12
+ account_cmds,
9
13
  auth_cmds,
10
14
  cloud_cmds,
11
15
  engine_cmds,
@@ -23,6 +27,43 @@ app = typer.Typer(
23
27
  )
24
28
 
25
29
 
30
+ _INTRO_COMMANDS = {"setup", "doctor", "review", "code"}
31
+ _TIP_BY_COMMAND = {
32
+ "setup": "Next: refactor doctor",
33
+ "doctor": "Next: refactor review .",
34
+ "review": "Next: refactor code . --apply",
35
+ "code": "Next: refactor review .",
36
+ }
37
+ _TITLE_BY_COMMAND = {
38
+ "setup": "Refactor Setup Agent",
39
+ "doctor": "Refactor Doctor Agent",
40
+ "review": "Refactor Review Agent",
41
+ "code": "Refactor Code Agent",
42
+ }
43
+
44
+
45
+ def _print_agent_intro(command_name: str) -> None:
46
+ # Keep this lightweight and TTY-only so scripts/non-interactive usage are unchanged.
47
+ if not sys.stdout.isatty():
48
+ return
49
+ title = _TITLE_BY_COMMAND.get(command_name, "Refactor Agent")
50
+ tip = _TIP_BY_COMMAND.get(command_name, "Next: refactor review .")
51
+ green_bold = "\033[1;38;5;46m"
52
+ green_soft = "\033[38;5;120m"
53
+ green_art = "\033[38;5;82m"
54
+ reset = "\033[0m"
55
+ typer.echo("")
56
+ typer.echo(f"{green_bold}{title}{reset}")
57
+ typer.echo(f"{green_soft}Model: Sonnet 4.5{reset}")
58
+ typer.echo(f"Welcome back, {os.environ.get('USER', 'developer')}")
59
+ typer.echo("")
60
+ typer.echo(f"{green_art} * [■ ■] *{reset}")
61
+ typer.echo(f"{green_art} * \\_/ *{reset}")
62
+ typer.echo("")
63
+ typer.echo(f"{green_bold}Tip:{reset} {tip}")
64
+ typer.echo("")
65
+
66
+
26
67
  def _version_callback(value: bool) -> None:
27
68
  if value:
28
69
  typer.echo(f"refactor {__version__}")
@@ -31,6 +72,7 @@ def _version_callback(value: bool) -> None:
31
72
 
32
73
  @app.callback()
33
74
  def main(
75
+ ctx: typer.Context,
34
76
  version: bool = typer.Option(
35
77
  False,
36
78
  "--version",
@@ -41,11 +83,15 @@ def main(
41
83
  ),
42
84
  ) -> None:
43
85
  """refactor CLI."""
86
+ command_name = (ctx.invoked_subcommand or "").strip().lower()
87
+ if command_name in _INTRO_COMMANDS:
88
+ _print_agent_intro(command_name)
44
89
 
45
90
 
46
91
  # Auth (R7.1)
47
92
  app.command()(auth_cmds.login)
48
93
  app.command()(auth_cmds.whoami)
94
+ app.add_typer(account_cmds.app, name="account")
49
95
 
50
96
  # Project / run surface (proxied to activated runtime artifact)
51
97
  _RUNTIME_CTX = {"allow_extra_args": True, "ignore_unknown_options": True}
@@ -65,6 +111,7 @@ app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.check)
65
111
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.doctor)
66
112
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.config)
67
113
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.compliance)
114
+ app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.gate)
68
115
 
69
116
  app.command()(setup_cmds.setup)
70
117
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -21,6 +21,7 @@ refactorai_cli.egg-info/entry_points.txt
21
21
  refactorai_cli.egg-info/requires.txt
22
22
  refactorai_cli.egg-info/top_level.txt
23
23
  refactorai_cli/commands/__init__.py
24
+ refactorai_cli/commands/account_cmds.py
24
25
  refactorai_cli/commands/auth_cmds.py
25
26
  refactorai_cli/commands/cloud_cmds.py
26
27
  refactorai_cli/commands/engine_cmds.py
File without changes
File without changes