refactorai-cli 0.3.5__tar.gz → 0.3.9__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 (36) hide show
  1. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/PKG-INFO +2 -2
  2. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/pyproject.toml +2 -2
  3. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/cloud_rr.py +192 -30
  5. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/run_cmds.py +34 -0
  6. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/runtime_proxy_cmds.py +11 -1
  7. refactorai_cli-0.3.9/refactorai_cli/commands/toolchains_cmds.py +57 -0
  8. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/main.py +19 -8
  9. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli.egg-info/PKG-INFO +2 -2
  10. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli.egg-info/SOURCES.txt +2 -1
  11. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli.egg-info/requires.txt +1 -1
  12. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/README.md +0 -0
  13. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/auth.py +0 -0
  14. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/client.py +0 -0
  15. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/__init__.py +0 -0
  16. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/account_cmds.py +0 -0
  17. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/auth_cmds.py +0 -0
  18. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/cloud_cmds.py +0 -0
  19. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/engine_cmds.py +0 -0
  20. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/model_cmds.py +0 -0
  21. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/rules_cmds.py +0 -0
  22. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/runtime_cmds.py +0 -0
  23. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/commands/setup_cmds.py +0 -0
  24. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/control_plane.py +0 -0
  25. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/credentials.py +0 -0
  26. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/local_constitution.py +0 -0
  27. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/local_engine_runtime.py +0 -0
  28. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/local_paths.py +0 -0
  29. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/model_policy.py +0 -0
  30. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/runtime_manager.py +0 -0
  31. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/settings.py +0 -0
  32. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli/setup_flow.py +0 -0
  33. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  34. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli.egg-info/entry_points.txt +0 -0
  35. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/refactorai_cli.egg-info/top_level.txt +0 -0
  36. {refactorai_cli-0.3.5 → refactorai_cli-0.3.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.5
3
+ Version: 0.3.9
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.0.4
11
+ Requires-Dist: refactorai-core>=3.1.0
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.3.5"
3
+ version = "0.3.9"
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.0.4",
15
+ "refactorai-core>=3.1.0",
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.3.5"
8
+ __version__ = "0.3.9"
@@ -50,8 +50,14 @@ from refactor_core.refactor_requests import (
50
50
  rr_test_dir,
51
51
  save_request,
52
52
  )
53
+ from refactor_core.host_toolchain import provision_for_command
53
54
  from refactor_core.store import create_run
54
- from refactor_core.testcmd import _SKIP_DIRS, discover_test_command, resolve_test_command
55
+ from refactor_core.testcmd import (
56
+ _SKIP_DIRS,
57
+ _configured_command,
58
+ discover_test_command,
59
+ resolve_test_command,
60
+ )
55
61
 
56
62
  from refactorai_cli.auth import ensure_authenticated
57
63
  from refactorai_cli.settings import platform_url
@@ -93,7 +99,9 @@ def _read_source(project_root: Path, rel: str) -> str:
93
99
  return ""
94
100
 
95
101
 
96
- def _run_test(project_root: Path, test_command: str | None) -> tuple[bool, str]:
102
+ def _run_test(
103
+ project_root: Path, test_command: str | None, *, env: dict | None = None
104
+ ) -> tuple[bool, str]:
97
105
  """Run the configured test command on the host; (passed, detail).
98
106
 
99
107
  Cloud runs execute tests client-side because the developer's code + deps live
@@ -101,6 +109,9 @@ def _run_test(project_root: Path, test_command: str | None) -> tuple[bool, str]:
101
109
  command is configured we return ``(False, ...)`` so the server defers the RR
102
110
  rather than applying an unverifiable change. This differs from local's
103
111
  permissive skip on purpose -- cloud never applies an unverified change.
112
+
113
+ ``env`` optionally overrides the process environment (e.g. a managed host
114
+ toolchain prepended to ``PATH``); when None the inherited environment is used.
104
115
  """
105
116
  if not test_command or not str(test_command).strip():
106
117
  return False, "no test command configured; cannot verify behavior preservation"
@@ -115,6 +126,7 @@ def _run_test(project_root: Path, test_command: str | None) -> tuple[bool, str]:
115
126
  capture_output=True,
116
127
  text=True,
117
128
  timeout=600,
129
+ env=env,
118
130
  )
