refactorai-cli 0.7.11__tar.gz → 0.7.13__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.13}/PKG-INFO +51 -2
  2. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/README.md +49 -0
  3. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/pyproject.toml +2 -2
  4. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/__init__.py +1 -1
  5. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/client.py +65 -0
  6. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/run_cmds.py +42 -77
  7. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/watch_cmds.py +10 -0
  8. refactorai_cli-0.7.13/refactorai_cli/mode_sync.py +171 -0
  9. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/setup_flow.py +73 -17
  10. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli.egg-info/PKG-INFO +51 -2
  11. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  12. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli.egg-info/requires.txt +1 -1
  13. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/auth.py +0 -0
  14. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/cloud_rr.py +0 -0
  15. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/__init__.py +0 -0
  16. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/account_cmds.py +0 -0
  17. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/auth_cmds.py +0 -0
  18. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/branch_cmds.py +0 -0
  19. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/cloud_cmds.py +0 -0
  20. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/engine_cmds.py +0 -0
  21. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/hook_cmds.py +0 -0
  22. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/model_cmds.py +0 -0
  23. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/policy_cmds.py +0 -0
  24. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/pre_push_cmds.py +0 -0
  25. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/request_cmds.py +0 -0
  26. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/rules_cmds.py +0 -0
  27. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/runtime_cmds.py +0 -0
  28. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  29. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/setup_cmds.py +0 -0
  30. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  31. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commands/workspace_cmds.py +0 -0
  32. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commit_queue.py +0 -0
  33. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/commit_telemetry.py +0 -0
  34. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/control_plane.py +0 -0
  35. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/credentials.py +0 -0
  36. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/dotenv_loader.py +0 -0
  37. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/git_scope.py +0 -0
  38. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/local_constitution.py +0 -0
  39. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/local_engine_runtime.py +0 -0
  40. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/local_paths.py +0 -0
  41. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/main.py +0 -0
  42. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/model_policy.py +0 -0
  43. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/pre_push_gate.py +0 -0
  44. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/refactor_branch_store.py +0 -0
  45. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/review_runner.py +0 -0
  46. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/runtime_manager.py +0 -0
  47. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/settings.py +0 -0
  48. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/watch_ledger.py +0 -0
  49. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/watch_state.py +0 -0
  50. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli/watch_supervisor.py +0 -0
  51. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  52. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli.egg-info/entry_points.txt +0 -0
  53. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/refactorai_cli.egg-info/top_level.txt +0 -0
  54. {refactorai_cli-0.7.11 → refactorai_cli-0.7.13}/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.13
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.22
11
+ Requires-Dist: refactorai-core>=3.2.23
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -66,6 +66,55 @@ the managed host toolchain when no container runtime is available. `refactor
66
66
  doctor` reports the selected environment (and `refactor doctor --sandbox` shows
67
67
  its full state), so a run and diagnostics always agree.
68
68
 
69
+ ## Customizing refactor.config
70
+
71
+ `refactor init` writes a **bare-minimum** `refactor.config`: just `version`,
72
+ `project_id`, and a CLI-managed block seeded to `local_heuristic`. A normal
73
+ developer never edits this file by hand — pick the mode (and provider keys, if
74
+ any) in the web UI or via `refactor setup --mode <variant>`.
75
+
76
+ ### The managed block (single source of truth for the mode)
77
+
78
+ ```
79
+ # >>> refactor managed (synced from server; do not edit) >>>
80
+ execution_variant: cloud_byok
81
+ provider: openai
82
+ model_id: gpt-4.1-mini
83
+ credential_ref: cred_01J...
84
+ # <<< refactor managed <<<
85
+ ```
86
+
87
+ - Owned by the CLI. `refactor setup` (terminal) writes it immediately; a change
88
+ made in the web UI is pulled into this block on the next `refactor doctor` /
89
+ `refactor watch` / `refactor setup` (**server-wins**).
90
+ - Only these keys live here: `execution_variant`, `provider`, `model_id`,
91
+ `base_url` (local_model), `credential_ref`. **Secrets are never written** —
92
+ keep provider keys in the environment and reference them as `${ENV_VAR}`.
93
+
94
+ ### What each mode needs
95
+
96
+ | variant | you provide |
97
+ | ---------------- | ------------------------------------------------------- |
98
+ | `local_heuristic`| nothing |
99
+ | `local_model` | nothing (setup writes the Ollama provider/model block) |
100
+ | `local_byok` | provider API key in the env (`${VAR}`); provider/model via UI/setup |
101
+ | `local_managed` | nothing |
102
+ | `cloud_byok` | provider API key in env (or a UI credential); provider/model via UI/setup |
103
+ | `cloud_managed` | nothing |
104
+
105
+ ### Advanced knobs and precedence
106
+
107
+ Advanced settings (`sandbox`, `verification`, `intelligence`, `compliance`,
108
+ `watch`, `timeout`, `exclude`, `model_context_overrides`, …) are **not** written
109
+ by `init`; add only the ones you need anywhere **outside** the managed block.
110
+ `refactor init --full` writes the fully-annotated template with every knob if you
111
+ prefer to start from that.
112
+
113
+ Resolution precedence: CLI flags → environment (`provider_key`,
114
+ `REFACTOR_PROVIDER`) → the synced managed block → code defaults. A key you place
115
+ outside the managed block (later in the file) also takes precedence over the
116
+ block, so power users can pin values while normal syncing continues.
117
+
69
118
  ## Local development install
