refactorai-cli 0.4.0__tar.gz → 0.4.2__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 (43) hide show
  1. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/PKG-INFO +2 -2
  2. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/pyproject.toml +2 -2
  3. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/hook_cmds.py +59 -27
  5. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/run_cmds.py +128 -27
  6. refactorai_cli-0.4.2/refactorai_cli/dotenv_loader.py +112 -0
  7. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/main.py +11 -0
  8. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli.egg-info/PKG-INFO +2 -2
  9. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  10. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli.egg-info/requires.txt +1 -1
  11. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/README.md +0 -0
  12. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/auth.py +0 -0
  13. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/client.py +0 -0
  14. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/cloud_rr.py +0 -0
  15. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/__init__.py +0 -0
  16. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/account_cmds.py +0 -0
  17. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/auth_cmds.py +0 -0
  18. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/cloud_cmds.py +0 -0
  19. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/engine_cmds.py +0 -0
  20. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/model_cmds.py +0 -0
  21. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/rules_cmds.py +0 -0
  22. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/runtime_cmds.py +0 -0
  23. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  24. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/setup_cmds.py +0 -0
  25. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  26. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commands/watch_cmds.py +0 -0
  27. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/commit_queue.py +0 -0
  28. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/control_plane.py +0 -0
  29. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/credentials.py +0 -0
  30. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/git_scope.py +0 -0
  31. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/local_constitution.py +0 -0
  32. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/local_engine_runtime.py +0 -0
  33. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/local_paths.py +0 -0
  34. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/model_policy.py +0 -0
  35. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/review_runner.py +0 -0
  36. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/runtime_manager.py +0 -0
  37. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/settings.py +0 -0
  38. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/setup_flow.py +0 -0
  39. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli/watch_state.py +0 -0
  40. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  41. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli.egg-info/entry_points.txt +0 -0
  42. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/refactorai_cli.egg-info/top_level.txt +0 -0
  43. {refactorai_cli-0.4.0 → refactorai_cli-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.0
3
+ Version: 0.4.2
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.0
11
+ Requires-Dist: refactorai-core>=3.2.1
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.4.0"
3
+ version = "0.4.2"
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.0",
15
+ "refactorai-core>=3.2.1",
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.4.0"
8
+ __version__ = "0.4.2"
@@ -55,22 +55,15 @@ def _require_repo(project_root: Path) -> str:
55
55
  return root
56
56
 
57
57
 
58
- @app.command("install")
59
- def install(
60
- hooks_dir: str | None = typer.Option(
61
- None,
62
- "--hooks-dir",
63
- help=f"Directory to hold git hooks (default: existing core.hooksPath or {_DEFAULT_HOOKS_DIR}).",
64
- ),
65
- force: bool = typer.Option(
66
- False,
67
- "--force",
68
- help="Overwrite an existing non-RefactorAI post-commit hook.",
69
- ),
70
- ) -> None:
71
- """Install the non-blocking post-commit hook and wire ``core.hooksPath``."""
72
- root = Path(_require_repo(Path.cwd()))
73
-
58
+ def perform_install(
59
+ root: Path, *, hooks_dir: str | None = None, force: bool = False
60
+ ) -> dict:
61
+ """Install the post-commit hook + wire ``core.hooksPath`` (no printing/exit).
62
+
63
+ Returns a result dict with ``status`` in ``{installed, exists_foreign, error}``
64
+ plus ``rel_dir``, ``hook_file`` and a human ``message``. Shared by the
65
+ ``hook install`` command and ``refactor init`` so both stay in lockstep.
66
+ """
74
67
  existing_path = git_scope.get_config(root, "core.hooksPath")
75
68
  if hooks_dir:
76
69
  rel_dir = hooks_dir
@@ -83,29 +76,68 @@ def install(
83
76
  hook_file = hooks_path / "post-commit"
84
77
 
85
78
  if hook_file.exists() and _HOOK_MARKER not in _safe_read(hook_file) and not force:
86
- console.print(
87
- f"[yellow]hook:[/yellow] a post-commit hook already exists at {hook_file}.\n"
88
- "Re-run with [bold]--force[/bold] to overwrite, or point --hooks-dir elsewhere."
89
- )
90
- raise typer.Exit(code=1)
79
+ return {
80
+ "status": "exists_foreign",
81
+ "rel_dir": rel_dir,
82
+ "hook_file": hook_file,
83
+ "message": f"a post-commit hook already exists at {hook_file}",
84
+ }
91
85
 
92
86
  try:
93
87
  hooks_path.mkdir(parents=True, exist_ok=True)
94
88
  hook_file.write_text(_POST_COMMIT_SCRIPT, encoding="utf-8")
95
89
  hook_file.chmod(hook_file.stat().st_mode | 0o755)
96
90
  except OSError as exc:
97
- console.print(f"[red]hook: could not write hook:[/red] {exc}")
98
- raise typer.Exit(code=1) from exc
91
+ return {"status": "error", "rel_dir": rel_dir, "hook_file": None, "message": str(exc)}
99
92
 
100
93
  if not git_scope.set_config(root, "core.hooksPath", rel_dir):
101
- console.print("[red]hook: could not set core.hooksPath.[/red]")
94
+ return {
95
+ "status": "error",
96
+ "rel_dir": rel_dir,
97
+ "hook_file": hook_file,
98
+ "message": "could not set core.hooksPath",
99
+ }
100
+
101
+ return {
102
+ "status": "installed",
103
+ "rel_dir": rel_dir,
104
+ "hook_file": hook_file,
105
+ "message": f"installed post-commit hook -> {hook_file}",
106
+ }
107
+
108
+
109
+ @app.command("install")
110
+ def install(
111
+ hooks_dir: str | None = typer.Option(
112
+ None,
113
+ "--hooks-dir",
114
+ help=f"Directory to hold git hooks (default: existing core.hooksPath or {_DEFAULT_HOOKS_DIR}).",
115
+ ),
116
+ force: bool = typer.Option(
117
+ False,
118
+ "--force",
119
+ help="Overwrite an existing non-RefactorAI post-commit hook.",
120
+ ),
121
+ ) -> None:
122
+ """Install the non-blocking post-commit hook and wire ``core.hooksPath``."""
123
+ root = Path(_require_repo(Path.cwd()))
124
+ result = perform_install(root, hooks_dir=hooks_dir, force=force)
125
+
126
+ if result["status"] == "exists_foreign":
127
+ console.print(
128
+ f"[yellow]hook:[/yellow] {result['message']}.\n"
129
+ "Re-run with [bold]--force[/bold] to overwrite, or point --hooks-dir elsewhere."
130
+ )
131
+ raise typer.Exit(code=1)
132
+ if result["status"] == "error":
133
+ console.print(f"[red]hook: could not install:[/red] {result['message']}")
102
134
  raise typer.Exit(code=1)
103
135
 
104
136
  console.print(
105
- f"[green]Installed[/green] post-commit hook -> {hook_file}\n"
106
- f"[dim]core.hooksPath = {rel_dir}[/dim]\n"
137
+ f"[green]Installed[/green] post-commit hook -> {result['hook_file']}\n"
138
+ f"[dim]core.hooksPath = {result['rel_dir']}[/dim]\n"
107
139
  "Every commit now enqueues a local review event. "
108
- "Open the Refactor Terminal with `refactor watch` (coming in Phase 3)."
140
+ "Open the Refactor Terminal with `refactor watch`."
109
141
  )
110
142
 
111
143
 
@@ -72,7 +72,7 @@ from refactor_core.refactor_requests import (
72
72
  save_request,
73
73
  )
74
74
  from refactor_core.refactor_ops import remediation_hint
75
- from refactor_core.indexing import index_target
75
+ from refactor_core.indexing import index_paths, index_target
76
76
  from refactor_core.models import Finding, GeneratedTest, PatchGroup, RunArtifact
77
77
  from refactor_core.rules import annotate_documents_with_rules, filter_documents_by_rules
78
78
  from refactor_core.security import (
@@ -101,6 +101,7 @@ from refactor_core.store import (
101
101
  from refactor_core.testcmd import resolve_test_command
102
102
 
103
103
  from refactorai_cli.auth import AuthError, ensure_authenticated
104
+ from refactorai_cli.dotenv_loader import DotenvResult, load_project_dotenv
104
105
  from refactorai_cli.local_engine_runtime import DEFAULT_ENGINE_PORT, read_engine_state
105
106
  from refactorai_cli.runtime_manager import runtime_status, runtime_version_dir
106
107
  from refactorai_cli.settings import platform_url
@@ -679,12 +680,19 @@ def _cloud_inference(
679
680
  target: str,
680
681
  model_hint: str | None = None,
681
682
  findings: list | None = None,
683
+ scope_paths: list[str] | None = None,
682
684
  ) -> RunArtifact:
683
685
  _raw_alerts = constitution.get_setting("max_alerts_per_run", 5)
684
686
  max_alerts = int(_raw_alerts if _raw_alerts is not None else 5)
685
687
  gate_mode = str(constitution.get_setting("gate_mode", "strict") or "strict")
686
688
  excludes = set(constitution.get_setting("exclude", []) or [])
687
- documents = index_target(Path(project_root), target, excludes=excludes)
689
+ # Commit-scoped cloud review (docs/41 `R28`): when a scope is supplied, index
690
+ # only the changed + graph-associated files instead of the whole target, so a
691
+ # BYOK/managed commit review sends just the relevant documents to the platform.
692
+ if scope_paths is not None:
693
+ documents = index_paths(Path(project_root), scope_paths, excludes=excludes)
694
+ else:
695
+ documents = index_target(Path(project_root), target, excludes=excludes)
688
696
  documents, _scope_meta = filter_documents_by_rules(documents, project_root=Path(project_root))
689
697
  rule_meta = annotate_documents_with_rules(documents, project_root=Path(project_root))
690
698
 
@@ -1029,6 +1037,59 @@ def init(
1029
1037
  "all run state stays under ~/.refactor "
1030
1038
  f"(encoded: {record['encoded']})."
1031
1039
  )
1040
+ _offer_commit_hook(project_root)
1041
+
1042
+
1043
+ def _offer_commit_hook(project_root: Path) -> None:
1044
+ """Offer to wire the local commit-review hook during `init` (docs/41 `R28`).
1045
+
1046
+ Interactive TTYs get a yes/no prompt (default yes); non-interactive runs
1047
+ (CI/agents) print a one-line tip instead of blocking. Never fails `init`.
1048
+ """
1049
+ try:
1050
+ from refactorai_cli import git_scope
1051
+ from refactorai_cli.commands import hook_cmds
1052
+ except Exception:
1053
+ return
1054
+
1055
+ if not git_scope.is_git_repo(project_root):
1056
+ console.print(
1057
+ "[dim]Tip:[/dim] this folder is not a git repo yet. After `git init`, run "
1058
+ "[bold]refactor hook install[/bold] to review each commit before push."
1059
+ )
1060
+ return
1061
+
1062
+ if not sys.stdin.isatty():
1063
+ console.print(
1064
+ "[dim]Tip:[/dim] run [bold]refactor hook install[/bold] to enable "
1065
+ "commit-triggered local review (docs/41)."
1066
+ )
1067
+ return
1068
+
1069
+ if not typer.confirm(
1070
+ "Install the post-commit review hook for this repo now?", default=True
1071
+ ):
1072
+ console.print(
1073
+ "[dim]Skipped. Run [bold]refactor hook install[/bold] anytime to enable it.[/dim]"
1074
+ )
1075
+ return
1076
+
1077
+ result = hook_cmds.perform_install(project_root)
1078
+ status = result.get("status")
1079
+ if status == "installed":
1080
+ console.print(
1081
+ f"[green]Installed[/green] post-commit review hook "
1082
+ f"[dim](core.hooksPath = {result['rel_dir']})[/dim]. "
1083
+ "Open the Refactor Terminal with [bold]refactor watch[/bold]."
1084
+ )
1085
+ elif status == "exists_foreign":
1086
+ console.print(
1087
+ f"[yellow]A different post-commit hook already exists[/yellow] "
1088
+ f"({result['hook_file']}). Run [bold]refactor hook install --force[/bold] "
1089
+ "to replace it, or point [bold]--hooks-dir[/bold] elsewhere."
1090
+ )
1091
+ else:
1092
+ console.print(f"[yellow]Could not install hook:[/yellow] {result.get('message')}")
1032
1093
 
1033
1094
 
1034
1095
  _ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
@@ -1949,14 +2010,14 @@ def review(
1949
2010
  None,
1950
2011
  "--commit",
1951
2012
  help="Scope the review to a commit's changed files + graph-associated "
1952
- "files (local only, docs/41). Use a SHA/ref; the repo root commit falls "
1953
- "back to a full-project review.",
2013
+ "files (docs/41; works in local and cloud/BYOK modes). Use a SHA/ref; the "
2014
+ "repo root commit falls back to a full-project review.",
1954
2015
  ),
1955
2016
  changed: bool = typer.Option(
1956
2017
  False,
1957
2018
  "--changed",
1958
2019
  help="Scope the review to the working tree's changed files + "
1959
- "graph-associated files (local only).",
2020
+ "graph-associated files.",
1960
2021
  ),
1961
2022
  intent: str | None = typer.Option(
1962
2023
  None,
@@ -2026,14 +2087,10 @@ def review(
2026
2087
  register_project(project_root, constitution_hash=constitution.content_hash)
2027
2088
  _maybe_build_graph(project_root, project_config)
2028
2089
 
2029
- # Commit-scoped local review (docs/41 `R28`). Local-only: the cloud path does
2030
- # not carry an explicit file scope, so scoping + cloud mode is rejected early.
2031
- if (commit or changed) and _is_cloud_mode(constitution):
2032
- console.print(
2033
- "[red]review: --commit/--changed is local-only[/red] and is not "
2034
- f"supported in {_cloud_execution_mode(constitution)} mode."
2035
- )
2036
- raise typer.Exit(code=2)
2090
+ # Commit-scoped review (docs/41 `R28`). Works in every execution mode: local
2091
+ # runs it through the runtime, while cloud/BYOK index only the changed +
2092
+ # associated files and send just those documents to the platform (so the
2093
+ # commit loop runs without a downloaded runtime artifact).
2037
2094
  scope = _resolve_commit_scope(
2038
2095
  project_root=project_root,
2039
2096
  constitution=constitution,
@@ -2074,7 +2131,10 @@ def review(
2074
2131
  constitution=constitution,
2075
2132
  target=target,
2076
2133
  model_hint=provider,
2134
+ scope_paths=scope_paths,
2077
2135
  )
2136
+ if scope and scope.get("summary"):
2137
+ artifact.notes.append(f"Commit-scoped review intent: {scope['summary']}")
2078
2138
  else:
2079
2139
  from refactor_core.execution.orchestrator import run_with_mode
2080
2140
  from refactor_core.providers import get_provider
@@ -2204,13 +2264,13 @@ def code(
2204
2264
  None,
2205
2265
  "--commit",
2206
2266
  help="Scope to a commit's changed + graph-associated files and (re)create "
2207
- "that commit's refactor requests before resolving them (local only, docs/41). "
2208
- "Used by the Refactor Terminal's [r] action.",
2267
+ "that commit's refactor requests before resolving them (docs/41; works in "
2268
+ "local and cloud/BYOK modes). Used by the Refactor Terminal's [r] action.",
2209
2269
  ),
2210
2270
  changed: bool = typer.Option(
2211
2271
  False,
2212
2272
  "--changed",
2213
- help="Scope to the working tree's changed + graph-associated files (local only).",
2273
+ help="Scope to the working tree's changed + graph-associated files.",
2214
2274
  ),
2215
2275
  intent: str | None = typer.Option(
2216
2276
  None,
@@ -2299,16 +2359,11 @@ def code(
2299
2359
  register_project(project_root, constitution_hash=constitution.content_hash)
2300
2360
  _maybe_build_graph(project_root, project_config)
2301
2361
 
2302
- # Commit-scoped resolve (docs/41 `R28`, Phase 4). Local-only, symmetric to
2303
- # `review`. When scoped, we (re)create THIS commit's requests via a scoped
2304
- # review-first so `[r]` in the Refactor Terminal resolves the selected
2305
- # commit's requests rather than a stale pending set.
2306
- if (commit or changed) and _is_cloud_mode(constitution):
2307
- console.print(
2308
- "[red]code: --commit/--changed is local-only[/red] and is not "
2309
- f"supported in {_cloud_execution_mode(constitution)} mode."
2310
- )
2311
- raise typer.Exit(code=2)
2362
+ # Commit-scoped resolve (docs/41 `R28`, Phase 4), symmetric to `review` and
2363
+ # available in every execution mode. When scoped, we (re)create THIS commit's
2364
+ # requests via a scoped review-first so `[r]` in the Refactor Terminal resolves
2365
+ # the selected commit's requests rather than a stale pending set. In cloud/BYOK
2366
+ # the review-first sends only the scoped documents to the platform.
2312
2367
  code_scope = _resolve_commit_scope(
2313
2368
  project_root=project_root,
2314
2369
  constitution=constitution,
@@ -2372,6 +2427,7 @@ def code(
2372
2427
  constitution=constitution,
2373
2428
  target=target,
2374
2429
  model_hint=provider,
2430
+ scope_paths=code_scope_paths,
2375
2431
  )
2376
2432
  cloud_review_findings = list(review_artifact.findings or [])
2377
2433
  else:
@@ -3570,6 +3626,25 @@ def _print_detected_credentials(provider_name: str) -> None:
3570
3626
  break
3571
3627
 
3572
3628
 
3629
+ def _report_dotenv(result: DotenvResult, project_root: Path) -> None:
3630
+ """Print a concise ``.env`` status line for `doctor` diagnostics.
3631
+
3632
+ The entrypoint loads ``.env`` before any command runs, so ``result.present``
3633
+ (not ``applied``) reflects what the file contributes. Values are never shown.
3634
+ """
3635
+ if result.found:
3636
+ count = len(result.present)
3637
+ console.print(
3638
+ f"[green].env[/green]: loaded {count} var{'s' if count != 1 else ''} "
3639
+ f"from {result.path}"
3640
+ )
3641
+ else:
3642
+ console.print(
3643
+ f"[yellow].env[/yellow]: none found near {project_root} "
3644
+ "(export your provider vars, or create a .env next to refactor.config)."
3645
+ )
3646
+
3647
+
3573
3648
  def _missing_requirements(provider_name: str) -> list[str]:
3574
3649
  requirements = _REQUIRED_ENV_BY_PROVIDER.get(provider_name, [])
3575
3650
  missing: list[str] = []
@@ -3589,6 +3664,7 @@ def _cloud_doctor(
3589
3664
  constitution,
3590
3665
  execution_mode: str,
3591
3666
  skip_ping: bool,
3667
+ dotenv: DotenvResult | None = None,
3592
3668
  ) -> None:
3593
3669
  """Cloud-mode readiness checks (C7): auth, entitlement, endpoint, credential.
3594
3670
 
@@ -3637,6 +3713,11 @@ def _cloud_doctor(
3637
3713
  "[red]cloud_byok[/red]: set either `provider_key` (preferred) "
3638
3714
  "or `credential_ref` in refactor.config."
3639
3715
  )
3716
+ if dotenv is not None and not dotenv.found:
3717
+ console.print(
3718
+ "[dim] Tip: if `provider_key` uses ${ENV_VAR}, put that var in a "
3719
+ ".env next to refactor.config (refactor loads it automatically).[/dim]"
3720
+ )
3640
3721
  raise typer.Exit(code=1)
3641
3722
 
3642
3723
  if skip_ping:
@@ -3758,14 +3839,23 @@ def doctor(
3758
3839
  )
3759
3840
  raise typer.Exit(code=1)
3760
3841
  consti_path, _ = files
3842
+ project_root = consti_path.parent
3843
+
3844
+ # Load ``.env`` BEFORE parsing config so ``${VAR}`` interpolation (for
3845
+ # example ``provider_key: ${OPENAI_API_KEY}``) resolves from it. The
3846
+ # entrypoint already loads it for real runs; here we also surface the status
3847
+ # so a missing/misconfigured ``.env`` is flagged during diagnostics.
3848
+ dotenv = load_project_dotenv(project_root)
3849
+
3761
3850
  constitution = load_constitution(consti_path)
3762
3851
  project_config = load_config(consti_path.parent / CONFIG_FILENAME)
3763
- project_root = consti_path.parent
3764
3852
 
3765
3853
  if sandbox:
3766
3854
  _report_sandbox(project_root, project_config)
3767
3855
  return
3768
3856
 
3857
+ _report_dotenv(dotenv, project_root)
3858
+
3769
3859
  execution_mode = _cloud_execution_mode(constitution)
3770
3860
  if execution_mode:
3771
3861
  _cloud_doctor(
@@ -3773,6 +3863,7 @@ def doctor(
3773
3863
  constitution=constitution,
3774
3864
  execution_mode=execution_mode,
3775
3865
  skip_ping=skip_ping,
3866
+ dotenv=dotenv,
3776
3867
  )
3777
3868
  return
3778
3869
 
@@ -3807,6 +3898,16 @@ def doctor(
3807
3898
  console.print("[red]provider env[/red]: missing required values:")
3808
3899
  for item in missing:
3809
3900
  console.print(f" - {item}")
3901
+ if not dotenv.found:
3902
+ console.print(
3903
+ "[dim] Tip: create a .env next to refactor.config with these vars "
3904
+ "(refactor loads it automatically), or export them in your shell.[/dim]"
3905
+ )
3906
+ else:
3907
+ console.print(
3908
+ f"[dim] Tip: add these vars to {dotenv.path} "
3909
+ "(refactor loads it automatically).[/dim]"
3910
+ )
3810
3911
  raise typer.Exit(code=1)
3811
3912
  console.print("[green]provider env[/green]: OK")
3812
3913
  _print_detected_credentials(provider_name)
@@ -0,0 +1,112 @@
1
+ """Minimal, dependency-free ``.env`` loading for the CLI.
2
+
3
+ Developers configure BYOK/provider credentials in ``refactor.config`` using
4
+ ``${VAR}`` interpolation (for example ``provider_key: ${OPENAI_API_KEY}``).
5
+ Those tokens are resolved from ``os.environ`` at config-parse time, which means
6
+ a developer would otherwise have to ``set -a; source .env; set +a`` in every
7
+ shell (and the git hook's shell) before ``refactor`` could see the values.
8
+
9
+ This module loads a project ``.env`` into ``os.environ`` so that interpolation
10
+ "just works". It is intentionally tiny and has no third-party dependency so the
11
+ thin client stays light and deterministic.
12
+
13
+ Precedence: the real process environment always wins. Existing variables are
14
+ never overwritten unless ``override=True`` is passed explicitly.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ DOTENV_FILENAME = ".env"
25
+ DISABLE_ENV = "REFACTOR_NO_DOTENV"
26
+ _KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
27
+ _TRUTHY = {"1", "true", "yes", "on"}
28
+
29
+
30
+ @dataclass
31
+ class DotenvResult:
32
+ """Outcome of a ``.env`` load attempt (used for diagnostics/reporting)."""
33
+
34
+ path: Path | None = None
35
+ present: list[str] = field(default_factory=list)
36
+ applied: list[str] = field(default_factory=list)
37
+
38
+ @property
39
+ def found(self) -> bool:
40
+ return self.path is not None
41
+
42
+
43
+ def _disabled() -> bool:
44
+ return os.environ.get(DISABLE_ENV, "").strip().lower() in _TRUTHY
45
+
46
+
47
+ def find_dotenv(start: Path | None = None) -> Path | None:
48
+ """Return the nearest ``.env`` searching ``start`` (or CWD) and its parents."""
49
+ try:
50
+ current = Path(start).resolve() if start is not None else Path.cwd().resolve()
51
+ except OSError:
52
+ return None
53
+ for candidate in [current, *current.parents]:
54
+ env_path = candidate / DOTENV_FILENAME
55
+ if env_path.is_file():
56
+ return env_path
57
+ return None
58
+
59
+
60
+ def _strip_quotes(value: str) -> str:
61
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
62
+ return value[1:-1]
63
+ return value
64
+
65
+
66
+ def parse_dotenv(text: str) -> dict[str, str]:
67
+ """Parse ``KEY=VALUE`` lines. Supports ``export`` prefix and quoted values.
68
+
69
+ Comment lines (``#``) and blanks are ignored. Inline comments are *not*
70
+ stripped so secret values containing ``#`` are preserved verbatim.
71
+ """
72
+ parsed: dict[str, str] = {}
73
+ for raw_line in text.replace("\r\n", "\n").split("\n"):
74
+ line = raw_line.strip()
75
+ if not line or line.startswith("#"):
76
+ continue
77
+ if line.startswith("export "):
78
+ line = line[len("export "):].lstrip()
79
+ if "=" not in line:
80
+ continue
81
+ key, _, value = line.partition("=")
82
+ key = key.strip()
83
+ if not _KEY_RE.match(key):
84
+ continue
85
+ parsed[key] = _strip_quotes(value.strip())
86
+ return parsed
87
+
88
+
89
+ def load_project_dotenv(start: Path | None = None, *, override: bool = False) -> DotenvResult:
90
+ """Load the nearest ``.env`` into ``os.environ`` (real env wins by default).
91
+
92
+ Returns a :class:`DotenvResult` describing what was found and applied so
93
+ callers such as ``refactor doctor`` can report it. Never raises: any I/O or
94
+ parse issue degrades to an empty/partial result.
95
+ """
96
+ if _disabled():
97
+ return DotenvResult()
98
+ env_path = find_dotenv(start)
99
+ if env_path is None:
100
+ return DotenvResult()
101
+ try:
102
+ text = env_path.read_text(encoding="utf-8")
103
+ except OSError:
104
+ return DotenvResult(path=env_path)
105
+ parsed = parse_dotenv(text)
106
+ applied: list[str] = []
107
+ for key, value in parsed.items():
108
+ if not override and key in os.environ:
109
+ continue
110
+ os.environ[key] = value
111
+ applied.append(key)
112
+ return DotenvResult(path=env_path, present=list(parsed.keys()), applied=applied)
@@ -96,6 +96,17 @@ def main(
96
96
  ),
97
97
  ) -> None:
98
98
  """refactor CLI."""
99
+ # Load a project ``.env`` early so ``${VAR}`` interpolation in
100
+ # ``refactor.config`` (and provider SDKs) resolves for every command and for
101
+ # the git-hook-driven commit loop, without a manual ``source .env``. Real
102
+ # environment variables always win; failures never break dispatch.
103
+ try:
104
+ from refactorai_cli.dotenv_loader import load_project_dotenv
105
+
106
+ load_project_dotenv()
107
+ except Exception:
108
+ pass
109
+
99
110
  command_name = (ctx.invoked_subcommand or "").strip().lower()
100
111
  if command_name in _INTRO_COMMANDS:
101
112
  _print_agent_intro(command_name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.0
3
+ Version: 0.4.2
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.0
11
+ Requires-Dist: refactorai-core>=3.2.1
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -7,6 +7,7 @@ refactorai_cli/cloud_rr.py
7
7
  refactorai_cli/commit_queue.py
8
8
  refactorai_cli/control_plane.py
9
9
  refactorai_cli/credentials.py
10
+ refactorai_cli/dotenv_loader.py
10
11
  refactorai_cli/git_scope.py
11
12
  refactorai_cli/local_constitution.py
12
13
  refactorai_cli/local_engine_runtime.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.0
5
+ refactorai-core>=3.2.1
File without changes
File without changes