refactorai-cli 0.7.11__tar.gz → 0.7.12__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.11 → refactorai_cli-0.7.12}/PKG-INFO +1 -1
  2. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/pyproject.toml +1 -1
  3. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/client.py +65 -0
  5. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/run_cmds.py +30 -77
  6. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/watch_cmds.py +10 -0
  7. refactorai_cli-0.7.12/refactorai_cli/mode_sync.py +140 -0
  8. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/setup_flow.py +77 -5
  9. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli.egg-info/PKG-INFO +1 -1
  10. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  11. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/README.md +0 -0
  12. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/auth.py +0 -0
  13. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/cloud_rr.py +0 -0
  14. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/__init__.py +0 -0
  15. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/account_cmds.py +0 -0
  16. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/auth_cmds.py +0 -0
  17. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/branch_cmds.py +0 -0
  18. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/cloud_cmds.py +0 -0
  19. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/engine_cmds.py +0 -0
  20. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/hook_cmds.py +0 -0
  21. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/model_cmds.py +0 -0
  22. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/policy_cmds.py +0 -0
  23. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/pre_push_cmds.py +0 -0
  24. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/request_cmds.py +0 -0
  25. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/rules_cmds.py +0 -0
  26. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/runtime_cmds.py +0 -0
  27. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  28. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/setup_cmds.py +0 -0
  29. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  30. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commands/workspace_cmds.py +0 -0
  31. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commit_queue.py +0 -0
  32. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/commit_telemetry.py +0 -0
  33. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/control_plane.py +0 -0
  34. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/credentials.py +0 -0
  35. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/dotenv_loader.py +0 -0
  36. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/git_scope.py +0 -0
  37. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/local_constitution.py +0 -0
  38. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/local_engine_runtime.py +0 -0
  39. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/local_paths.py +0 -0
  40. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/main.py +0 -0
  41. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/model_policy.py +0 -0
  42. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/pre_push_gate.py +0 -0
  43. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/refactor_branch_store.py +0 -0
  44. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/review_runner.py +0 -0
  45. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/runtime_manager.py +0 -0
  46. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/settings.py +0 -0
  47. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/watch_ledger.py +0 -0
  48. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/watch_state.py +0 -0
  49. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli/watch_supervisor.py +0 -0
  50. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  51. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli.egg-info/entry_points.txt +0 -0
  52. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli.egg-info/requires.txt +0 -0
  53. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/refactorai_cli.egg-info/top_level.txt +0 -0
  54. {refactorai_cli-0.7.11 → refactorai_cli-0.7.12}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.7.11
3
+ Version: 0.7.12
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.11"
3
+ version = "0.7.12"
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.11"
8
+ __version__ = "0.7.12"
@@ -143,3 +143,68 @@ class PlatformClient:
143
143
  )
144
144
  payload = response.json()
145
145
  return payload if isinstance(payload, dict) else {}