70
119
 
71
120
  From repository root:
@@ -54,6 +54,55 @@ the managed host toolchain when no container runtime is available. `refactor
54
54
  doctor` reports the selected environment (and `refactor doctor --sandbox` shows
55
55
  its full state), so a run and diagnostics always agree.
56
56
 
57
+ ## Customizing refactor.config
58
+
59
+ `refactor init` writes a **bare-minimum** `refactor.config`: just `version`,
60
+ `project_id`, and a CLI-managed block seeded to `local_heuristic`. A normal
61
+ developer never edits this file by hand — pick the mode (and provider keys, if
62
+ any) in the web UI or via `refactor setup --mode <variant>`.
63
+
64
+ ### The managed block (single source of truth for the mode)
65
+
66
+ ```
67
+ # >>> refactor managed (synced from server; do not edit) >>>
68
+ execution_variant: cloud_byok
69
+ provider: openai
70
+ model_id: gpt-4.1-mini
71
+ credential_ref: cred_01J...
72
+ # <<< refactor managed <<<
73
+ ```
74
+
75
+ - Owned by the CLI. `refactor setup` (terminal) writes it immediately; a change
76
+ made in the web UI is pulled into this block on the next `refactor doctor` /
77
+ `refactor watch` / `refactor setup` (**server-wins**).
78
+ - Only these keys live here: `execution_variant`, `provider`, `model_id`,
79
+ `base_url` (local_model), `credential_ref`. **Secrets are never written** —
80
+ keep provider keys in the environment and reference them as `${ENV_VAR}`.
81
+
82
+ ### What each mode needs
83
+
84
+ | variant | you provide |
85
+ | ---------------- | ------------------------------------------------------- |
86
+ | `local_heuristic`| nothing |
87
+ | `local_model` | nothing (setup writes the Ollama provider/model block) |
88
+ | `local_byok` | provider API key in the env (`${VAR}`); provider/model via UI/setup |
89
+ | `local_managed` | nothing |
90
+ | `cloud_byok` | provider API key in env (or a UI credential); provider/model via UI/setup |
91
+ | `cloud_managed` | nothing |
92
+
93
+ ### Advanced knobs and precedence
94
+
95
+ Advanced settings (`sandbox`, `verification`, `intelligence`, `compliance`,
96
+ `watch`, `timeout`, `exclude`, `model_context_overrides`, …) are **not** written
97
+ by `init`; add only the ones you need anywhere **outside** the managed block.
98
+ `refactor init --full` writes the fully-annotated template with every knob if you
99
+ prefer to start from that.
100
+
101
+ Resolution precedence: CLI flags → environment (`provider_key`,
102
+ `REFACTOR_PROVIDER`) → the synced managed block → code defaults. A key you place
103
+ outside the managed block (later in the file) also takes precedence over the
104
+ block, so power users can pin values while normal syncing continues.
105
+
57
106
  ## Local development install
58
107
 
59
108
  From repository root:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.7.11"
3
+ version = "0.7.13"
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.22",
15
+ "refactorai-core>=3.2.23",
16
16
  ]
17
17
 
18
18
  [project.scripts]