119
131
  except (subprocess.SubprocessError, OSError) as exc:
120
132
  return False, f"test command error: {exc}"
@@ -123,31 +135,132 @@ def _run_test(project_root: Path, test_command: str | None) -> tuple[bool, str]:
123
135
  return False, (proc.stdout or proc.stderr or "")[-500:]
124
136
 
125
137
 
126
- def _client_preflight_command(project_root: Path, command: str | None) -> bool:
138
+ def _leading_binary(command: str | None) -> str | None:
139
+ """Return the leading executable of a simple command, or None.
140
+
141
+ Returns None for composite/shell commands (``&&``, subshells, pipes) that
142
+ can't be reduced to a single binary, and for env-only prefixes.
143
+ """
144
+ if not command or not str(command).strip():
145
+ return None
146
+ if any(op in command for op in ("&&", "||", ";", "|", "(", ")")):
147
+ return None
148
+ try:
149
+ tokens = shlex.split(command, posix=True)
150
+ except ValueError:
151
+ return None
152
+ while tokens and _ENV_ASSIGN_RE.match(tokens[0]):
153
+ tokens.pop(0)
154
+ if not tokens:
155
+ return None
156
+ return tokens[0]
157
+
158
+
159
+ def _client_preflight_command(
160
+ project_root: Path, command: str | None, *, env: dict | None = None
161
+ ) -> bool:
127
162
  """Return True when ``command`` looks runnable on this host.
128
163
 
129
164
  Composite/shell commands (``&&``, subshells, pipes) run under ``shell=True``
130
165
  and can't be reduced to one binary, so we trust them and let the real exit
131
166
  code gate. Simple commands are validated by checking the leading binary is on
132
167
  PATH — this guards against running (and charging for) a server-suggested
133
- command whose toolchain isn't installed.
168
+ command whose toolchain isn't installed. ``env`` optionally supplies the
169
+ PATH to check against (e.g. after a managed toolchain is provisioned).
134
170
  """
135
171
  if not command or not str(command).strip():
136
172
  return False
137
173
  if any(op in command for op in ("&&", "||", ";", "|", "(", ")")):
138
174
  return True
139
- try:
140
- tokens = shlex.split(command, posix=True)
141
- except ValueError:
142
- return False
143
- while tokens and _ENV_ASSIGN_RE.match(tokens[0]):
144
- tokens.pop(0)
145
- if not tokens:
175
+ binary = _leading_binary(command)
176
+ if not binary:
146
177
  return False
147
- binary = tokens[0]
148
178
  if "/" in binary:
149
179
  return Path(binary).expanduser().exists()