146
+
147
+ def get_project_execution_mode(self, developer_key: str, project_id: str) -> dict:
148
+ """Read a project's engine execution settings via the developer key.
149
+
150
+ Used by the CLI (``setup`` / ``doctor`` / ``watch``) to sync the server's
151
+ ``execution_mode`` (as chosen in the web UI) into the local
152
+ ``refactor.config`` -- server-wins. Returns the project record dict.
153
+ """
154
+ try:
155
+ response = httpx.get(
156
+ f"{self.base_url}/v1/projects/{project_id}/execution-mode",
157
+ headers={"Authorization": f"Bearer {developer_key}"},
158
+ timeout=self.timeout,
159
+ )
160
+ except httpx.HTTPError as exc:
161
+ raise PlatformError(f"Could not reach platform at {self.base_url}: {exc}") from exc
162
+ if response.status_code == 401:
163
+ raise PlatformError("Developer key is invalid or revoked", status_code=401)
164
+ if response.status_code == 404:
165
+ raise PlatformError("Project not found for this account", status_code=404)
166
+ if response.status_code >= 400:
167
+ raise PlatformError(
168
+ f"Could not read project execution mode ({response.status_code})",
169
+ status_code=response.status_code,
170
+ )
171
+ payload = response.json() if response.content else {}
172
+ return payload if isinstance(payload, dict) else {}
173
+
174
+ def set_project_execution_mode(
175
+ self, developer_key: str, project_id: str, execution_mode: str
176
+ ) -> dict:
177
+ """Push a chosen execution variant to the server project (developer key).
178
+
179
+ Lets ``refactor setup --mode`` keep the terminal and the web UI in sync.
180
+ Returns the updated project record dict.
181
+ """
182
+ try:
183
+ response = httpx.patch(
184
+ f"{self.base_url}/v1/projects/{project_id}/execution-mode",
185
+ headers={
186
+ "Authorization": f"Bearer {developer_key}",
187
+ "Content-Type": "application/json",
188
+ },
189
+ json={"execution_mode": execution_mode},
190
+ timeout=self.timeout,
191
+ )
192
+ except httpx.HTTPError as exc:
193
+ raise PlatformError(f"Could not reach platform at {self.base_url}: {exc}") from exc
194
+ if response.status_code == 401:
195
+ raise PlatformError("Developer key is invalid or revoked", status_code=401)
196
+ if response.status_code == 404:
197
+ raise PlatformError("Project not found for this account", status_code=404)
198
+ if response.status_code >= 400:
199
+ detail = ""
200
+ try:
201
+ detail = str((response.json() or {}).get("detail", ""))
202
+ except Exception:
203
+ detail = response.text[:200]
204
+ suffix = f": {detail}" if detail else ""
205
+ raise PlatformError(
206
+ f"Could not update project execution mode ({response.status_code}){suffix}",
207
+ status_code=response.status_code,
208
+ )
209
+ payload = response.json() if response.content else {}
210
+ return payload if isinstance(payload, dict) else {}
@@ -126,15 +126,8 @@ from refactorai_cli.auth import AuthContext, AuthError, ensure_authenticated
126
126
  from refactorai_cli.client import PlatformClient, PlatformError
127
127
  from refactorai_cli.credentials import load_credentials, save_credentials
128
128
  from refactorai_cli.dotenv_loader import DotenvResult, load_project_dotenv
129
- from refactorai_cli.local_engine_runtime import DEFAULT_ENGINE_PORT, read_engine_state
130
129
  from refactorai_cli.runtime_manager import runtime_status, runtime_version_dir
131
130
  from refactorai_cli.settings import mask_key, platform_url
132
- from refactorai_cli.setup_flow import (
133
- BACKEND_BYOK,
134
- BACKEND_LOCAL_SERVER,
135
- read_setup_state,
136
- stage_output_path,
137
- )
138
131
 
139
132
  console = Console()
140
133
 
@@ -165,66 +158,16 @@ def _sandbox_runtime():
165
158
  return _sr
166
159
 
167
160
 
168
- def _read_stage_output(stage_id: str) -> dict:
169
- path = stage_output_path(stage_id)
170
- if not path.is_file():
171
- return {}
172
- try:
173
- payload = json.loads(path.read_text(encoding="utf-8"))
174
- except (json.JSONDecodeError, OSError):
175
- return {}
176
- output = payload.get("output")
177
- return output if isinstance(output, dict) else {}
178
-
179
-
180
- def _setup_recommended_model_id() -> str:
181
- s5_output = _read_stage_output("S5")
182
- model_id = str(s5_output.get("recommended_model_id") or "").strip()
183
- return model_id or "qwen2.5-coder:1.5b-instruct"
184
-
185
-
186
- def _setup_engine_base_url() -> str:
187
- state = read_engine_state()
188
- try:
189
- port = int(state.get("port", DEFAULT_ENGINE_PORT))
190
- except (TypeError, ValueError):
191
- port = DEFAULT_ENGINE_PORT
192
- return f"http://127.0.0.1:{port}/v1"
193
-
194
-
195
161
  def _default_config_for_init() -> tuple[str, str]:
196
- setup_state = read_setup_state()
197
- backend = str(setup_state.get("execution_backend") or "").strip().lower()
198
- if backend == BACKEND_LOCAL_SERVER:
199
- model_id = _setup_recommended_model_id()
200
- base_url = _setup_engine_base_url()
201
- # local_model runs tests on the host and swaps the default heuristic
202
- # provider block for an Ollama block pointing at the local engine.
203
- config_text = DEFAULT_CONFIG.replace(
204
- "# Heuristic (local, no LLM) - default for execution_variant: local_heuristic\n"
205
- "provider: heuristic\n"
206
- "model_id: heuristic-local-v1",
207
- "# Ollama (local engine) - configured by `refactor init`\n"
208
- "provider: ollama\n"
209
- f"model_id: {model_id}\n"
210
- f"base_url: {base_url}",
211
- 1,
212
- )
213
- config_text = config_text.replace(
214
- "execution_variant: local_heuristic",
215
- "execution_variant: local_model",
216
- 1,
217
- )
218
- return config_text, "local_server"
219
- if backend == BACKEND_BYOK:
220
- # BYOK only flips the execution variant. The developer chooses and
221
- # uncomments a provider block in the Provider selection section.
222
- config_text = DEFAULT_CONFIG.replace(
223
- "execution_variant: local_heuristic",
224
- "execution_variant: cloud_byok",
225
- 1,
226
- )
227
- return config_text, "byok"
162
+ """Return the config `init` writes: always the ``local_heuristic`` default.
163
+
164
+ Mode selection is a *setup*-time concern (docs/46): after ``init`` the
165
+ developer picks the execution variant in the web UI or via ``refactor setup
166
+ --mode``, which persists it to ``refactor.config`` (and the server project).
167
+ ``init`` therefore never derives the mode from prior setup-backend state --
168
+ that legacy coupling produced configs whose variant disagreed with the
169
+ server's default and is intentionally removed.
170
+ """
228
171
  return DEFAULT_CONFIG, "default"
229
172
 