@@ -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.13"
@@ -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 {}
@@ -30,6 +30,7 @@ from refactor_core.constitution import (
30
30
  CONSTITUTION_FILENAME,
31
31
  DEFAULT_CONFIG,
32
32
  DEFAULT_CONSTITUTION,
33
+ MINIMAL_CONFIG,
33
34
  find_project_files,
34
35
  has_raw_secret_literal,
35
36
  load_config,
@@ -126,15 +127,8 @@ from refactorai_cli.auth import AuthContext, AuthError, ensure_authenticated
126
127
  from refactorai_cli.client import PlatformClient, PlatformError
127
128
  from refactorai_cli.credentials import load_credentials, save_credentials
128
129
  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
130
  from refactorai_cli.runtime_manager import runtime_status, runtime_version_dir
131
131
  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
132
 
139
133
  console = Console()
140
134
 
@@ -165,67 +159,22 @@ def _sandbox_runtime():
165
159
  return _sr
166
160
 
167
161
 
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"
162
+ def _default_config_for_init(full: bool = False) -> tuple[str, str]:
163
+ """Return the config `init` writes.
184
164
 
165
+ By default this is the bare-minimum template (docs/46): ``version`` +
166
+ the CLI-managed block seeded to ``local_heuristic``. A normal developer never
167
+ hand-edits it -- they pick a mode in the web UI or via ``refactor setup
168
+ --mode`` and the managed block is filled in by the CLI. ``--full`` writes the
169
+ fully-annotated template with every advanced knob for power users.
185
170
 
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
- 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"
228
- return DEFAULT_CONFIG, "default"
171
+ Mode selection is a *setup*-time concern: ``init`` never derives the mode
172
+ from prior setup-backend state (that legacy coupling produced configs whose
173
+ variant disagreed with the server default and is intentionally removed).
174
+ """
175
+ if full:
176
+ return DEFAULT_CONFIG, "full"
177
+ return MINIMAL_CONFIG, "default"
229
178
 
230
179
  _SEVERITY_STYLE = {
231
180
  "critical": "bold red",
@@ -1413,6 +1362,12 @@ def init(
1413
1362
  "--force",
1414
1363
  help=f"Overwrite existing {CONSTITUTION_FILENAME} and {CONFIG_FILENAME}.",
1415
1364
  ),
1365
+ full: bool = typer.Option(
1366
+ False,
1367
+ "--full",
1368
+ hidden=True,
1369
+ help="Write the fully-annotated refactor.config template (power users).",
1370
+ ),
1416
1371
  ) -> None:
1417
1372
  """Create `refactor.consti` + `refactor.config` and register the project.
1418
1373
 
@@ -1479,7 +1434,7 @@ def init(
1479
1434
  raise typer.Exit(code=1)
1480
1435
 
1481
1436
  consti_target.write_text(DEFAULT_CONSTITUTION, encoding="utf-8")
1482
- config_text, config_profile = _default_config_for_init()
1437
+ config_text, _config_profile = _default_config_for_init(full=full)
1483
1438
  config_text = _inject_project_id(config_text, project_id)
1484
1439
  config_target.write_text(config_text, encoding="utf-8")
1485
1440
  gitignore_updated = _ensure_refactor_gitignore(project_root)
@@ -1534,16 +1489,11 @@ def init(
1534
1489
  f"{account_label.title()}: [bold]{account_value}[/bold]. "
1535
1490
  f"Project id saved in {CONFIG_FILENAME}."
1536
1491
  )
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
- )
1492
+ console.print(
1493
+ "[green]Configured[/green] refactor.config with the default mode "
1494
+ "([bold]local_heuristic[/bold]). Choose your execution mode (and provider "
1495
+ "keys, if any) in the web UI or via `refactor setup --mode <variant>`."
1496
+ )
1547
1497
  console.print(
1548
1498
  f"Added {CONSTITUTION_FILENAME}. "
1549
1499
  f"{CONFIG_FILENAME} is treated as local by default and added to .gitignore "
@@ -5360,6 +5310,21 @@ def doctor(
5360
5310
  consti_path, _ = files
5361
5311
  project_root = consti_path.parent
5362
5312
 
5313
+ # Server-wins: pull the project's execution mode (as chosen in the web UI)
5314
+ # into the local refactor.config before reading it, so doctor diagnoses the
5315
+ # mode that runs will actually use. Best-effort; a no-op when offline.
5316
+ try:
5317
+ from refactorai_cli.mode_sync import sync_execution_variant_from_server
5318
+
5319
+ synced = sync_execution_variant_from_server(
5320
+ project_root, emit=lambda m: console.print(f"[dim]{m}[/dim]")
5321
+ )
5322
+ if synced:
5323
+ files = find_project_files() or files
5324
+ consti_path, _ = files
5325
+ except Exception:
5326
+ pass
5327
+
5363
5328
  # Load ``.env`` BEFORE parsing config so ``${VAR}`` interpolation (for
5364
5329
  # example ``provider_key: ${OPENAI_API_KEY}``) resolves from it. The
5365
5330
  # 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,171 @@
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 (
19
+ CONFIG_FILENAME,
20
+ MANAGED_KEYS,
21
+ find_project_files,
22
+ load_config,
23
+ upsert_managed_block,
24
+ )
25
+ from refactor_core.execution_variant import normalize_execution_variant
26
+ from refactor_core.store import resolve_project_id
27
+
28
+ from refactorai_cli.client import PlatformClient
29
+ from refactorai_cli.credentials import resolve_developer_key
30
+
31
+
32
+ def _local_config_path(project_root: Path) -> Path | None:
33
+ files = find_project_files(project_root)
34
+ if files:
35
+ return Path(files[1])
36
+ candidate = Path(project_root) / CONFIG_FILENAME
37
+ return candidate if candidate.is_file() else None
38
+
39
+
40
+ def _current_managed_values(project_root: Path) -> dict:
41
+ """Current values of the managed keys as parsed from refactor.config."""
42
+ config_path = _local_config_path(project_root)
43
+ if config_path is None:
44
+ return {}
45
+ try:
46
+ settings = getattr(load_config(config_path), "settings", {}) or {}
47
+ except Exception:
48
+ return {}
49
+ values: dict = {}
50
+ for key in MANAGED_KEYS:
51
+ raw = settings.get(key)
52
+ if raw is None:
53
+ continue
54
+ text = str(raw).strip()
55
+ if text:
56
+ values[key] = text
57
+ return values
58
+
59
+
60
+ def _desired_managed_fields(record: dict, current: dict, server_variant: str) -> dict:
61
+ """Compute the managed-block fields from the server record (server-wins).
62
+
63
+ When the server carries a remote provider (BYOK/managed set from the UI), the
64
+ provider/model/credential come from the server and any local ``base_url`` is
65
+ dropped. Otherwise (heuristic / terminal-set local_model, where the server has
66
+ no provider) the local provider/model/base_url are preserved and only the
67
+ variant is reconciled.
68
+ """
69
+ provider = str(record.get("inference_provider") or "").strip()
70
+ model_id = str(record.get("model_id") or "").strip()
71
+ credential_ref = str(record.get("provider_credential_ref") or "").strip()
72
+
73
+ fields: dict = {"execution_variant": server_variant}
74
+ if provider:
75
+ fields["provider"] = provider
76
+ if model_id:
77
+ fields["model_id"] = model_id
78
+ if credential_ref:
79
+ fields["credential_ref"] = credential_ref
80
+ else:
81
+ for key in ("provider", "model_id", "base_url", "credential_ref"):
82
+ if current.get(key):
83
+ fields[key] = current[key]
84
+ return fields
85
+
86
+
87
+ def sync_execution_variant_from_server(
88
+ project_root: Path,
89
+ *,
90
+ client: PlatformClient | None = None,
91
+ emit: Callable[[str], None] | None = None,
92
+ ) -> str | None:
93
+ """Pull the server project's execution settings into the managed config block.
94
+
95
+ Server-wins for the managed keys (execution_variant / provider / model_id /
96
+ credential_ref). Secrets (``provider_key``) are never written -- they stay in
97
+ the environment. Returns the applied variant when the local config changed,
98
+ else ``None`` (already in sync, offline, unauthenticated, or unbound project).
99
+ """
100
+ try:
101
+ project_id = resolve_project_id(project_root)
102
+ if not project_id:
103
+ return None
104
+ resolved = resolve_developer_key(project_root)
105
+ if not resolved:
106
+ return None
107
+ client = client or PlatformClient()
108
+ record = client.get_project_execution_mode(resolved.key, project_id)
109
+ except Exception:
110
+ return None
111
+
112
+ server_variant = normalize_execution_variant((record or {}).get("execution_mode"))
113
+ if not server_variant:
114
+ return None
115
+
116
+ config_path = _local_config_path(project_root)
117
+ if config_path is None:
118
+ return None
119
+
120
+ current = _current_managed_values(project_root)
121
+ desired = _desired_managed_fields(record or {}, current, server_variant)
122
+
123
+ # No-op when the managed keys already match (compare only managed keys).
124
+ current_managed = {k: current.get(k) for k in MANAGED_KEYS if current.get(k)}
125
+ if desired == current_managed:
126
+ return None
127
+
128
+ try:
129
+ text = config_path.read_text(encoding="utf-8")
130
+ new_text = upsert_managed_block(text, desired)
131
+ if not new_text.endswith("\n"):
132
+ new_text += "\n"
133
+ if new_text == text:
134
+ return None
135
+ config_path.write_text(new_text, encoding="utf-8")
136
+ except OSError:
137
+ return None
138
+
139
+ if emit:
140
+ emit(f"Synced execution_variant from server: {server_variant}")
141
+ return server_variant
142
+
143
+
144
+ def push_execution_variant_to_server(
145
+ project_root: Path,
146
+ variant: str,
147
+ *,
148
+ client: PlatformClient | None = None,
149
+ emit: Callable[[str], None] | None = None,
150
+ ) -> bool:
151
+ """Push a locally chosen execution variant to the server project.
152
+
153
+ Best-effort; returns ``True`` on a successful push, else ``False``.
154
+ """
155
+ normalized = normalize_execution_variant(variant)
156
+ if not normalized:
157
+ return False
158
+ try:
159
+ project_id = resolve_project_id(project_root)
160
+ if not project_id:
161
+ return False
162
+ resolved = resolve_developer_key(project_root)
163
+ if not resolved:
164
+ return False
165
+ client = client or PlatformClient()
166
+ client.set_project_execution_mode(resolved.key, project_id, normalized)
167
+ except Exception:
168
+ return False
169
+ if emit:
170
+ emit(f"Pushed execution_variant to server: {normalized}")
171
+ 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,17 +577,51 @@ 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 _local_model_managed_fields() -> dict:
602
+ """Managed-block fields for ``local_model`` (Ollama provider + model/base_url)."""
603
+ model_id, base_url = _local_model_provider_values()
604
+ return {
605
+ "execution_variant": LOCAL_MODEL,
606
+ "provider": "ollama",
607
+ "model_id": model_id,
608
+ "base_url": base_url,
609
+ }
610
+
611
+
565
612
  def _persist_variant_to_project_config(variant: str, emit: Callable[[str], None] | None = None) -> None:
566
- """Write the chosen ``execution_variant`` into the project's refactor.config.
613
+ """Write the chosen ``execution_variant`` into the CLI-managed config block.
567
614
 
568
615
  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.
616
+ for subsequent runs (doctor/review/code read it from config). For
617
+ ``local_model`` the block also carries the local Ollama provider/model/base_url
618
+ (docs/46: local_model provider config is a setup concern, not an init concern).
619
+ Best-effort: a missing config or write error never fails setup.
571
620
  """
572
621
  if not variant:
573
622
  return
574
623
  try:
575
- from refactor_core.constitution import find_project_files
624
+ from refactor_core.constitution import find_project_files, upsert_managed_block
576
625
 
577
626
  files = find_project_files()
578
627
  if not files:
@@ -580,19 +629,12 @@ def _persist_variant_to_project_config(variant: str, emit: Callable[[str], None]
580
629
  _consti_path, config_path = files
581
630
  config_path = Path(config_path)
582
631
  text = config_path.read_text(encoding="utf-8")
583
- lines = text.splitlines()
584
- new_line = f"execution_variant: {variant}"
585
- replaced = False
586
- for idx, line in enumerate(lines):
587
- if line.strip().startswith("execution_variant:"):
588
- if line.strip() == new_line:
589
- return
590
- lines[idx] = new_line
591
- replaced = True
592
- break
593
- if not replaced:
594
- lines.append(new_line)
595
- config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
632
+ fields = _local_model_managed_fields() if variant == LOCAL_MODEL else {"execution_variant": variant}
633
+ new_text = upsert_managed_block(text, fields)
634
+ if not new_text.endswith("\n"):
635
+ new_text += "\n"
636
+ if new_text != text:
637
+ config_path.write_text(new_text, encoding="utf-8")
596
638
  if emit:
597
639
  emit(f"Recorded execution_variant: {variant} in refactor.config.")
598
640
  except Exception:
@@ -819,6 +861,20 @@ def run_setup(
819
861
  resolved_variant = _resolve_project_variant()
820
862
  if resolved_variant:
821
863
  _persist_variant_to_project_config(resolved_variant, on_progress)
864
+ # Push the chosen variant to the server project so the web UI and the
865
+ # terminal agree (server-wins on later syncs). Best-effort; never blocks
866
+ # setup when offline or the project is not bound to a cloud record.
867
+ try:
868
+ from refactor_core.constitution import find_project_files
869
+ from refactorai_cli.mode_sync import push_execution_variant_to_server
870
+
871
+ files = find_project_files()
872
+ if files:
873
+ push_execution_variant_to_server(
874
+ Path(files[0]).parent, resolved_variant, emit=on_progress
875
+ )
876
+ except Exception:
877
+ pass
822
878
 
823
879
  state = read_setup_state()
824
880
  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.13
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.22
11
+ Requires-Dist: refactorai-core>=3.2.23
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -66,6 +66,55 @@ the managed host toolchain when no container runtime is available. `refactor
66
66
  doctor` reports the selected environment (and `refactor doctor --sandbox` shows
67
67
  its full state), so a run and diagnostics always agree.
68
68
 
69
+ ## Customizing refactor.config
70
+
71
+ `refactor init` writes a **bare-minimum** `refactor.config`: just `version`,
72
+ `project_id`, and a CLI-managed block seeded to `local_heuristic`. A normal
73
+ developer never edits this file by hand — pick the mode (and provider keys, if
74
+ any) in the web UI or via `refactor setup --mode <variant>`.
75
+
76
+ ### The managed block (single source of truth for the mode)
77
+
78
+ ```
79
+ # >>> refactor managed (synced from server; do not edit) >>>
80
+ execution_variant: cloud_byok
81
+ provider: openai
82
+ model_id: gpt-4.1-mini
83
+ credential_ref: cred_01J...
84
+ # <<< refactor managed <<<
85
+ ```
86
+
87
+ - Owned by the CLI. `refactor setup` (terminal) writes it immediately; a change
88
+ made in the web UI is pulled into this block on the next `refactor doctor` /
89
+ `refactor watch` / `refactor setup` (**server-wins**).
90
+ - Only these keys live here: `execution_variant`, `provider`, `model_id`,
91
+ `base_url` (local_model), `credential_ref`. **Secrets are never written** —
92
+ keep provider keys in the environment and reference them as `${ENV_VAR}`.
93
+
94
+ ### What each mode needs
95
+
96
+ | variant | you provide |
97
+ | ---------------- | ------------------------------------------------------- |
98
+ | `local_heuristic`| nothing |
99
+ | `local_model` | nothing (setup writes the Ollama provider/model block) |
100
+ | `local_byok` | provider API key in the env (`${VAR}`); provider/model via UI/setup |
101
+ | `local_managed` | nothing |
102
+ | `cloud_byok` | provider API key in env (or a UI credential); provider/model via UI/setup |
103
+ | `cloud_managed` | nothing |
104
+
105
+ ### Advanced knobs and precedence
106
+
107
+ Advanced settings (`sandbox`, `verification`, `intelligence`, `compliance`,
108
+ `watch`, `timeout`, `exclude`, `model_context_overrides`, …) are **not** written
109
+ by `init`; add only the ones you need anywhere **outside** the managed block.
110
+ `refactor init --full` writes the fully-annotated template with every knob if you
111
+ prefer to start from that.
112
+
113
+ Resolution precedence: CLI flags → environment (`provider_key`,
114
+ `REFACTOR_PROVIDER`) → the synced managed block → code defaults. A key you place
115
+ outside the managed block (later in the file) also takes precedence over the
116
+ block, so power users can pin values while normal syncing continues.
117
+
69
118
  ## Local development install
70
119
 
71
120
  From repository root:
@@ -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
@@ -2,4 +2,4 @@ typer>=0.12.0
2
2
  httpx>=0.27.0
3
3
  rich>=13.7.0
4
4
  PyYAML>=6.0.1
5
- refactorai-core>=3.2.22
5
+ refactorai-core>=3.2.23