150
- return shutil.which(binary) is not None
180
+ search_path = (env or {}).get("PATH") if env else None
181
+ return shutil.which(binary, path=search_path) is not None
182
+
183
+
184
+ def _managed_toolchain_enabled(config: dict) -> bool:
185
+ """Whether managed host-toolchain provisioning is enabled (default on).
186
+
187
+ Reads ``verification.managed_toolchain`` from the project config; absent =>
188
+ enabled, so cloud modes provision runtimes for the developer by default.
189
+ """
190
+ verification = (config or {}).get("verification")
191
+ if isinstance(verification, dict) and "managed_toolchain" in verification:
192
+ return bool(verification.get("managed_toolchain"))
193
+ return True
194
+
195
+
196
+ def _provision_toolchain(
197
+ project_root: Path,
198
+ test_command: str | None,
199
+ config: dict,
200
+ *,
201
+ consent_cb: Callable[[dict], bool] | None,
202
+ progress_cb: Callable[[dict], None] | None,
203
+ ) -> tuple[dict | None, str]:
204
+ """Provision a managed runtime for ``test_command`` when it's missing locally.
205
+
206
+ Returns ``(env, note)``. ``env`` (with the toolchain on ``PATH``) is returned
207
+ only when a runtime was actually provisioned; otherwise ``None`` (the caller
208
+ then runs with the inherited environment). No-ops when the command is already
209
+ runnable, has no managed runtime, or provisioning is disabled/declined.
210
+ """
211
+ if not test_command or not _managed_toolchain_enabled(config):
212
+ return None, ""
213
+ if _client_preflight_command(project_root, test_command):
214
+ return None, "" # already runnable on the host
215
+ binary = _leading_binary(test_command)
216
+ if not binary:
217
+ return None, ""
218
+ from refactor_core.host_toolchain import binary_to_runtime, guidance_for_binary
219
+
220
+ runtime = binary_to_runtime(binary)
221
+ if runtime is None:
222
+ # No self-provisionable runtime -> Tier 3 guided-install guidance so the
223
+ # developer gets an actionable message instead of a silent defer.
224
+ guidance = guidance_for_binary(binary)
225
+ if guidance:
226
+ return None, f"'{binary}' is not installed and cannot be auto-provisioned. {guidance}"
227
+ return None, ""
228
+
229
+ if consent_cb is not None:
230
+ approved = bool(consent_cb({"binary": binary, "runtime": runtime}))
231
+ if not approved:
232
+ return None, f"managed toolchain for '{binary}' declined; skipping provisioning."
233
+
234
+ def _on_progress(msg: str) -> None:
235
+ _emit(progress_cb, phase="rr", event="toolchain_provisioning", detail=msg)
236
+
237
+ env, note = provision_for_command(binary, on_progress=_on_progress)
238
+ if env is None:
239
+ return None, note
240
+ _emit(progress_cb, phase="rr", event="toolchain_ready", runtime=runtime)
241
+ _restore_dependencies(project_root, runtime, env, progress_cb)
242
+ return env, note
243
+
244
+
245
+ def _restore_dependencies(
246
+ project_root: Path, runtime: str, env: dict, progress_cb: Callable[[dict], None] | None
247
+ ) -> None:
248
+ """Restore project dependencies with the provisioned toolchain (best effort).
249
+
250
+ Mirrors the sandbox dependency-restore step so the test command has its deps
251
+ present. Failures are non-fatal: the test run surfaces the real error.
252
+ """
253
+ if runtime == "node" and (project_root / "package.json").is_file():
254
+ _emit(progress_cb, phase="rr", event="toolchain_provisioning", detail="npm install")
255
+ _run_test(project_root, "npm install", env=env)
256
+ elif runtime == "python":
257
+ if (project_root / "requirements.txt").is_file():
258
+ _emit(progress_cb, phase="rr", event="toolchain_provisioning",
259
+ detail="pip install -r requirements.txt")
260
+ _run_test(project_root, "python -m pip install -r requirements.txt", env=env)
261
+ elif (project_root / "pyproject.toml").is_file() or (project_root / "setup.py").is_file():
262
+ _emit(progress_cb, phase="rr", event="toolchain_provisioning", detail="pip install -e .")
263
+ _run_test(project_root, "python -m pip install -e .", env=env)
151
264
 
152
265
 