230
173
  _SEVERITY_STYLE = {
@@ -1479,7 +1422,7 @@ def init(
1479
1422
  raise typer.Exit(code=1)
1480
1423
 
1481
1424
  consti_target.write_text(DEFAULT_CONSTITUTION, encoding="utf-8")
1482
- config_text, config_profile = _default_config_for_init()
1425
+ config_text, _config_profile = _default_config_for_init()
1483
1426
  config_text = _inject_project_id(config_text, project_id)
1484
1427
  config_target.write_text(config_text, encoding="utf-8")
1485
1428
  gitignore_updated = _ensure_refactor_gitignore(project_root)
@@ -1534,16 +1477,11 @@ def init(
1534
1477
  f"{account_label.title()}: [bold]{account_value}[/bold]. "
1535
1478
  f"Project id saved in {CONFIG_FILENAME}."
1536
1479
  )
1537
- if config_profile == "local_server":
1538
- console.print(
1539
- "[green]Configured[/green] refactor.config for local server setup "
1540
- "(provider=ollama with setup-recommended model)."
1541
- )
1542
- elif config_profile == "byok":
1543
- console.print(
1544
- "[green]Configured[/green] refactor.config for BYOK setup "
1545
- "(mode=cloud_byok, env-key-first via provider_key; credential_ref is optional fallback)."
1546
- )
1480
+ console.print(
1481
+ "[green]Configured[/green] refactor.config with the default mode "
1482
+ "([bold]local_heuristic[/bold]). Choose your execution mode (and provider "
1483
+ "keys, if any) in the web UI or via `refactor setup --mode <variant>`."
1484
+ )
1547
1485
  console.print(
1548
1486
  f"Added {CONSTITUTION_FILENAME}. "
1549
1487
  f"{CONFIG_FILENAME} is treated as local by default and added to .gitignore "
@@ -5360,6 +5298,21 @@ def doctor(
5360
5298
  consti_path, _ = files
5361
5299
  project_root = consti_path.parent
5362
5300
 
5301
+ # Server-wins: pull the project's execution mode (as chosen in the web UI)
5302
+ # into the local refactor.config before reading it, so doctor diagnoses the
5303
+ # mode that runs will actually use. Best-effort; a no-op when offline.
5304
+ try:
5305
+ from refactorai_cli.mode_sync import sync_execution_variant_from_server
5306
+
5307
+ synced = sync_execution_variant_from_server(
5308
+ project_root, emit=lambda m: console.print(f"[dim]{m}[/dim]")
5309
+ )
5310
+ if synced:
5311
+ files = find_project_files() or files
5312
+ consti_path, _ = files
5313
+ except Exception:
5314
+ pass
5315
+
5363
5316
  # Load ``.env`` BEFORE parsing config so ``${VAR}`` interpolation (for
5364
5317
  # example ``provider_key: ${OPENAI_API_KEY}``) resolves from it. The
5365
5318
  # entrypoint already loads it for real runs; here we also surface the status
@@ -1098,6 +1098,16 @@ def watch(
1098
1098
  _ensure_auth_in_window(json_output)
1099
1099
  _report_heartbeat(project_root)
1100
1100
 
1101
+ # Server-wins: sync the project's execution mode (web UI choice) into the
1102
+ # local refactor.config before the loop reads it. Silent + best-effort so it
1103
+ # never pollutes the --json stream or blocks the terminal when offline.
1104
+ try:
1105
+ from refactorai_cli.mode_sync import sync_execution_variant_from_server
1106
+
1107
+ sync_execution_variant_from_server(project_root)
1108
+ except Exception:
1109
+ pass
1110
+
1101
1111
  state = WatchState(repo_name=project_root.name)
1102
1112
  try:
1103
1113
  state.current_branch = git_scope.current_branch(project_root) or ""
@@ -0,0 +1,140 @@
1
+ """Execution-mode sync between the server project and local ``refactor.config``.
2
+
3
+ The web UI sets a project's ``execution_mode`` on the server (JWT). The CLI
4
+ authenticates with a developer key and treats the server as the source of truth
5
+ (server-wins): ``setup`` / ``doctor`` / ``watch`` pull the server mode and write
6
+ it into the local ``refactor.config`` so a UI change reflects in terminal runs.
7
+ ``refactor setup --mode`` also pushes the chosen variant up so both agree.
8
+
9
+ All operations are best-effort: offline / unauthenticated / unbound projects are
10
+ a silent no-op so they never break the surrounding command.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+ from refactor_core.constitution import CONFIG_FILENAME, find_project_files
19
+ from refactor_core.execution_variant import (
20
+ normalize_execution_variant,
21
+ resolve_execution_variant,
22
+ )
23
+ from refactor_core.store import resolve_project_id
24
+
25
+ from refactorai_cli.client import PlatformClient
26
+ from refactorai_cli.credentials import resolve_developer_key
27
+
28
+
29
+ def _local_config_path(project_root: Path) -> Path | None:
30
+ files = find_project_files(project_root)
31
+ if files:
32
+ return Path(files[1])
33
+ candidate = Path(project_root) / CONFIG_FILENAME
34
+ return candidate if candidate.is_file() else None
35
+
36
+
37
+ def _read_local_variant(project_root: Path) -> str:
38
+ files = find_project_files(project_root)
39
+ if not files:
40
+ return ""
41
+ try:
42
+ from refactor_core.constitution import load_constitution
43
+
44
+ constitution = load_constitution(files[0])
45
+ return resolve_execution_variant(getattr(constitution, "settings", {}) or {})
46
+ except Exception:
47
+ return ""
48
+
49
+
50
+ def _write_variant_to_config(config_path: Path, variant: str) -> bool:
51
+ """Replace (or append) the ``execution_variant`` line in refactor.config."""
52
+ try:
53
+ text = config_path.read_text(encoding="utf-8")
54
+ except OSError:
55
+ return False
56
+ lines = text.splitlines()
57
+ new_line = f"execution_variant: {variant}"
58
+ replaced = False
59
+ for idx, line in enumerate(lines):
60
+ if line.strip().startswith("execution_variant:"):
61
+ if line.strip() == new_line:
62
+ return False
63
+ lines[idx] = new_line
64
+ replaced = True
65
+ break
66
+ if not replaced:
67
+ lines.append(new_line)
68
+ try:
69
+ config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
70
+ except OSError:
71
+ return False
72
+ return True
73
+
74
+
75
+ def sync_execution_variant_from_server(
76
+ project_root: Path,
77
+ *,
78
+ client: PlatformClient | None = None,
79
+ emit: Callable[[str], None] | None = None,
80
+ ) -> str | None:
81
+ """Pull the server project's execution mode into local config (server-wins).
82
+
83
+ Returns the newly applied variant when the local config was updated, else
84
+ ``None`` (already in sync, offline, unauthenticated, or unbound project).
85
+ """
86
+ try:
87
+ project_id = resolve_project_id(project_root)
88
+ if not project_id:
89
+ return None
90
+ resolved = resolve_developer_key(project_root)
91
+ if not resolved:
92
+ return None
93
+ client = client or PlatformClient()
94
+ record = client.get_project_execution_mode(resolved.key, project_id)
95
+ except Exception:
96
+ return None
97
+
98
+ server_variant = normalize_execution_variant((record or {}).get("execution_mode"))
99
+ if not server_variant:
100
+ return None
101
+ if server_variant == _read_local_variant(project_root):
102
+ return None
103
+ config_path = _local_config_path(project_root)
104
+ if config_path is None:
105
+ return None
106
+ if _write_variant_to_config(config_path, server_variant):
107
+ if emit:
108
+ emit(f"Synced execution_variant from server: {server_variant}")
109
+ return server_variant
110
+ return None
111
+
112
+
113
+ def push_execution_variant_to_server(
114
+ project_root: Path,
115
+ variant: str,
116
+ *,
117
+ client: PlatformClient | None = None,
118
+ emit: Callable[[str], None] | None = None,
119
+ ) -> bool:
120
+ """Push a locally chosen execution variant to the server project.
121
+
122
+ Best-effort; returns ``True`` on a successful push, else ``False``.
123
+ """
124
+ normalized = normalize_execution_variant(variant)
125
+ if not normalized:
126
+ return False
127
+ try:
128
+ project_id = resolve_project_id(project_root)
129
+ if not project_id:
130
+ return False
131
+ resolved = resolve_developer_key(project_root)
132
+ if not resolved:
133
+ return False
134
+ client = client or PlatformClient()
135
+ client.set_project_execution_mode(resolved.key, project_id, normalized)
136
+ except Exception:
137
+ return False
138
+ if emit:
139
+ emit(f"Pushed execution_variant to server: {normalized}")
140
+ return True
@@ -18,9 +18,11 @@ from rich.progress import BarColumn, DownloadColumn, Progress, TaskID, TextColum
18
18
  from refactorai_cli.local_engine_runtime import (
19
19
  DEFAULT_ENGINE_CONTAINER,
20
20
  DEFAULT_ENGINE_IMAGE,
21
+ DEFAULT_ENGINE_PORT,
21
22
  engine_status,
22
23
  ensure_engine_up,
23
24
  pull_model,
25
+ read_engine_state,
24
26
  resolve_runtime as resolve_engine_runtime,
25
27
  )
26
28
  from refactorai_cli.local_paths import ensure_dir, refactor_home
@@ -151,6 +153,19 @@ def write_stage_output(stage_id: str, payload: dict) -> None:
151
153
  tmp.replace(path)
152
154
 
153
155
 
156
+ def read_stage_output(stage_id: str) -> dict:
157
+ """Return a stage's persisted ``output`` dict (or ``{}`` when absent)."""
158
+ path = stage_output_path(stage_id)
159
+ if not path.is_file():
160
+ return {}
161
+ try:
162
+ payload = json.loads(path.read_text(encoding="utf-8"))
163
+ except (json.JSONDecodeError, OSError):
164
+ return {}
165
+ output = payload.get("output")
166
+ return output if isinstance(output, dict) else {}
167
+
168
+
154
169
  def get_setup_diagnostics() -> dict:
155
170
  state = read_setup_state()
156
171
  stages: dict[str, dict] = {}
@@ -562,12 +577,54 @@ def _resolve_project_variant() -> str:
562
577
  return ""
563
578
 
564
579
 
580
+ def _local_model_provider_values() -> tuple[str, str]:
581
+ """Resolve (model_id, base_url) for the local_model Ollama provider block.
582
+
583
+ ``model_id`` prefers the S5 setup recommendation, falling back to a small
584
+ default; ``base_url`` targets the local engine port.
585
+ """
586
+ model_id = ""
587
+ try:
588
+ s5 = read_stage_output("S5")
589
+ model_id = str(s5.get("recommended_model_id") or "").strip()
590
+ except Exception:
591
+ model_id = ""
592
+ if not model_id:
593
+ model_id = "qwen2.5-coder:1.5b-instruct"
594
+ try:
595
+ port = int(read_engine_state().get("port", DEFAULT_ENGINE_PORT))
596
+ except Exception:
597
+ port = DEFAULT_ENGINE_PORT
598
+ return model_id, f"http://127.0.0.1:{port}/v1"
599
+
600
+
601
+ def _apply_local_model_provider_block(text: str) -> str:
602
+ """Swap the default heuristic provider block for an Ollama block (local_model).
603
+
604
+ Only rewrites when the untouched heuristic default is still present, so a
605
+ developer who already configured a provider is never overwritten.
606
+ """
607
+ model_id, base_url = _local_model_provider_values()
608
+ return text.replace(
609
+ "# Heuristic (local, no LLM) - default for execution_variant: local_heuristic\n"
610
+ "provider: heuristic\n"
611
+ "model_id: heuristic-local-v1",
612
+ "# Ollama (local engine) - configured by `refactor setup --mode local_model`\n"
613
+ "provider: ollama\n"
614
+ f"model_id: {model_id}\n"
615
+ f"base_url: {base_url}",
616
+ 1,
617
+ )
618
+
619
+
565
620
  def _persist_variant_to_project_config(variant: str, emit: Callable[[str], None] | None = None) -> None:
566
621
  """Write the chosen ``execution_variant`` into the project's refactor.config.
567
622
 
568
623
  Keeps a UI-selected or terminal-selected mode as the single source of truth
569
- for subsequent runs (doctor/review/code read it from config). Best-effort: a
570
- missing project config or write error never fails setup.
624
+ for subsequent runs (doctor/review/code read it from config). For
625
+ ``local_model`` it also swaps the default heuristic provider block for a local
626
+ Ollama block (docs/46: local_model provider config is a setup concern, not an
627
+ init concern). Best-effort: a missing config or write error never fails setup.
571
628
  """
572
629
  if not variant:
573
630
  return
@@ -580,14 +637,15 @@ def _persist_variant_to_project_config(variant: str, emit: Callable[[str], None]
580
637
  _consti_path, config_path = files
581
638
  config_path = Path(config_path)
582
639
  text = config_path.read_text(encoding="utf-8")
640
+ if variant == LOCAL_MODEL:
641
+ text = _apply_local_model_provider_block(text)
583
642
  lines = text.splitlines()
584
643
  new_line = f"execution_variant: {variant}"
585
644
  replaced = False
586
645
  for idx, line in enumerate(lines):
587
646
  if line.strip().startswith("execution_variant:"):
588
- if line.strip() == new_line:
589
- return
590
- lines[idx] = new_line
647
+ if line.strip() != new_line:
648
+ lines[idx] = new_line
591
649
  replaced = True
592
650
  break
593
651
  if not replaced:
@@ -819,6 +877,20 @@ def run_setup(
819
877
  resolved_variant = _resolve_project_variant()
820
878
  if resolved_variant:
821
879
  _persist_variant_to_project_config(resolved_variant, on_progress)
880
+ # Push the chosen variant to the server project so the web UI and the
881
+ # terminal agree (server-wins on later syncs). Best-effort; never blocks
882
+ # setup when offline or the project is not bound to a cloud record.
883
+ try:
884
+ from refactor_core.constitution import find_project_files
885
+ from refactorai_cli.mode_sync import push_execution_variant_to_server
886
+
887
+ files = find_project_files()
888
+ if files:
889
+ push_execution_variant_to_server(
890
+ Path(files[0]).parent, resolved_variant, emit=on_progress
891
+ )
892
+ except Exception:
893
+ pass
822
894
 
823
895
  state = read_setup_state()
824
896
  if not resume and not from_stage:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.7.11
3
+ Version: 0.7.12
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -14,6 +14,7 @@ refactorai_cli/local_constitution.py
14
14
  refactorai_cli/local_engine_runtime.py
15
15
  refactorai_cli/local_paths.py
16
16
  refactorai_cli/main.py
17
+ refactorai_cli/mode_sync.py
17
18
  refactorai_cli/model_policy.py
18
19
  refactorai_cli/pre_push_gate.py
19
20
  refactorai_cli/refactor_branch_store.py