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.
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/PKG-INFO +2 -2
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/pyproject.toml +2 -2
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/__init__.py +1 -1
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/cloud_rr.py +361 -17
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/run_cmds.py +49 -2
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/PKG-INFO +2 -2
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/requires.txt +1 -1
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/README.md +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/auth.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/client.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/__init__.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/account_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/auth_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/branch_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/cloud_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/engine_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/hook_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/model_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/policy_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/pre_push_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/request_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/rules_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/runtime_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/setup_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/toolchains_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/watch_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commands/workspace_cmds.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commit_queue.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/commit_telemetry.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/control_plane.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/credentials.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/dotenv_loader.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/git_scope.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/local_constitution.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/local_engine_runtime.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/local_paths.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/main.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/model_policy.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/pre_push_gate.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/refactor_branch_store.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/review_runner.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/runtime_manager.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/settings.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/setup_flow.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/watch_ledger.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/watch_state.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli/watch_supervisor.py +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/SOURCES.txt +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/dependency_links.txt +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/entry_points.txt +0 -0
- {refactorai_cli-0.7.6 → refactorai_cli-0.7.7}/refactorai_cli.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
15
|
+
"refactorai-core>=3.2.19",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[project.scripts]
|
|
@@ -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(
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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=
|
|
997
|
+
detail=(
|
|
998
|
+
f"skipping {label} restore: '{leading}' is not available "
|
|
999
|
+
f"for {command}"
|
|
1000
|
+
),
|
|
670
1001
|
)
|
|
671
|
-
|
|
1002
|
+
continue
|
|
672
1003
|
_emit(
|
|
673
1004
|
progress_cb,
|
|
674
1005
|
phase="rr",
|
|
675
1006
|
event="toolchain_provisioning",
|
|
676
|
-
detail="step
|
|
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
|
-
|
|
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"
|
|
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"
|
|
1493
|
-
"
|
|
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.
|
|
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.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.2.19
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|