153
266
  def _collect_test_manifests(
@@ -257,18 +370,47 @@ def _resolve_cloud_test_command(
257
370
  """Resolve a test command for a cloud run; return ``(command, note)``.
258
371
 
259
372
  Layered resolution so the developer usually needs no config:
260
- 1. configured ``verification.tests.command`` or root-marker detection,
261
- 2. deterministic monorepo discovery (subproject test setups),
262
- 3. agentic discovery (server infers from manifests), preflight-validated.
373
+ 1. configured ``verification.tests.command`` (explicit developer intent),
374
+ 2. root-marker detection / deterministic monorepo discovery (subproject
375
+ test setups),
376
+ 3. agentic discovery (server infers from manifests).
377
+
378
+ Cloud runs execute tests on the HOST (no sandbox), so every candidate is
379
+ preflight-validated against the host ``PATH`` before it is committed to. An
380
+ explicitly *configured* command is honored even when preflight fails (the
381
+ developer may know something we don't, e.g. a shell-resolved binary), but the
382
+ mismatch is surfaced as a note. Marker/monorepo-*detected* commands that fail
383
+ preflight are NOT returned: resolution falls through to server discovery so a
384
+ runnable alternative can be suggested instead of locking in an un-runnable
385
+ command (e.g. ``npm test`` when ``npm`` is not installed on the host).
263
386
  """
264
- configured = resolve_test_command(project_root, config)
387
+ # 1) Explicitly configured command: honor developer intent, but warn if the
388
+ # toolchain is not visible on the host PATH.
389
+ configured = _configured_command(config)
265
390
  if configured:
266
- return configured, ""
391
+ if _client_preflight_command(project_root, configured):
392
+ return configured, ""
393
+ return configured, (
394
+ f"configured test command '{configured}' was not found on the host PATH; "
395
+ "cloud runs execute tests on the host, so it may fail. Install the "
396
+ "toolchain or update verification.tests.command."
397
+ )
267
398
 
268
- discovered = discover_test_command(project_root)
269
- if discovered:
270
- return discovered, f"auto-detected test command: {discovered}"
399
+ # 2) Root-marker / monorepo detection: only usable if runnable on the host.
400
+ detected = resolve_test_command(project_root, config) or discover_test_command(project_root)
401
+ if detected:
402
+ if _client_preflight_command(project_root, detected):
403
+ return detected, f"auto-detected test command: {detected}"
404
+ # Detected but not runnable on the host -> fall through to server
405
+ # discovery rather than committing to an un-runnable command.
406
+ detected_note = (
407
+ f"auto-detected test command '{detected}' but its toolchain was not "
408
+ "found on the host PATH; trying server discovery."
409
+ )
410
+ else:
411
+ detected_note = ""
271
412
 
413
+ # 3) Server-assisted discovery, preflight-validated.
272
414
  suggested = _discover_test_command_via_cloud(
273
415
  project_root=project_root,
274
416
  constitution=constitution,
@@ -280,11 +422,12 @@ def _resolve_cloud_test_command(
280
422
  if suggested and _client_preflight_command(project_root, suggested):
281
423
  return suggested, f"server-suggested test command: {suggested}"
282
424
  if suggested:
283
- return None, (
425
+ note = (
284
426
  f"server suggested test command '{suggested}' but its toolchain was not "
285
- "found on PATH; skipped."
427
+ "found on the host PATH; skipped."
286
428
  )
287
- return None, ""
429
+ return None, f"{detected_note} {note}".strip() if detected_note else note
430
+ return None, detected_note
288
431
 
289
432
 
290
433
  class _Session:
@@ -375,6 +518,7 @@ def run_cloud_refactor_requests(
375
518
  progress_cb: Callable[[dict], None] | None = None,
376
519
  approval_cb: Callable[[dict], bool] | None = None,
377
520
  apply_byok_payload: Callable[[dict, object], None] | None = None,
521
+ toolchain_consent_cb: Callable[[dict], bool] | None = None,
378
522
  ) -> RunArtifact:
379
523
  """Drive pending RRs through the cloud server-run loop and return a summary.
380
524
 
@@ -382,6 +526,8 @@ def run_cloud_refactor_requests(
382
526
  (``_rr_progress_logger`` / ``_rr_approval_prompt``), so the run streams the
383
527
  same events and prompts per RR mid-run (FR8). ``apply_byok_payload`` is the
384
528
  shared BYOK resolver injected by the caller to avoid a circular import.
529
+ ``toolchain_consent_cb`` gates first-run managed-toolchain provisioning; when
530
+ None, provisioning proceeds without prompting (e.g. ``--auto-approve``).
385
531
  """
386
532
  project_root = Path(project_root)
387
533
  progress_cb = progress_cb or (lambda e: None)
@@ -399,6 +545,18 @@ def run_cloud_refactor_requests(
399
545
  apply_byok_payload=apply_byok_payload,
400
546
  )
401
547
 
548
+ # Managed host toolchain: when the resolved command's runtime is missing from
549
+ # the host PATH, download a pinned runtime into the central store and run
550
+ # tests with it (no container, no system changes). ``toolchain_env`` is None
551
+ # when nothing was provisioned (already runnable / no managed runtime).
552
+ toolchain_env, toolchain_note = _provision_toolchain(
553
+ project_root,
554
+ test_command,
555
+ config,
556
+ consent_cb=toolchain_consent_cb,
557
+ progress_cb=progress_cb,
558
+ )
559
+
402
560
  if requests is None:
403
561
  requests = pending_requests(project_root)
404
562
 
@@ -416,6 +574,8 @@ def run_cloud_refactor_requests(
416
574
  notes: list[str] = []
417
575
  if testcmd_note:
418
576
  notes.append(testcmd_note)
577
+ if toolchain_note:
578
+ notes.append(toolchain_note)
419
579
  stopped = False
420
580
  stop_reason = ""
421
581
  model_id = resolved_model
@@ -499,9 +659,10 @@ def run_cloud_refactor_requests(
499
659
  current_rr_id = action.get("rr_id", "") or current_rr_id
500
660
  _emit(progress_cb, phase="rr", event="test_generating", rr_id=current_rr_id)
501
661
  _write_generated_test(project_root, current_rr_id, action, by_id)
502
- passed, detail = _run_test(project_root, test_command)
662
+ passed, detail = _run_test(project_root, test_command, env=toolchain_env)
503
663
  _emit(progress_cb, phase="rr", event="baseline_test", rr_id=current_rr_id,
504
- passed=passed, detail=detail, execution_env="host")
664
+ passed=passed, detail=detail,
665
+ execution_env="host+toolchain" if toolchain_env else "host")
505
666
  action = session.step(
506
667
  _step_payload(token, current_rr_id, "baseline_result", by_id, project_root,
507
668
  baseline_passed=passed, baseline_detail=detail)
@@ -550,7 +711,7 @@ def run_cloud_refactor_requests(
550
711
  current_rr_id = action.get("rr_id", "") or current_rr_id
551
712
  diff_text = _PENDING_DIFF.get(current_rr_id, "")
552
713
  passed, detail, files = _apply_and_verify(
553
- project_root, run_store.run_dir, diff_text, test_command
714
+ project_root, run_store.run_dir, diff_text, test_command, env=toolchain_env
554
715
  )
555
716
  if passed:
556
717
  _emit(progress_cb, phase="rr", event="rr_applied", rr_id=current_rr_id, files=files)
@@ -626,13 +787,14 @@ def _write_generated_test(project_root: Path, rr_id: str, action: dict, by_id: d
626
787
 
627
788
 
628
789
  def _apply_and_verify(
629
- project_root: Path, run_dir: Path, diff_text: str, test_command
790
+ project_root: Path, run_dir: Path, diff_text: str, test_command, *, env: dict | None = None
630
791
  ) -> tuple[bool, str, list[str]]:
631
792
  """Apply the diff (with backup), run the test; revert on red.
632
793
 
633
794
  Returns ``(passed, detail, changed_files)``. ``changed_files`` is computed
634
795
  from the diff BEFORE applying (the tree is mutated in place afterward, so it
635
- cannot be recomputed against the modified files).
796
+ cannot be recomputed against the modified files). ``env`` optionally supplies
797
+ the managed-toolchain environment used to run the test.
636
798
  """
637
799
  if not diff_text:
638
800
  return False, "empty diff", []
@@ -647,7 +809,7 @@ def _apply_and_verify(
647
809
  apply_changes(project_root, run_dir, changes)
648
810
  except Exception as exc:
649
811
  return False, f"apply failed: {exc}", changed_files
650
- passed, detail = _run_test(project_root, test_command)
812
+ passed, detail = _run_test(project_root, test_command, env=env)
651
813
  if not passed:
652
814
  try:
653
815
  revert_from_backup(project_root, run_dir)
@@ -460,6 +460,29 @@ def _rr_approval_prompt(*, auto_approve: bool) -> Callable[[dict], bool]:
460
460
  return _ask
461
461
 
462
462
 
463
+ def _toolchain_consent_prompt(*, auto_approve: bool) -> Callable[[dict], bool]:
464
+ """First-run consent for downloading a managed language runtime.
465
+
466
+ Managed toolchains are downloaded into the central store (``~/.refactor``),
467
+ never system paths, so this is low-risk; we still confirm once per run before
468
+ fetching. ``--auto-approve`` and non-interactive terminals proceed silently.
469
+ """
470
+
471
+ def _ask(info: dict) -> bool:
472
+ binary = str(info.get("binary", "?"))
473
+ runtime = str(info.get("runtime", "?"))
474
+ if auto_approve or not sys.stdin.isatty():
475
+ return True
476
+ console.print(
477
+ f"[dim]'{binary}' was not found on your PATH. refactor can download a "
478
+ f"pinned {runtime} runtime into ~/.refactor (no system changes, no container).[/dim]"
479
+ )
480
+ raw = typer.prompt("Download managed toolchain? (Y/n)", default="Y")
481
+ return raw.strip().lower() in {"", "y", "yes"}
482
+
483
+ return _ask
484
+
485
+
463
486
  def _rr_progress_logger(status=None) -> Callable[[dict], None]:
464
487
  """Render the per-RR interactive flow with clear, dynamic messages."""
465
488
 
@@ -482,6 +505,16 @@ def _rr_progress_logger(status=None) -> Callable[[dict], None]:
482
505
  f"[bold cyan]code:[/bold cyan] running in sandbox: [dim]{cmd}[/dim] "
483
506
  f"(container={container or '?'})"
484
507
  )
508
+ elif ev == "toolchain_provisioning":
509
+ detail = str(event.get("detail", "") or "")
510
+ if status:
511
+ status.update(f"[cyan]code[/cyan] provisioning toolchain: {detail}")
512
+ console.print(f"[dim] toolchain: {detail}[/dim]")
513
+ elif ev == "toolchain_ready":
514
+ console.print(
515
+ f"[green] toolchain ready:[/green] {event.get('runtime', '?')} "
516
+ "(managed; no container required)"
517
+ )
485
518
  elif ev == "rr_start":
486
519
  if status:
487
520
  status.update(f"[cyan]code[/cyan] working on {event.get('location', '?')}...")
@@ -2211,6 +2244,7 @@ def code(
2211
2244
  progress_cb=_rr_progress_logger(status=status),
2212
2245
  approval_cb=_rr_approval_prompt(auto_approve=auto_approve),
2213
2246
  apply_byok_payload=_apply_byok_payload,
2247
+ toolchain_consent_cb=_toolchain_consent_prompt(auto_approve=auto_approve),
2214
2248
  )
2215
2249
  else:
2216
2250
  # Local mode is unchanged: dispatch to the local executor with the
@@ -75,7 +75,17 @@ def _delegate_to_run_cmds(command_name: str, passthrough_args: list[str]) -> Non
75
75
  real command signature (options, arguments, help) is honored without manual
76
76
  argument parsing. Exit codes propagate to the caller.
77
77
  """
78
- from refactorai_cli.commands import run_cmds
78
+ try:
79
+ from refactorai_cli.commands import run_cmds
80
+ except ModuleNotFoundError as exc:
81
+ if exc.name == "refactor_core.merge_gate":
82
+ console.print(
83
+ "[red]Installed refactor core is too old for this CLI.[/red]\n"
84
+ "Please upgrade both packages:\n"
85
+ " pip install --upgrade refactorai-core refactorai-cli"
86
+ )
87
+ raise typer.Exit(code=1) from exc
88
+ raise
79
89
 
80
90
  func = getattr(run_cmds, command_name, None)
81
91
  if func is None: # pragma: no cover - defensive; all cloud verbs exist
@@ -0,0 +1,57 @@
1
+ """Managed host-toolchain commands: inspect and prune provisioned runtimes.
2
+
3
+ Managed toolchains are pinned language runtimes (Node/Python/Go) downloaded into
4
+ the central store (``~/.refactor/toolchains``) so cloud modes can run tests on
5
+ the host without a container. These commands let developers see and clean up
6
+ what has been provisioned.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from refactor_core import host_toolchain as ht
16
+
17
+ console = Console()
18
+ app = typer.Typer(help="Inspect and manage managed language toolchains.")
19
+
20
+
21
+ @app.command("list")
22
+ def list_toolchains() -> None:
23
+ """List runtimes provisioned into the central store."""
24
+ installed = ht.list_installed()
25
+ if not installed:
26
+ console.print("[dim]No managed toolchains provisioned.[/dim]")
27
+ return
28
+ table = Table(title="Managed toolchains")
29
+ table.add_column("runtime")
30
+ table.add_column("version")
31
+ table.add_column("platform")
32
+ table.add_column("path", overflow="fold")
33
+ for entry in installed:
34
+ table.add_row(entry["runtime"], entry["version"], entry["platform"], entry["path"])
35
+ console.print(table)
36
+
37
+
38
+ @app.command("prune")
39
+ def prune_toolchains(
40
+ runtime: str = typer.Option(
41
+ None, "--runtime", help="Only remove this runtime (e.g. node, python, go)."
42
+ ),
43
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
44
+ ) -> None:
45
+ """Remove provisioned toolchains to reclaim disk space."""
46
+ target = runtime or "all managed toolchains"
47
+ if not yes:
48
+ confirm = typer.prompt(f"Remove {target}? (y/N)", default="N")
49
+ if confirm.strip().lower() not in {"y", "yes"}:
50
+ console.print("[dim]Aborted.[/dim]")
51
+ raise typer.Exit(code=0)
52
+ removed = ht.prune(runtime=runtime)
53
+ if not removed:
54
+ console.print("[dim]Nothing to remove.[/dim]")
55
+ return
56
+ for path in removed:
57
+ console.print(f"[green]removed[/green] {path}")
@@ -17,6 +17,7 @@ from refactorai_cli.commands import (
17
17
  runtime_cmds,
18
18
  runtime_proxy_cmds,
19
19
  setup_cmds,
20
+ toolchains_cmds,
20
21
  )
21
22
 
22
23
  app = typer.Typer(
@@ -40,6 +41,12 @@ _TITLE_BY_COMMAND = {
40
41
  "review": "Refactor Review Agent",
41
42
  "code": "Refactor Code Agent",
42
43
  }
44
+ _COLOR_BY_COMMAND = {
45
+ "setup": ("245", "250", "247"), # base, soft, art
46
+ "doctor": ("39", "117", "81"),
47
+ "review": ("214", "222", "220"),
48
+ "code": ("46", "120", "82"),
49
+ }
43
50
 
44
51
 
45
52
  def _print_agent_intro(command_name: str) -> None:
@@ -48,19 +55,20 @@ def _print_agent_intro(command_name: str) -> None:
48
55
  return
49
56
  title = _TITLE_BY_COMMAND.get(command_name, "Refactor Agent")
50
57
  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"
58
+ base, soft, art = _COLOR_BY_COMMAND.get(command_name, ("46", "120", "82"))
59
+ color_bold = f"\033[1;38;5;{base}m"
60
+ color_soft = f"\033[38;5;{soft}m"
61
+ color_art = f"\033[38;5;{art}m"
54
62
  reset = "\033[0m"
55
63
  typer.echo("")
56
- typer.echo(f"{green_bold}{title}{reset}")
57
- typer.echo(f"{green_soft}Model: Sonnet 4.5{reset}")
64
+ typer.echo(f"{color_bold}{title}{reset}")
65
+ typer.echo(f"{color_soft}Model: Sonnet 4.5{reset}")
58
66
  typer.echo(f"Welcome back, {os.environ.get('USER', 'developer')}")
59
67
  typer.echo("")
60
- typer.echo(f"{green_art} * [■ ■] *{reset}")
61
- typer.echo(f"{green_art} * \\_/ *{reset}")
68
+ typer.echo(f"{color_art} * [■ ■] *{reset}")
69
+ typer.echo(f"{color_art} * \\_/ *{reset}")
62
70
  typer.echo("")
63
- typer.echo(f"{green_bold}Tip:{reset} {tip}")
71
+ typer.echo(f"{color_bold}Tip:{reset} {tip}")
64
72
  typer.echo("")
65
73
 
66
74
 
@@ -123,6 +131,9 @@ app.add_typer(model_cmds.app, name="model")
123
131
  # Cloud (BYOK) credential management (R20).
124
132
  app.add_typer(cloud_cmds.app, name="cloud")
125
133
 
134
+ # Managed host toolchains (R24 Phase 2).
135
+ app.add_typer(toolchains_cmds.app, name="toolchains")
136
+
126
137
 
127
138
  if __name__ == "__main__":
128
139
  app()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.5
3
+ Version: 0.3.9
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.0.4
11
+ Requires-Dist: refactorai-core>=3.1.0
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -30,4 +30,5 @@ refactorai_cli/commands/rules_cmds.py
30
30
  refactorai_cli/commands/run_cmds.py
31
31
  refactorai_cli/commands/runtime_cmds.py
32
32
  refactorai_cli/commands/runtime_proxy_cmds.py
33
- refactorai_cli/commands/setup_cmds.py
33
+ refactorai_cli/commands/setup_cmds.py
34
+ refactorai_cli/commands/toolchains_cmds.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.0.4
5
+ refactorai-core>=3.1.0
File without changes
File without changes