refactorai-cli 0.7.6__tar.gz → 0.7.7__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 (53) hide show
  1. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/PKG-INFO +2 -2
  2. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/pyproject.toml +2 -2
  3. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/cloud_rr.py +361 -17
  5. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/run_cmds.py +49 -2
  6. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/PKG-INFO +2 -2
  7. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/requires.txt +1 -1
  8. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/README.md +0 -0
  9. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/auth.py +0 -0
  10. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/client.py +0 -0
  11. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/__init__.py +0 -0
  12. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/account_cmds.py +0 -0
  13. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/auth_cmds.py +0 -0
  14. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/branch_cmds.py +0 -0
  15. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/cloud_cmds.py +0 -0
  16. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/engine_cmds.py +0 -0
  17. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/hook_cmds.py +0 -0
  18. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/model_cmds.py +0 -0
  19. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/policy_cmds.py +0 -0
  20. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/pre_push_cmds.py +0 -0
  21. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/request_cmds.py +0 -0
  22. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/rules_cmds.py +0 -0
  23. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/runtime_cmds.py +0 -0
  24. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  25. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/setup_cmds.py +0 -0
  26. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  27. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/watch_cmds.py +0 -0
  28. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/workspace_cmds.py +0 -0
  29. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commit_queue.py +0 -0
  30. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commit_telemetry.py +0 -0
  31. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/control_plane.py +0 -0
  32. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/credentials.py +0 -0
  33. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/dotenv_loader.py +0 -0
  34. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/git_scope.py +0 -0
  35. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/local_constitution.py +0 -0
  36. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/local_engine_runtime.py +0 -0
  37. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/local_paths.py +0 -0
  38. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/main.py +0 -0
  39. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/model_policy.py +0 -0
  40. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/pre_push_gate.py +0 -0
  41. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/refactor_branch_store.py +0 -0
  42. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/review_runner.py +0 -0
  43. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/runtime_manager.py +0 -0
  44. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/settings.py +0 -0
  45. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/setup_flow.py +0 -0
  46. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/watch_ledger.py +0 -0
  47. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/watch_state.py +0 -0
  48. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/watch_supervisor.py +0 -0
  49. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/SOURCES.txt +0 -0
  50. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  51. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/entry_points.txt +0 -0
  52. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/top_level.txt +0 -0
  53. {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.7.6
3
+ Version: 0.7.7
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.18
11
+ Requires-Dist: refactorai-core>=3.2.19
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.7.6"
3
+ version = "0.7.7"
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.18",
15
+ "refactorai-core>=3.2.19",
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.6"
8
+ __version__ = "0.7.7"
@@ -31,6 +31,7 @@ import re
31
31
  import shlex
32
32
  import shutil
33
33
  import subprocess
34
+ import tomllib
34
35
  from pathlib import Path
35
36
  from typing import Callable
36
37
 
@@ -516,6 +517,7 @@ def _provision_toolchain(
516
517
  *,
517
518
  consent_cb: Callable[[dict], bool] | None,
518
519
  progress_cb: Callable[[dict], None] | None,
520
+ dep_locator_cb: Callable[[Path], list[tuple[str, str]]] | None = None,
519
521
  ) -> tuple[dict | None, str]:
520
522
  """Provision managed runtimes for every missing binary in ``test_command``.
521
523
 
@@ -568,11 +570,323 @@ def _provision_toolchain(
568
570
  continue
569
571
  _emit(progress_cb, phase="rr", event="toolchain_ready", runtime=runtime)
570
572
  merged_env = _merge_toolchain_env(merged_env, env)
571
- _restore_dependencies(execution_root, runtime, merged_env, progress_cb, config=config)
573
+ _restore_dependencies(
574
+ execution_root, runtime, merged_env, progress_cb,
575
+ config=config, dep_locator_cb=dep_locator_cb,
576
+ )
572
577
 
573
578
  return merged_env, " ".join(n for n in notes if n).strip()
574
579
 
575
580
 
581
+ # Conventional pip requirements files, base first so dev/test variants layer on
582
+ # top. A project may ship several; all present ones are installed.
583
+ _PY_REQUIREMENTS_FILES = (
584
+ "requirements.txt",
585
+ "requirements-dev.txt",
586
+ "requirements_dev.txt",
587
+ "dev-requirements.txt",
588
+ "dev_requirements.txt",
589
+ "requirements-test.txt",
590
+ "requirements_test.txt",
591
+ "test-requirements.txt",
592
+ "test_requirements.txt",
593
+ )
594
+ # Conventional PEP 621 optional-dependency (extras) groups that hold test deps.
595
+ _PY_TEST_EXTRA_CANDIDATES = ("test", "tests", "dev", "develop", "testing", "dev-test")
596
+
597
+
598
+ def _leading_binary(command: str) -> str:
599
+ """First executable token of a command (``""`` when unparseable/empty)."""
600
+ try:
601
+ parts = shlex.split(command)
602
+ except ValueError:
603
+ return ""
604
+ return parts[0] if parts else ""
605
+
606
+
607
+ def _load_pyproject(pyproject: Path) -> dict:
608
+ """Parse ``pyproject.toml`` into a dict (``{}`` on any read/parse failure)."""
609
+ try:
610
+ with pyproject.open("rb") as fh:
611
+ data = tomllib.load(fh)
612
+ except (OSError, tomllib.TOMLDecodeError, ValueError):
613
+ return {}
614
+ return data if isinstance(data, dict) else {}
615
+
616
+
617
+ def _configured_deps_install(config: dict) -> list[str]:
618
+ """Explicit developer-specified install commands (escape hatch).
619
+
620
+ ``verification.deps_install`` may be a string or list of shell commands. When
621
+ set it is honored verbatim and short-circuits auto-detection, so nonstandard
622
+ dependency layouts always have a deterministic path.
623
+ """
624
+ verification = (config or {}).get("verification")
625
+ if not isinstance(verification, dict):
626
+ return []
627
+ raw = verification.get("deps_install")
628
+ if isinstance(raw, str):
629
+ raw = [raw]
630
+ if not isinstance(raw, list):
631
+ return []
632
+ return [str(cmd).strip() for cmd in raw if str(cmd).strip()]
633
+
634
+
635
+ def _resolve_test_extras(config: dict, pyproject_data: dict) -> list[str]:
636
+ """Resolve pip extras that carry test deps.
637
+
638
+ Explicit ``verification.extras`` wins (developer intent, possibly nonstandard
639
+ names). Otherwise auto-detect conventionally named extras that actually exist
640
+ under ``[project.optional-dependencies]`` so ``pip install -e .[test]`` pulls
641
+ the test dependencies without configuration.
642
+ """
643
+ verification = (config or {}).get("verification")
644
+ if isinstance(verification, dict):
645
+ raw = verification.get("extras")
646
+ if isinstance(raw, str):
647
+ raw = [raw]
648
+ if isinstance(raw, list):
649
+ explicit = [str(e).strip() for e in raw if str(e).strip()]
650
+ if explicit:
651
+ return explicit
652
+
653
+ project = pyproject_data.get("project") if isinstance(pyproject_data, dict) else None
654
+ optional = project.get("optional-dependencies") if isinstance(project, dict) else None
655
+ if not isinstance(optional, dict):
656
+ return []
657
+ present = {str(name).strip().lower(): str(name) for name in optional.keys()}
658
+ return [present[name] for name in _PY_TEST_EXTRA_CANDIDATES if name in present]
659
+
660
+
661
+ def _python_restore_commands(project_root: Path, config: dict) -> list[tuple[str, str]]:
662
+ """Deterministically resolve ordered python dependency-install commands.
663
+
664
+ Detection (no LLM, no fabricated packages) covers the common declaration
665
+ sites: the ``requirements*.txt`` family, a ``requirements/`` directory, tool
666
+ managed projects (Poetry/uv/PDM via lockfile or ``[tool.*]``), and PEP 621 /
667
+ setuptools projects (editable install with auto-detected test extras). An
668
+ explicit ``verification.deps_install`` overrides everything.
669
+ """
670
+ configured = _configured_deps_install(config)
671
+ if configured:
672
+ return [(f"deps_install[{i}]", cmd) for i, cmd in enumerate(configured, start=1)]
673
+
674
+ cmds: list[tuple[str, str]] = []
675
+ seen: set[str] = set()
676
+
677
+ def _add(label: str, command: str) -> None:
678
+ if command not in seen:
679
+ seen.add(command)
680
+ cmds.append((label, command))
681
+
682
+ # a) requirements* files, then a requirements/ directory layout.
683
+ for name in _PY_REQUIREMENTS_FILES:
684
+ if (project_root / name).is_file():
685
+ _add(name, f"python -m pip install -r {name}")
686
+ req_dir = project_root / "requirements"
687
+ if req_dir.is_dir():
688
+ for child in sorted(req_dir.glob("*.txt")):
689
+ rel = child.relative_to(project_root).as_posix()
690
+ _add(rel, f"python -m pip install -r {rel}")
691
+
692
+ pyproject = project_root / "pyproject.toml"
693
+ pyproject_data = _load_pyproject(pyproject) if pyproject.is_file() else {}
694
+ tool = pyproject_data.get("tool") if isinstance(pyproject_data, dict) else {}
695
+ tool = tool if isinstance(tool, dict) else {}
696
+
697
+ # b) tool-managed projects: prefer the project's own resolver for dev deps.
698
+ # These are best-effort and skipped at run time when the tool is absent.
699
+ tool_managed = False
700
+ if (project_root / "poetry.lock").is_file() or "poetry" in tool:
701
+ _add("poetry", "poetry install --with dev --no-root")
702
+ tool_managed = True
703
+ elif (project_root / "uv.lock").is_file() or "uv" in tool:
704
+ _add("uv", "uv sync --all-extras")
705
+ tool_managed = True
706
+ elif (project_root / "pdm.lock").is_file() or "pdm" in tool:
707
+ _add("pdm", "pdm install -d")
708
+ tool_managed = True
709
+
710
+ # c) PEP 621 / setuptools editable install with test extras (pip fallback that
711
+ # always works with the toolchain-provisioned python).
712
+ has_setuptools_manifest = (
713
+ pyproject.is_file()
714
+ or (project_root / "setup.py").is_file()
715
+ or (project_root / "setup.cfg").is_file()
716
+ )
717
+ if has_setuptools_manifest and not tool_managed:
718
+ extras = _resolve_test_extras(config, pyproject_data)
719
+ target = "." if not extras else f".[{','.join(extras)}]"
720
+ _add("project", f'python -m pip install -e "{target}"')
721
+
722
+ return cmds
723
+
724
+
725
+ # --- LLM locator fallback (find WHERE deps are declared; never WHAT) --------- #
726
+ # The model only points at existing files/dirs + a kind label; deterministic code
727
+ # maps kind -> install command and re-verifies the path on disk. The model can
728
+ # never fabricate a package or a shell command.
729
+ _LLM_LOCATABLE_KINDS = {"pip-requirements", "pip-editable", "poetry", "uv", "pdm"}
730
+
731
+
732
+ def _llm_dep_discovery_enabled(config: dict) -> bool:
733
+ """Whether the cloud dependency-file locator fallback is allowed (default on)."""
734
+ verification = (config or {}).get("verification")
735
+ if isinstance(verification, dict) and "llm_dep_discovery" in verification:
736
+ return bool(verification.get("llm_dep_discovery"))
737
+ return True
738
+
739
+
740
+ def _list_repo_files(project_root: Path, *, max_files: int = 400) -> list[str]:
741
+ """Bounded, skip-aware listing of repo-relative file paths (for the locator)."""
742
+ root = Path(project_root)
743
+ out: list[str] = []
744
+ for dirpath, dirnames, filenames in os.walk(root):
745
+ dirnames[:] = [
746
+ d for d in dirnames if d not in _SKIP_DIRS and d not in _WORKSPACE_EXCLUDE and not d.startswith(".")
747
+ ]
748
+ for name in sorted(filenames):
749
+ rel = (Path(dirpath) / name).relative_to(root).as_posix()
750
+ out.append(rel)
751
+ if len(out) >= max_files:
752
+ return out
753
+ return out
754
+
755
+
756
+ def _locate_dependency_files_via_cloud(
757
+ *,
758
+ project_root: Path,
759
+ constitution,
760
+ execution_mode: str,
761
+ auth_ctx,
762
+ model_hint: str | None,
763
+ apply_byok_payload: Callable[[dict, object], None] | None,
764
+ ) -> list[dict]:
765
+ """Ask the server which files declare dependencies (best-effort, structured).
766
+
767
+ Returns a list of ``{"path", "kind"}`` entries (unvalidated). Any transport/
768
+ parse/auth issue returns ``[]`` so the caller safely falls back to deferral.
769
+ The model is given only a file listing + manifest snippets and must return
770
+ *locations*, never install commands or package names.
771
+ """
772
+ manifests = _collect_test_manifests(project_root)
773
+ listing = _list_repo_files(project_root)
774
+ if not listing:
775
+ return []
776
+ documents = manifests + [{"path": "__file_listing__", "content": "\n".join(listing)}]
777
+ payload = {
778
+ "stage": "rr_locate_deps",
779
+ "constitution_hash": constitution.content_hash,
780
+ "constitution_body": getattr(constitution, "body", "") or "",
781
+ "model_hint": model_hint,
782
+ "documents": documents,
783
+ "execution_mode": execution_mode,
784
+ }
785
+ if execution_mode == "cloud_byok" and apply_byok_payload is not None:
786
+ apply_byok_payload(payload, constitution)
787
+ idem = hashlib.sha256(
788
+ "|".join(
789
+ [auth_ctx.account_id, execution_mode, constitution.content_hash, "rr_locate_deps"]
790
+ ).encode("utf-8")
791
+ ).hexdigest()
792
+ try:
793
+ resp = httpx.post(
794
+ f"{platform_url()}/v1/inference/refactor",
795
+ headers={
796
+ "Authorization": f"Bearer {auth_ctx.key.key}",
797
+ "Content-Type": "application/json",
798
+ "Idempotency-Key": idem,
799
+ },
800
+ json=payload,
801
+ timeout=45.0,
802
+ )
803
+ except httpx.HTTPError:
804
+ return []
805
+ if resp.status_code != 200:
806
+ return []
807
+ try:
808
+ data = resp.json() if resp.content else {}
809
+ except ValueError:
810
+ return []
811
+ entries = (data or {}).get("dependency_files")
812
+ if not isinstance(entries, list):
813
+ return []
814
+ out: list[dict] = []
815
+ for entry in entries:
816
+ if isinstance(entry, dict) and entry.get("path") and entry.get("kind"):
817
+ out.append({"path": str(entry["path"]).strip(), "kind": str(entry["kind"]).strip().lower()})
818
+ return out
819
+
820
+
821
+ def _validate_located_command(project_root: Path, entry: dict) -> tuple[str, str] | None:
822
+ """Map a validated ``{path, kind}`` locator entry to an install command.
823
+
824
+ Defense-in-depth: the ``kind`` must be allowlisted and the referenced path
825
+ must resolve *inside* the project root and exist on disk. Anything else is
826
+ rejected (returns ``None``) so a bad suggestion can never run.
827
+ """
828
+ kind = str(entry.get("kind", "")).strip().lower()
829
+ raw_path = str(entry.get("path", "")).strip().replace("\\", "/")
830
+ if kind not in _LLM_LOCATABLE_KINDS or not raw_path:
831
+ return None
832
+ root = Path(project_root).resolve()
833
+ candidate = (root / raw_path).resolve()
834
+ try:
835
+ candidate.relative_to(root) # reject absolute / `..` escapes
836
+ except ValueError:
837
+ return None
838
+ if not candidate.exists():
839
+ return None
840
+
841
+ rel = candidate.relative_to(root).as_posix()
842
+ # Directory context for editable/tool installs (parent dir of a manifest file).
843
+ work_dir = candidate if candidate.is_dir() else candidate.parent
844
+ rel_dir = work_dir.relative_to(root).as_posix()
845
+ cd_prefix = "" if rel_dir in ("", ".") else f'cd "{rel_dir}" && '
846
+
847
+ if kind == "pip-requirements":
848
+ if not candidate.is_file():
849
+ return None
850
+ return (f"located:{rel}", f'python -m pip install -r "{rel}"')
851
+ if kind == "pip-editable":
852
+ return (f"located:{rel_dir or '.'}", f'{cd_prefix}python -m pip install -e "."')
853
+ if kind == "poetry":
854
+ return (f"located:poetry:{rel_dir or '.'}", f"{cd_prefix}poetry install --with dev --no-root")
855
+ if kind == "uv":
856
+ return (f"located:uv:{rel_dir or '.'}", f"{cd_prefix}uv sync --all-extras")
857
+ if kind == "pdm":
858
+ return (f"located:pdm:{rel_dir or '.'}", f"{cd_prefix}pdm install -d")
859
+ return None
860
+
861
+
862
+ def _located_restore_commands(
863
+ project_root: Path,
864
+ *,
865
+ constitution,
866
+ execution_mode: str,
867
+ auth_ctx,
868
+ model_hint: str | None,
869
+ apply_byok_payload: Callable[[dict, object], None] | None,
870
+ ) -> list[tuple[str, str]]:
871
+ """Resolve LLM-located dependency install commands (validated + deduped)."""
872
+ located = _locate_dependency_files_via_cloud(
873
+ project_root=project_root,
874
+ constitution=constitution,
875
+ execution_mode=execution_mode,
876
+ auth_ctx=auth_ctx,
877
+ model_hint=model_hint,
878
+ apply_byok_payload=apply_byok_payload,
879
+ )
880
+ cmds: list[tuple[str, str]] = []
881
+ seen: set[str] = set()
882
+ for entry in located:
883
+ resolved = _validate_located_command(project_root, entry)
884
+ if resolved and resolved[1] not in seen:
885
+ seen.add(resolved[1])
886
+ cmds.append(resolved)
887
+ return cmds
888
+
889
+
576
890
  def _restore_dependencies(
577
891
  project_root: Path,
578
892
  runtime: str,
@@ -580,6 +894,7 @@ def _restore_dependencies(
580
894
  progress_cb: Callable[[dict], None] | None,
581
895
  *,
582
896
  config: dict,
897
+ dep_locator_cb: Callable[[Path], list[tuple[str, str]]] | None = None,
583
898
  ) -> None:
584
899
  """Restore project dependencies with the provisioned toolchain (best effort).
585
900
 
@@ -646,49 +961,67 @@ def _restore_dependencies(
646
961
  detail=f"{install_command} finished with errors: {detail}",
647
962
  )
648
963
  elif runtime == "python":
649
- if (project_root / "requirements.txt").is_file():
964
+ steps = _python_restore_commands(project_root, config)
965
+ if not steps and dep_locator_cb is not None and _llm_dep_discovery_enabled(config):
966
+ # Deterministic detection found nothing in the conventional locations.
967
+ # Ask the server to point at where deps are declared (nonstandard
968
+ # layouts); the returned locations are re-validated on disk before use.
969
+ located = dep_locator_cb(project_root)
970
+ if located:
971
+ _emit(
972
+ progress_cb,
973
+ phase="rr",
974
+ event="toolchain_provisioning",
975
+ detail=(
976
+ f"no conventional manifests found; using {len(located)} "
977
+ "cloud-located dependency source(s)"
978
+ ),
979
+ )
980
+ steps = located
981
+ if not steps:
650
982
  _emit(
651
983
  progress_cb,
652
984
  phase="rr",
653
985
  event="toolchain_provisioning",
654
- detail="step 1/1: pip install -r requirements.txt (streaming output below)",
655
- )
656
- ok, detail = _run_command_with_progress(
657
- project_root,
658
- "python -m pip install -r requirements.txt",
659
- env=env,
660
- progress_cb=progress_cb,
661
- label="pip",
662
- timeout=1800,
986
+ detail="no python dependency manifests detected; skipping restore",
663
987
  )
664
- if not ok:
988
+ return
989
+ total = len(steps)
990
+ for idx, (label, command) in enumerate(steps, start=1):
991
+ leading = _leading_binary(command)
992
+ if leading and not _binary_on_path(leading, env=env):
665
993
  _emit(
666
994
  progress_cb,
667
995
  phase="rr",
668
996
  event="toolchain_provisioning",
669
- detail=f"pip install finished with errors: {detail}",
997
+ detail=(
998
+ f"skipping {label} restore: '{leading}' is not available "
999
+ f"for {command}"
1000
+ ),
670
1001
  )
671
- elif (project_root / "pyproject.toml").is_file() or (project_root / "setup.py").is_file():
1002
+ continue
672
1003
  _emit(
673
1004
  progress_cb,
674
1005
  phase="rr",
675
1006
  event="toolchain_provisioning",
676
- detail="step 1/1: pip install -e . (streaming output below)",
1007
+ detail=f"step {idx}/{total}: {command} (streaming output below)",
677
1008
  )
678
1009
  ok, detail = _run_command_with_progress(
679
1010
  project_root,
680
- "python -m pip install -e .",
1011
+ command,
681
1012
  env=env,
682
1013
  progress_cb=progress_cb,
683
1014
  label="pip",
684
1015
  timeout=1800,
685
1016
  )
686
1017
  if not ok:
1018
+ # Non-fatal: later sources may still supply the missing deps, and
1019
+ # the baseline test surfaces the real error if they don't.
687
1020
  _emit(
688
1021
  progress_cb,
689
1022
  phase="rr",
690
1023
  event="toolchain_provisioning",
691
- detail=f"pip install finished with errors: {detail}",
1024
+ detail=f"{command} finished with errors: {detail}",
692
1025
  )
693
1026
 
694
1027
 
@@ -1204,12 +1537,23 @@ def run_cloud_refactor_requests(
1204
1537
  # the host PATH, download a pinned runtime into the central store and run
1205
1538
  # tests with it (no container, no system changes). ``toolchain_env`` is None
1206
1539
  # when nothing was provisioned (already runnable / no managed runtime).
1540
+ def _dep_locator(root: Path) -> list[tuple[str, str]]:
1541
+ return _located_restore_commands(
1542
+ root,
1543
+ constitution=constitution,
1544
+ execution_mode=execution_mode,
1545
+ auth_ctx=auth_ctx,
1546
+ model_hint=model_hint,
1547
+ apply_byok_payload=apply_byok_payload,
1548
+ )
1549
+
1207
1550
  toolchain_env, toolchain_note = _provision_toolchain(
1208
1551
  execution_root,
1209
1552
  test_command,
1210
1553
  config,
1211
1554
  consent_cb=toolchain_consent_cb,
1212
1555
  progress_cb=progress_cb,
1556
+ dep_locator_cb=_dep_locator,
1213
1557
  )
1214
1558
  toolchain_env = _workspace_env(project_root, toolchain_env)
1215
1559
  status_after_toolchain = _git_status_porcelain(project_root)
@@ -1427,6 +1427,7 @@ def init(
1427
1427
  config_text, config_profile = _default_config_for_init()
1428
1428
  config_text = _inject_project_id(config_text, project_id)
1429
1429
  config_target.write_text(config_text, encoding="utf-8")
1430
+ gitignore_updated = _ensure_refactor_gitignore(project_root)
1430
1431
 
1431
1432
  constitution = load_constitution(consti_target)
1432
1433
  register_project(project_root, constitution_hash=constitution.content_hash, project_id=project_id)
@@ -1489,9 +1490,12 @@ def init(
1489
1490
  "(mode=cloud_byok, env-key-first via provider_key; credential_ref is optional fallback)."
1490
1491
  )
1491
1492
  console.print(
1492
- f"Only {CONSTITUTION_FILENAME} and {CONFIG_FILENAME} are added to your repo. "
1493
- "Runtime state stays outside the repository."
1493
+ f"Added {CONSTITUTION_FILENAME}. "
1494
+ f"{CONFIG_FILENAME} is treated as local by default and added to .gitignore "
1495
+ "(remove it there if you want to track it)."
1494
1496
  )
1497
+ if gitignore_updated:
1498
+ console.print("[green]Updated[/green] .gitignore with `refactor.config`.")
1495
1499
  if org_cached_meta is not None:
1496
1500
  policy_meta = org_cached_meta.get("policy", {}) if isinstance(org_cached_meta, dict) else {}
1497
1501
  source = str(policy_meta.get("source") or "system_default")
@@ -1596,6 +1600,49 @@ def _inject_project_id(config_text: str, project_id: str) -> str:
1596
1600
  return line + config_text
1597
1601
 
1598
1602
 
1603
+ def _ensure_refactor_gitignore(project_root: Path) -> bool:
1604
+ """Ensure ``refactor.config`` is ignored in git for this repository.
1605
+
1606
+ This is idempotent: running ``refactor init`` repeatedly never duplicates
1607
+ entries. Developers can still remove/edit the entry manually if they prefer
1608
+ to track ``refactor.config`` in git.
1609
+ """
1610
+ git_dir = project_root / ".git"
1611
+ if not git_dir.exists():
1612
+ return False
1613
+ gitignore = project_root / ".gitignore"
1614
+ try:
1615
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
1616
+ except OSError:
1617
+ return False
1618
+
1619
+ def _normalize(entry: str) -> str:
1620
+ return str(entry or "").strip().replace("\\", "/").lstrip("./")
1621
+
1622
+ normalized_existing = {
1623
+ _normalize(line)
1624
+ for line in existing.splitlines()
1625
+ if str(line).strip() and not str(line).lstrip().startswith("#")
1626
+ }
1627
+ if "refactor.config" in normalized_existing:
1628
+ return False
1629
+
1630
+ newline = "\r\n" if "\r\n" in existing else "\n"
1631
+ prefix = ""
1632
+ if existing and not existing.endswith(("\n", "\r\n")):
1633
+ prefix = newline
1634
+ block = (
1635
+ f"{prefix}# Refactor local config (remove if you want to track it){newline}"
1636
+ f"refactor.config{newline}"
1637
+ )
1638
+ try:
1639
+ with gitignore.open("a", encoding="utf-8", newline="") as fh:
1640
+ fh.write(block)
1641
+ except OSError:
1642
+ return False
1643
+ return True
1644
+
1645
+
1599
1646
  def _offer_commit_hook(project_root: Path) -> None:
1600
1647
  """Offer to wire the local commit-review hook during `init` (docs/41 `R28`).
1601
1648
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.7.6
3
+ Version: 0.7.7
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.18
11
+ Requires-Dist: refactorai-core>=3.2.19
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -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.18
5
+ refactorai-core>=3.2.19
File without changes
File without changes