refactorai-cli 0.3.3__tar.gz → 0.3.5__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.3.3 → refactorai_cli-0.3.5}/PKG-INFO +3 -3
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/README.md +1 -1
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/pyproject.toml +2 -2
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/__init__.py +1 -1
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/client.py +18 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/cloud_rr.py +201 -2
- refactorai_cli-0.3.5/refactorai_cli/commands/account_cmds.py +76 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/auth_cmds.py +3 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/run_cmds.py +257 -9
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/runtime_proxy_cmds.py +7 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/main.py +47 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/PKG-INFO +3 -3
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/SOURCES.txt +1 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/requires.txt +1 -1
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/auth.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/__init__.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/cloud_cmds.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/engine_cmds.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/model_cmds.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/rules_cmds.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/runtime_cmds.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/setup_cmds.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/control_plane.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/credentials.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/local_constitution.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/local_engine_runtime.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/local_paths.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/model_policy.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/runtime_manager.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/settings.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/setup_flow.py +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/dependency_links.txt +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/entry_points.txt +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/top_level.txt +0 -0
- {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactorai-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.0.4
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
@@ -54,7 +54,7 @@ python -m twine upload ./refactorai-cli/dist/*
|
|
|
54
54
|
## Install test (local)
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.
|
|
57
|
+
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
|
|
58
58
|
refactor --version
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -42,7 +42,7 @@ python -m twine upload ./refactorai-cli/dist/*
|
|
|
42
42
|
## Install test (local)
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.
|
|
45
|
+
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
|
|
46
46
|
refactor --version
|
|
47
47
|
```
|
|
48
48
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "refactorai-cli"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.5"
|
|
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.
|
|
15
|
+
"refactorai-core>=3.0.4",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[project.scripts]
|
|
@@ -44,3 +44,21 @@ class PlatformClient:
|
|
|
44
44
|
f"Key validation failed ({response.status_code})", status_code=response.status_code
|
|
45
45
|
)
|
|
46
46
|
return response.json()
|
|
47
|
+
|
|
48
|
+
def list_key_accounts(self, developer_key: str) -> list[dict]:
|
|
49
|
+
try:
|
|
50
|
+
response = httpx.get(
|
|
51
|
+
f"{self.base_url}/v1/keys/accounts",
|
|
52
|
+
headers={"Authorization": f"Bearer {developer_key}"},
|
|
53
|
+
timeout=self.timeout,
|
|
54
|
+
)
|
|
55
|
+
except httpx.HTTPError as exc:
|
|
56
|
+
raise PlatformError(f"Could not reach platform at {self.base_url}: {exc}") from exc
|
|
57
|
+
if response.status_code == 401:
|
|
58
|
+
raise PlatformError("Developer key is invalid or revoked", status_code=401)
|
|
59
|
+
if response.status_code >= 400:
|
|
60
|
+
raise PlatformError(
|
|
61
|
+
f"Account discovery failed ({response.status_code})", status_code=response.status_code
|
|
62
|
+
)
|
|
63
|
+
payload = response.json()
|
|
64
|
+
return payload if isinstance(payload, list) else []
|
|
@@ -23,6 +23,9 @@ this design fixes. ``tests/test_cloud_rr_client_boundary.py`` enforces this.
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import hashlib
|
|
26
|
+
import re
|
|
27
|
+
import shlex
|
|
28
|
+
import shutil
|
|
26
29
|
import subprocess
|
|
27
30
|
from pathlib import Path
|
|
28
31
|
from typing import Callable
|
|
@@ -48,11 +51,33 @@ from refactor_core.refactor_requests import (
|
|
|
48
51
|
save_request,
|
|
49
52
|
)
|
|
50
53
|
from refactor_core.store import create_run
|
|
51
|
-
from refactor_core.testcmd import resolve_test_command
|
|
54
|
+
from refactor_core.testcmd import _SKIP_DIRS, discover_test_command, resolve_test_command
|
|
52
55
|
|
|
53
56
|
from refactorai_cli.auth import ensure_authenticated
|
|
54
57
|
from refactorai_cli.settings import platform_url
|
|
55
58
|
|
|
59
|
+
_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
|
60
|
+
|
|
61
|
+
# Manifest/config files that reveal how a project runs its tests. Sent (truncated)
|
|
62
|
+
# to the server for agentic test-command discovery when deterministic detection
|
|
63
|
+
# finds nothing.
|
|
64
|
+
_MANIFEST_NAMES = (
|
|
65
|
+
"package.json",
|
|
66
|
+
"pyproject.toml",
|
|
67
|
+
"setup.cfg",
|
|
68
|
+
"setup.py",
|
|
69
|
+
"pytest.ini",
|
|
70
|
+
"tox.ini",
|
|
71
|
+
"noxfile.py",
|
|
72
|
+
"requirements.txt",
|
|
73
|
+
"Makefile",
|
|
74
|
+
"makefile",
|
|
75
|
+
"go.mod",
|
|
76
|
+
"Cargo.toml",
|
|
77
|
+
"build.gradle",
|
|
78
|
+
"pom.xml",
|
|
79
|
+
)
|
|
80
|
+
|
|
56
81
|
|
|
57
82
|
class CloudRRError(RuntimeError):
|
|
58
83
|
"""Raised when the cloud RR session cannot proceed."""
|
|
@@ -98,6 +123,170 @@ def _run_test(project_root: Path, test_command: str | None) -> tuple[bool, str]:
|
|
|
98
123
|
return False, (proc.stdout or proc.stderr or "")[-500:]
|
|
99
124
|
|
|
100
125
|
|
|
126
|
+
def _client_preflight_command(project_root: Path, command: str | None) -> bool:
|
|
127
|
+
"""Return True when ``command`` looks runnable on this host.
|
|
128
|
+
|
|
129
|
+
Composite/shell commands (``&&``, subshells, pipes) run under ``shell=True``
|
|
130
|
+
and can't be reduced to one binary, so we trust them and let the real exit
|
|
131
|
+
code gate. Simple commands are validated by checking the leading binary is on
|
|
132
|
+
PATH — this guards against running (and charging for) a server-suggested
|
|
133
|
+
command whose toolchain isn't installed.
|
|
134
|
+
"""
|
|
135
|
+
if not command or not str(command).strip():
|
|
136
|
+
return False
|
|
137
|
+
if any(op in command for op in ("&&", "||", ";", "|", "(", ")")):
|
|
138
|
+
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:
|
|
146
|
+
return False
|
|
147
|
+
binary = tokens[0]
|
|
148
|
+
if "/" in binary:
|
|
149
|
+
return Path(binary).expanduser().exists()
|
|
150
|
+
return shutil.which(binary) is not None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _collect_test_manifests(
|
|
154
|
+
project_root: Path, *, max_files: int = 24, max_bytes: int = 4000
|
|
155
|
+
) -> list[dict]:
|
|
156
|
+
"""Gather manifest snippets (repo root + immediate subdirs) for discovery."""
|
|
157
|
+
root = Path(project_root)
|
|
158
|
+
docs: list[dict] = []
|
|
159
|
+
dirs: list[Path] = [root]
|
|
160
|
+
try:
|
|
161
|
+
for child in sorted(root.iterdir(), key=lambda p: p.name):
|
|
162
|
+
if child.is_dir() and child.name not in _SKIP_DIRS and not child.name.startswith("."):
|
|
163
|
+
dirs.append(child)
|
|
164
|
+
except OSError:
|
|
165
|
+
return docs
|
|
166
|
+
|
|
167
|
+
for directory in dirs:
|
|
168
|
+
for name in _MANIFEST_NAMES:
|
|
169
|
+
path = directory / name
|
|
170
|
+
if not path.is_file():
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
content = path.read_text(encoding="utf-8", errors="replace")[:max_bytes]
|
|
174
|
+
except OSError:
|
|
175
|
+
continue
|
|
176
|
+
rel = path.relative_to(root)
|
|
177
|
+
docs.append({"path": str(rel), "content": content})
|
|
178
|
+
if len(docs) >= max_files:
|
|
179
|
+
return docs
|
|
180
|
+
tests_dir = directory / "tests"
|
|
181
|
+
if tests_dir.is_dir():
|
|
182
|
+
try:
|
|
183
|
+
names = sorted(p.name for p in tests_dir.iterdir() if p.is_file())[:40]
|
|
184
|
+
except OSError:
|
|
185
|
+
names = []
|
|
186
|
+
rel = tests_dir.relative_to(root)
|
|
187
|
+
docs.append({"path": f"{rel}/ (listing)", "content": "\n".join(names)})
|
|
188
|
+
if len(docs) >= max_files:
|
|
189
|
+
return docs
|
|
190
|
+
return docs
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _discover_test_command_via_cloud(
|
|
194
|
+
*,
|
|
195
|
+
project_root: Path,
|
|
196
|
+
constitution,
|
|
197
|
+
execution_mode: str,
|
|
198
|
+
auth_ctx,
|
|
199
|
+
model_hint: str | None,
|
|
200
|
+
apply_byok_payload: Callable[[dict, object], None] | None,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Ask the server to infer a test command from the project's manifests.
|
|
203
|
+
|
|
204
|
+
Best-effort: any transport/parse/auth issue returns ``""`` so the caller
|
|
205
|
+
falls back to the existing defer-when-unverifiable behavior.
|
|
206
|
+
"""
|
|
207
|
+
documents = _collect_test_manifests(project_root)
|
|
208
|
+
if not documents:
|
|
209
|
+
return ""
|
|
210
|
+
payload = {
|
|
211
|
+
"stage": "rr_discover_testcmd",
|
|
212
|
+
"constitution_hash": constitution.content_hash,
|
|
213
|
+
"constitution_body": getattr(constitution, "body", "") or "",
|
|
214
|
+
"model_hint": model_hint,
|
|
215
|
+
"documents": documents,
|
|
216
|
+
"execution_mode": execution_mode,
|
|
217
|
+
}
|
|
218
|
+
if execution_mode == "cloud_byok" and apply_byok_payload is not None:
|
|
219
|
+
apply_byok_payload(payload, constitution)
|
|
220
|
+
idem = hashlib.sha256(
|
|
221
|
+
"|".join(
|
|
222
|
+
[auth_ctx.account_id, execution_mode, constitution.content_hash, "rr_discover_testcmd"]
|
|
223
|
+
).encode("utf-8")
|
|
224
|
+
).hexdigest()
|
|
225
|
+
try:
|
|
226
|
+
resp = httpx.post(
|
|
227
|
+
f"{platform_url()}/v1/inference/refactor",
|
|
228
|
+
headers={
|
|
229
|
+
"Authorization": f"Bearer {auth_ctx.key.key}",
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
"Idempotency-Key": idem,
|
|
232
|
+
},
|
|
233
|
+
json=payload,
|
|
234
|
+
timeout=45.0,
|
|
235
|
+
)
|
|
236
|
+
except httpx.HTTPError:
|
|
237
|
+
return ""
|
|
238
|
+
if resp.status_code != 200:
|
|
239
|
+
return ""
|
|
240
|
+
try:
|
|
241
|
+
data = resp.json() if resp.content else {}
|
|
242
|
+
except ValueError:
|
|
243
|
+
return ""
|
|
244
|
+
return str((data or {}).get("test_command", "") or "").strip()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _resolve_cloud_test_command(
|
|
248
|
+
*,
|
|
249
|
+
project_root: Path,
|
|
250
|
+
config: dict,
|
|
251
|
+
constitution,
|
|
252
|
+
execution_mode: str,
|
|
253
|
+
auth_ctx,
|
|
254
|
+
model_hint: str | None,
|
|
255
|
+
apply_byok_payload: Callable[[dict, object], None] | None,
|
|
256
|
+
) -> tuple[str | None, str]:
|
|
257
|
+
"""Resolve a test command for a cloud run; return ``(command, note)``.
|
|
258
|
+
|
|
259
|
+
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.
|
|
263
|
+
"""
|
|
264
|
+
configured = resolve_test_command(project_root, config)
|
|
265
|
+
if configured:
|
|
266
|
+
return configured, ""
|
|
267
|
+
|
|
268
|
+
discovered = discover_test_command(project_root)
|
|
269
|
+
if discovered:
|
|
270
|
+
return discovered, f"auto-detected test command: {discovered}"
|
|
271
|
+
|
|
272
|
+
suggested = _discover_test_command_via_cloud(
|
|
273
|
+
project_root=project_root,
|
|
274
|
+
constitution=constitution,
|
|
275
|
+
execution_mode=execution_mode,
|
|
276
|
+
auth_ctx=auth_ctx,
|
|
277
|
+
model_hint=model_hint,
|
|
278
|
+
apply_byok_payload=apply_byok_payload,
|
|
279
|
+
)
|
|
280
|
+
if suggested and _client_preflight_command(project_root, suggested):
|
|
281
|
+
return suggested, f"server-suggested test command: {suggested}"
|
|
282
|
+
if suggested:
|
|
283
|
+
return None, (
|
|
284
|
+
f"server suggested test command '{suggested}' but its toolchain was not "
|
|
285
|
+
"found on PATH; skipped."
|
|
286
|
+
)
|
|
287
|
+
return None, ""
|
|
288
|
+
|
|
289
|
+
|
|
101
290
|
class _Session:
|
|
102
291
|
"""Transport for the RR session endpoints with status-code error mapping."""
|
|
103
292
|
|
|
@@ -200,7 +389,15 @@ def run_cloud_refactor_requests(
|
|
|
200
389
|
|
|
201
390
|
execution_mode = str(constitution.get_setting("mode", "cloud_managed") or "cloud_managed")
|
|
202
391
|
auth_ctx = ensure_authenticated(project_root)
|
|
203
|
-
test_command =
|
|
392
|
+
test_command, testcmd_note = _resolve_cloud_test_command(
|
|
393
|
+
project_root=project_root,
|
|
394
|
+
config=config,
|
|
395
|
+
constitution=constitution,
|
|
396
|
+
execution_mode=execution_mode,
|
|
397
|
+
auth_ctx=auth_ctx,
|
|
398
|
+
model_hint=model_hint,
|
|
399
|
+
apply_byok_payload=apply_byok_payload,
|
|
400
|
+
)
|
|
204
401
|
|
|
205
402
|
if requests is None:
|
|
206
403
|
requests = pending_requests(project_root)
|
|
@@ -217,6 +414,8 @@ def run_cloud_refactor_requests(
|
|
|
217
414
|
outcomes: list[dict] = []
|
|
218
415
|
counts = {"applied": 0, "denied": 0, "failed": 0, "deferred": 0}
|
|
219
416
|
notes: list[str] = []
|
|
417
|
+
if testcmd_note:
|
|
418
|
+
notes.append(testcmd_note)
|
|
220
419
|
stopped = False
|
|
221
420
|
stop_reason = ""
|
|
222
421
|
model_id = resolved_model
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""`refactor account` commands for account context selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from refactorai_cli.auth import AuthError, ensure_authenticated
|
|
10
|
+
from refactorai_cli.client import PlatformClient, PlatformError
|
|
11
|
+
from refactorai_cli.credentials import load_credentials, save_credentials
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="account",
|
|
16
|
+
help="List and switch account context for the current developer key.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _discover_accounts() -> tuple[list[dict], dict]:
|
|
23
|
+
try:
|
|
24
|
+
auth_ctx = ensure_authenticated(force_remote=True)
|
|
25
|
+
except AuthError as exc:
|
|
26
|
+
console.print(f"[red]Not authenticated:[/red] {exc}")
|
|
27
|
+
raise typer.Exit(code=1) from exc
|
|
28
|
+
client = PlatformClient()
|
|
29
|
+
try:
|
|
30
|
+
rows = client.list_key_accounts(auth_ctx.key.key)
|
|
31
|
+
except PlatformError as exc:
|
|
32
|
+
console.print(f"[red]Could not load accounts:[/red] {exc}")
|
|
33
|
+
raise typer.Exit(code=1) from exc
|
|
34
|
+
return rows, load_credentials()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("list")
|
|
38
|
+
def account_list() -> None:
|
|
39
|
+
rows, creds = _discover_accounts()
|
|
40
|
+
active_slug = str(creds.get("active_account_slug") or "")
|
|
41
|
+
if not rows:
|
|
42
|
+
console.print("[dim]No accounts available for this key.[/dim]")
|
|
43
|
+
return
|
|
44
|
+
table = Table(title="Accounts")
|
|
45
|
+
table.add_column("current")
|
|
46
|
+
table.add_column("slug")
|
|
47
|
+
table.add_column("name")
|
|
48
|
+
table.add_column("type")
|
|
49
|
+
table.add_column("role")
|
|
50
|
+
for row in rows:
|
|
51
|
+
slug = str(row.get("slug") or "")
|
|
52
|
+
table.add_row("*" if slug and slug == active_slug else "", slug, str(row.get("display_name") or ""), str(row.get("type") or ""), str(row.get("role") or ""))
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("use")
|
|
57
|
+
def account_use(
|
|
58
|
+
slug: str = typer.Argument(..., help="Account slug to set active."),
|
|
59
|
+
) -> None:
|
|
60
|
+
rows, creds = _discover_accounts()
|
|
61
|
+
selected = next((row for row in rows if str(row.get("slug") or "").lower() == slug.lower()), None)
|
|
62
|
+
if selected is None:
|
|
63
|
+
console.print(f"[red]Account not available for this key:[/red] {slug}")
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
creds["active_account_id"] = selected.get("id")
|
|
66
|
+
creds["active_account_slug"] = selected.get("slug")
|
|
67
|
+
save_credentials(creds)
|
|
68
|
+
console.print(f"[green]Active account set:[/green] {selected.get('slug')} ({selected.get('display_name')})")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("current")
|
|
72
|
+
def account_current() -> None:
|
|
73
|
+
creds = load_credentials()
|
|
74
|
+
slug = creds.get("active_account_slug") or "<unset>"
|
|
75
|
+
account_id = creds.get("active_account_id") or "<unset>"
|
|
76
|
+
console.print(f"[bold]active_account_slug[/bold]: {slug}\n[bold]active_account_id[/bold]: {account_id}")
|
|
@@ -43,6 +43,8 @@ def login(
|
|
|
43
43
|
{
|
|
44
44
|
"developer_key": developer_key,
|
|
45
45
|
"account_id": payload.get("account_id"),
|
|
46
|
+
"active_account_id": payload.get("account_id"),
|
|
47
|
+
"active_account_slug": payload.get("account_slug"),
|
|
46
48
|
"platform_url": platform_url(),
|
|
47
49
|
"masked_key": mask_key(developer_key),
|
|
48
50
|
}
|
|
@@ -80,6 +82,7 @@ def whoami() -> None:
|
|
|
80
82
|
suffix = " (cached)" if ctx.cached else ""
|
|
81
83
|
console.print(
|
|
82
84
|
f"[bold]account[/bold]: {ctx.account_id}\n"
|
|
85
|
+
f"[bold]active_account_slug[/bold]: {load_credentials().get('active_account_slug') or 'unknown'}\n"
|
|
83
86
|
f"[bold]key[/bold]: {mask_key(ctx.key.key)} (source: {source})\n"
|
|
84
87
|
f"[bold]quota_remaining[/bold]: {quota}{suffix}"
|
|
85
88
|
)
|
|
@@ -53,6 +53,13 @@ from refactor_core.apply import (
|
|
|
53
53
|
from refactor_core.budgeting import compute_budget
|
|
54
54
|
from refactor_core.capabilities import resolve_capabilities
|
|
55
55
|
from refactor_core.gate import evaluate_gate
|
|
56
|
+
from refactor_core.merge_gate import (
|
|
57
|
+
build_merge_contract,
|
|
58
|
+
merge_contract_result,
|
|
59
|
+
resolve_merge_gate_settings,
|
|
60
|
+
run_l4_adapter,
|
|
61
|
+
should_run_l4,
|
|
62
|
+
)
|
|
56
63
|
from refactor_core.refactor_requests import (
|
|
57
64
|
DEFERRED,
|
|
58
65
|
RequestSuppression,
|
|
@@ -68,7 +75,11 @@ from refactor_core.refactor_ops import remediation_hint
|
|
|
68
75
|
from refactor_core.indexing import index_target
|
|
69
76
|
from refactor_core.models import Finding, GeneratedTest, PatchGroup, RunArtifact
|
|
70
77
|
from refactor_core.rules import annotate_documents_with_rules, filter_documents_by_rules
|
|
71
|
-
from refactor_core.security import
|
|
78
|
+
from refactor_core.security import (
|
|
79
|
+
compute_semgrep_security_summary,
|
|
80
|
+
merge_refactor_findings,
|
|
81
|
+
suppress_findings_for_rr,
|
|
82
|
+
)
|
|
72
83
|
from refactor_core.security.semgrep_runner import EMBEDDED_SEMGREP_SENTINEL
|
|
73
84
|
from refactor_core.security.suppression import SecuritySuppression
|
|
74
85
|
from refactor_core.store import (
|
|
@@ -635,7 +646,8 @@ def _cloud_inference(
|
|
|
635
646
|
model_hint: str | None = None,
|
|
636
647
|
findings: list | None = None,
|
|
637
648
|
) -> RunArtifact:
|
|
638
|
-
|
|
649
|
+
_raw_alerts = constitution.get_setting("max_alerts_per_run", 5)
|
|
650
|
+
max_alerts = int(_raw_alerts if _raw_alerts is not None else 5)
|
|
639
651
|
gate_mode = str(constitution.get_setting("gate_mode", "strict") or "strict")
|
|
640
652
|
excludes = set(constitution.get_setting("exclude", []) or [])
|
|
641
653
|
documents = index_target(Path(project_root), target, excludes=excludes)
|
|
@@ -709,13 +721,40 @@ def _cloud_inference(
|
|
|
709
721
|
raise RuntimeError(f"Cloud inference failed ({response.status_code}): {response.text[:500]}")
|
|
710
722
|
|
|
711
723
|
data = response.json() if response.content else {}
|
|
712
|
-
|
|
713
|
-
|
|
724
|
+
_no_cap = max_alerts <= 0
|
|
725
|
+
findings = [Finding.model_validate(item) for item in (data.get("findings") or [])]
|
|
726
|
+
patches = [PatchGroup.model_validate(item) for item in (data.get("patches") or [])]
|
|
714
727
|
generated_tests = [
|
|
715
728
|
GeneratedTest.model_validate(item) for item in (data.get("generated_tests") or [])
|
|
716
|
-
]
|
|
729
|
+
]
|
|
730
|
+
if not _no_cap:
|
|
731
|
+
findings = findings[:max_alerts]
|
|
732
|
+
patches = patches[:max_alerts]
|
|
733
|
+
generated_tests = generated_tests[:max_alerts]
|
|
717
734
|
gate = evaluate_gate(findings, [], mode=gate_mode)
|
|
718
735
|
|
|
736
|
+
# Optional security-scan enrichment (R19). Semgrep is an additive signal and
|
|
737
|
+
# strictly fail-open: it never blocks the core cloud run and the gate stays
|
|
738
|
+
# driven by model findings only. Prefer the server-side baseline (Phase 2)
|
|
739
|
+
# when the platform ran it; otherwise fall back to a client-side scan against
|
|
740
|
+
# the local checkout (Phase 1) so parity holds against older backends.
|
|
741
|
+
security_summary: dict = {}
|
|
742
|
+
security_notes: list[str] = []
|
|
743
|
+
server_summary = data.get("security_summary")
|
|
744
|
+
if isinstance(server_summary, dict) and server_summary.get("enabled"):
|
|
745
|
+
security_summary = server_summary
|
|
746
|
+
else:
|
|
747
|
+
try:
|
|
748
|
+
security_summary, security_notes = compute_semgrep_security_summary(
|
|
749
|
+
project_root=Path(project_root),
|
|
750
|
+
constitution=constitution,
|
|
751
|
+
target=target,
|
|
752
|
+
base_findings=findings,
|
|
753
|
+
)
|
|
754
|
+
except Exception as exc: # optional feature must never fail the cloud run
|
|
755
|
+
security_summary = {}
|
|
756
|
+
security_notes = [f"Security baseline scan skipped (unexpected error): {exc}"]
|
|
757
|
+
|
|
719
758
|
run_id = uuid.uuid4().hex[:12]
|
|
720
759
|
if stage == "review":
|
|
721
760
|
summary = (
|
|
@@ -736,9 +775,11 @@ def _cloud_inference(
|
|
|
736
775
|
summary=summary,
|
|
737
776
|
constitution_hash=constitution.content_hash,
|
|
738
777
|
model_id=str(data.get("model_id") or payload["model_hint"]),
|
|
778
|
+
notes=security_notes,
|
|
739
779
|
rule_source=rule_meta.get("rule_source", ""),
|
|
740
780
|
rule_pattern=rule_meta.get("rule_pattern", ""),
|
|
741
781
|
rule_hash=rule_meta.get("rule_hash", ""),
|
|
782
|
+
security_summary=security_summary,
|
|
742
783
|
)
|
|
743
784
|
|
|
744
785
|
|
|
@@ -1893,6 +1934,7 @@ def review(
|
|
|
1893
1934
|
artifact=artifact,
|
|
1894
1935
|
)
|
|
1895
1936
|
_apply_compliance_gate_to_artifact(artifact)
|
|
1937
|
+
_apply_merge_contract_to_artifact(artifact, project_root, project_config)
|
|
1896
1938
|
run_store.write_artifact(artifact, target=target, status="completed")
|
|
1897
1939
|
if compliance_frameworks:
|
|
1898
1940
|
summary = dict(getattr(artifact, "compliance_summary", {}) or {})
|
|
@@ -1956,7 +1998,7 @@ def code(
|
|
|
1956
1998
|
help="Skip interactive issue approvals in agentic code mode.",
|
|
1957
1999
|
),
|
|
1958
2000
|
on_rr_failure: str = typer.Option(
|
|
1959
|
-
"
|
|
2001
|
+
"continue",
|
|
1960
2002
|
"--on-rr-failure",
|
|
1961
2003
|
help="RR failure behavior: stop | continue | defer.",
|
|
1962
2004
|
),
|
|
@@ -2109,6 +2151,7 @@ def code(
|
|
|
2109
2151
|
artifact=review_artifact,
|
|
2110
2152
|
)
|
|
2111
2153
|
_apply_compliance_gate_to_artifact(review_artifact)
|
|
2154
|
+
_apply_merge_contract_to_artifact(review_artifact, project_root, project_config)
|
|
2112
2155
|
if compliance_frameworks:
|
|
2113
2156
|
summary = dict(getattr(review_artifact, "compliance_summary", {}) or {})
|
|
2114
2157
|
console.print(
|
|
@@ -2227,7 +2270,7 @@ def requests(
|
|
|
2227
2270
|
help="Auto-approve retry-deferred proposals.",
|
|
2228
2271
|
),
|
|
2229
2272
|
on_rr_failure: str = typer.Option(
|
|
2230
|
-
"
|
|
2273
|
+
"continue",
|
|
2231
2274
|
"--on-rr-failure",
|
|
2232
2275
|
help="RR failure behavior for retry: stop | continue | defer.",
|
|
2233
2276
|
),
|
|
@@ -2685,6 +2728,94 @@ def _apply_compliance_gate_to_artifact(artifact: RunArtifact) -> None:
|
|
|
2685
2728
|
artifact.gate.blocking_reasons["reasons"] = reasons
|
|
2686
2729
|
|
|
2687
2730
|
|
|
2731
|
+
def _detect_changed_files(project_root: Path) -> list[str] | None:
|
|
2732
|
+
"""Best-effort list of changed files via git (staged + unstaged + untracked).
|
|
2733
|
+
|
|
2734
|
+
Returns ``None`` when git is unavailable or the path is not a repo, so the
|
|
2735
|
+
change-size guardrail degrades to "unknown" rather than a false value.
|
|
2736
|
+
"""
|
|
2737
|
+
try:
|
|
2738
|
+
proc = subprocess.run(
|
|
2739
|
+
["git", "status", "--porcelain"],
|
|
2740
|
+
cwd=str(project_root),
|
|
2741
|
+
capture_output=True,
|
|
2742
|
+
text=True,
|
|
2743
|
+
timeout=15,
|
|
2744
|
+
check=False,
|
|
2745
|
+
)
|
|
2746
|
+
except (OSError, subprocess.SubprocessError):
|
|
2747
|
+
return None
|
|
2748
|
+
if proc.returncode != 0:
|
|
2749
|
+
return None
|
|
2750
|
+
files: list[str] = []
|
|
2751
|
+
for line in (proc.stdout or "").splitlines():
|
|
2752
|
+
name = line[3:].strip() if len(line) > 3 else line.strip()
|
|
2753
|
+
if " -> " in name: # rename: take the destination path
|
|
2754
|
+
name = name.split(" -> ", 1)[1].strip()
|
|
2755
|
+
if name:
|
|
2756
|
+
files.append(name)
|
|
2757
|
+
return files
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def _agent_meta_from_env() -> dict:
|
|
2761
|
+
"""Resolve agent-authorship metadata from the environment (CI-friendly)."""
|
|
2762
|
+
authored = os.environ.get("REFACTOR_AGENT_AUTHORED", "").strip().lower() in {
|
|
2763
|
+
"1",
|
|
2764
|
+
"true",
|
|
2765
|
+
"yes",
|
|
2766
|
+
"on",
|
|
2767
|
+
}
|
|
2768
|
+
risk_tier = os.environ.get("REFACTOR_AGENT_RISK_TIER", "").strip().lower()
|
|
2769
|
+
return {"authored": authored, "risk_tier": risk_tier}
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
def _apply_merge_contract_to_artifact(
|
|
2773
|
+
artifact: RunArtifact, project_root: Path, project_config
|
|
2774
|
+
) -> None:
|
|
2775
|
+
"""Assemble the R24 merge contract from existing run outcomes.
|
|
2776
|
+
|
|
2777
|
+
Runs identically for every execution mode (local/cloud_managed/cloud_byok):
|
|
2778
|
+
the CLI always assembles the artifact and persists the report locally, so the
|
|
2779
|
+
contract is computed once here from the gate/compliance/security signals.
|
|
2780
|
+
|
|
2781
|
+
The expensive real-system (L4) layer runs only when it is required (strict
|
|
2782
|
+
profile, agent policy, or an explicit ``REFACTOR_MERGE_RUN_L4`` opt-in) so the
|
|
2783
|
+
fast inner loop stays cheap (R24 Phase 2).
|
|
2784
|
+
"""
|
|
2785
|
+
settings = resolve_merge_gate_settings(
|
|
2786
|
+
dict(getattr(project_config, "settings", {}) or {})
|
|
2787
|
+
)
|
|
2788
|
+
agent_meta = _agent_meta_from_env()
|
|
2789
|
+
changed_files = _detect_changed_files(project_root)
|
|
2790
|
+
changed_count = len(changed_files) if isinstance(changed_files, list) else None
|
|
2791
|
+
|
|
2792
|
+
l4_verdict = None
|
|
2793
|
+
if should_run_l4(settings, agent_authored=bool(agent_meta.get("authored"))):
|
|
2794
|
+
console.print("[dim]merge gate: running real-system (L4) validation...[/dim]")
|
|
2795
|
+
try:
|
|
2796
|
+
l4_verdict = run_l4_adapter(
|
|
2797
|
+
settings,
|
|
2798
|
+
project_root=project_root,
|
|
2799
|
+
run_id=str(getattr(artifact, "run_id", "") or ""),
|
|
2800
|
+
constitution_hash=str(getattr(artifact, "constitution_hash", "") or ""),
|
|
2801
|
+
changed_files=changed_files or [],
|
|
2802
|
+
)
|
|
2803
|
+
except Exception as exc: # adapter must never crash the run
|
|
2804
|
+
l4_verdict = {"status": "error", "reason_code": f"l4_adapter_error: {exc}"[:200]}
|
|
2805
|
+
|
|
2806
|
+
contract = build_merge_contract(
|
|
2807
|
+
gate=artifact.gate.model_dump(),
|
|
2808
|
+
compliance_summary=dict(getattr(artifact, "compliance_summary", {}) or {}),
|
|
2809
|
+
security_summary=dict(getattr(artifact, "security_summary", {}) or {}),
|
|
2810
|
+
settings=settings,
|
|
2811
|
+
constitution_hash=str(getattr(artifact, "constitution_hash", "") or ""),
|
|
2812
|
+
agent_meta=agent_meta,
|
|
2813
|
+
l4_verdict=l4_verdict,
|
|
2814
|
+
changed_files_count=changed_count,
|
|
2815
|
+
)
|
|
2816
|
+
artifact.merge_contract = contract
|
|
2817
|
+
|
|
2818
|
+
|
|
2688
2819
|
def _report_framework_suppressions(
|
|
2689
2820
|
suppressions: list[RequestSuppression], *, artifact: RunArtifact | None = None
|
|
2690
2821
|
) -> None:
|
|
@@ -3002,6 +3133,11 @@ def check(
|
|
|
3002
3133
|
"--compliance-gate",
|
|
3003
3134
|
help="Also enforce latest run compliance gate status (for CI/PR pipelines).",
|
|
3004
3135
|
),
|
|
3136
|
+
merge_gate: bool = typer.Option(
|
|
3137
|
+
False,
|
|
3138
|
+
"--merge-gate",
|
|
3139
|
+
help="Also enforce the latest run merge contract result (for CI/PR pipelines).",
|
|
3140
|
+
),
|
|
3005
3141
|
) -> None:
|
|
3006
3142
|
"""Check the constitution for committed secrets (developer/provider keys)."""
|
|
3007
3143
|
files = find_project_files()
|
|
@@ -3013,6 +3149,7 @@ def check(
|
|
|
3013
3149
|
config = load_config(config_path)
|
|
3014
3150
|
findings = raw_secret_matches(constitution) + raw_secret_matches_config(config)
|
|
3015
3151
|
compliance_failed = False
|
|
3152
|
+
merge_failed = False
|
|
3016
3153
|
if compliance_gate:
|
|
3017
3154
|
run_dir = head_run_dir(consti_path.parent)
|
|
3018
3155
|
if run_dir is None:
|
|
@@ -3031,6 +3168,29 @@ def check(
|
|
|
3031
3168
|
)
|
|
3032
3169
|
else:
|
|
3033
3170
|
console.print("[green]Compliance gate[/green]: passed")
|
|
3171
|
+
if merge_gate:
|
|
3172
|
+
run_dir = head_run_dir(consti_path.parent)
|
|
3173
|
+
if run_dir is None:
|
|
3174
|
+
console.print("[yellow]Merge gate check skipped:[/yellow] no runs yet.")
|
|
3175
|
+
else:
|
|
3176
|
+
report = read_report(run_dir) or {}
|
|
3177
|
+
contract = report.get("merge_contract", {}) if isinstance(report, dict) else {}
|
|
3178
|
+
result = merge_contract_result(contract)
|
|
3179
|
+
if result == "blocked":
|
|
3180
|
+
merge_failed = True
|
|
3181
|
+
reasons = contract.get("blocking_reasons") or ["merge_contract_blocked"]
|
|
3182
|
+
console.print(
|
|
3183
|
+
"[red]Merge gate blocked[/red] "
|
|
3184
|
+
f"(profile={contract.get('profile', 'unknown')}): "
|
|
3185
|
+
+ ", ".join(str(r) for r in reasons)
|
|
3186
|
+
)
|
|
3187
|
+
elif result == "passed":
|
|
3188
|
+
console.print(
|
|
3189
|
+
f"[green]Merge gate[/green]: passed "
|
|
3190
|
+
f"(profile={contract.get('profile', 'unknown')})"
|
|
3191
|
+
)
|
|
3192
|
+
else:
|
|
3193
|
+
console.print("[yellow]Merge gate[/yellow]: no contract in latest run.")
|
|
3034
3194
|
if findings:
|
|
3035
3195
|
kinds = ", ".join(sorted({kind for kind, _ in findings}))
|
|
3036
3196
|
console.print(
|
|
@@ -3038,11 +3198,99 @@ def check(
|
|
|
3038
3198
|
f"{consti_path} or {config_path}. Replace secrets with `${{ENV_VAR}}` references."
|
|
3039
3199
|
)
|
|
3040
3200
|
raise typer.Exit(code=1 if strict else 0)
|
|
3041
|
-
if compliance_failed:
|
|
3201
|
+
if compliance_failed or merge_failed:
|
|
3042
3202
|
raise typer.Exit(code=1 if strict else 0)
|
|
3043
3203
|
console.print("[green]OK[/green]: no committed secret-like literals detected.")
|
|
3044
3204
|
|
|
3045
3205
|
|
|
3206
|
+
def gate() -> None:
|
|
3207
|
+
"""Show the resolved merge contract for the latest run (R24, read-only)."""
|
|
3208
|
+
_require_auth()
|
|
3209
|
+
project_root, _constitution, project_config = _load_project()
|
|
3210
|
+
settings = resolve_merge_gate_settings(
|
|
3211
|
+
dict(getattr(project_config, "settings", {}) or {})
|
|
3212
|
+
)
|
|
3213
|
+
console.print("[bold]Merge gate[/bold]")
|
|
3214
|
+
console.print(
|
|
3215
|
+
f" profile: {settings.profile} "
|
|
3216
|
+
f"security_threshold={settings.security_threshold} "
|
|
3217
|
+
f"require_compliance_pass={settings.require_compliance_pass} "
|
|
3218
|
+
f"l4_adapter={settings.real_system_adapter}"
|
|
3219
|
+
)
|
|
3220
|
+
|
|
3221
|
+
run_dir = head_run_dir(project_root)
|
|
3222
|
+
report = read_report(run_dir) if run_dir else {}
|
|
3223
|
+
contract = (
|
|
3224
|
+
dict((report or {}).get("merge_contract", {}) or {})
|
|
3225
|
+
if isinstance(report, dict)
|
|
3226
|
+
else {}
|
|
3227
|
+
)
|
|
3228
|
+
if not contract:
|
|
3229
|
+
console.print(
|
|
3230
|
+
"[yellow]No merge contract in the latest run.[/yellow] "
|
|
3231
|
+
"Run `refactor review` to produce one."
|
|
3232
|
+
)
|
|
3233
|
+
return
|
|
3234
|
+
|
|
3235
|
+
layers = contract.get("layers", {}) if isinstance(contract.get("layers"), dict) else {}
|
|
3236
|
+
table = Table(title="Layers of confidence", show_lines=False)
|
|
3237
|
+
table.add_column("layer", no_wrap=True)
|
|
3238
|
+
table.add_column("enforcement", no_wrap=True)
|
|
3239
|
+
table.add_column("status", no_wrap=True)
|
|
3240
|
+
table.add_column("reason", no_wrap=True)
|
|
3241
|
+
for layer_id in ("well_formed", "internally_correct", "boundary_compatible", "system_correct"):
|
|
3242
|
+
entry = layers.get(layer_id, {}) if isinstance(layers.get(layer_id), dict) else {}
|
|
3243
|
+
code = str(entry.get("code", "")) or layer_id
|
|
3244
|
+
table.add_row(
|
|
3245
|
+
f"{code} {layer_id}",
|
|
3246
|
+
str(entry.get("class", "off")),
|
|
3247
|
+
str(entry.get("status", "unknown")),
|
|
3248
|
+
str(entry.get("reason_code", "") or "-"),
|
|
3249
|
+
)
|
|
3250
|
+
console.print(table)
|
|
3251
|
+
|
|
3252
|
+
# Real-system (L4) verdict details, when the adapter ran.
|
|
3253
|
+
l4 = layers.get("system_correct", {}) if isinstance(layers.get("system_correct"), dict) else {}
|
|
3254
|
+
nf = l4.get("non_functional", {}) if isinstance(l4.get("non_functional"), dict) else {}
|
|
3255
|
+
if l4.get("status") in {"passed", "failed", "error"} or nf.get("within_budget") is not None:
|
|
3256
|
+
parts = [f"status={l4.get('status', 'unknown')}"]
|
|
3257
|
+
if nf.get("p95_latency_ms") is not None:
|
|
3258
|
+
parts.append(f"p95={nf.get('p95_latency_ms')}ms")
|
|
3259
|
+
if nf.get("error_rate_pct") is not None:
|
|
3260
|
+
parts.append(f"error_rate={nf.get('error_rate_pct')}%")
|
|
3261
|
+
if nf.get("within_budget") is not None:
|
|
3262
|
+
parts.append(f"within_budget={nf.get('within_budget')}")
|
|
3263
|
+
console.print(" L4 real-system: " + " ".join(parts))
|
|
3264
|
+
evidence = str(l4.get("evidence_url", "") or "")
|
|
3265
|
+
if evidence:
|
|
3266
|
+
console.print(f" evidence: {evidence}")
|
|
3267
|
+
|
|
3268
|
+
agent = contract.get("agent", {}) if isinstance(contract.get("agent"), dict) else {}
|
|
3269
|
+
if agent.get("authored"):
|
|
3270
|
+
console.print(
|
|
3271
|
+
f" agent: authored=true risk_tier={agent.get('risk_tier', '') or 'unknown'} "
|
|
3272
|
+
f"require_l4={agent.get('require_l4', False)}"
|
|
3273
|
+
)
|
|
3274
|
+
changed = contract.get("changed_files_count")
|
|
3275
|
+
if isinstance(changed, int):
|
|
3276
|
+
console.print(f" changed_files={changed}")
|
|
3277
|
+
|
|
3278
|
+
result = merge_contract_result(contract)
|
|
3279
|
+
result_color = "green" if result == "passed" else ("red" if result == "blocked" else "yellow")
|
|
3280
|
+
console.print(
|
|
3281
|
+
f" quality_gate={contract.get('quality_gate', 'unknown')} "
|
|
3282
|
+
f"compliance={contract.get('compliance', 'unknown')} "
|
|
3283
|
+
f"security_findings={int((contract.get('security', {}) or {}).get('normalized_count', 0) or 0)}"
|
|
3284
|
+
)
|
|
3285
|
+
console.print(f" result: [{result_color}]{result}[/{result_color}]")
|
|
3286
|
+
reasons = contract.get("blocking_reasons") or []
|
|
3287
|
+
if reasons:
|
|
3288
|
+
console.print(" blocking_reasons: " + ", ".join(str(r) for r in reasons))
|
|
3289
|
+
consti_hash = str(contract.get("constitution_hash", "") or "")
|
|
3290
|
+
if consti_hash:
|
|
3291
|
+
console.print(f" [dim]constitution_hash={consti_hash}[/dim]")
|
|
3292
|
+
|
|
3293
|
+
|
|
3046
3294
|
def _mask_secret(value: str) -> str:
|
|
3047
3295
|
value = value.strip()
|
|
3048
3296
|
if len(value) <= 8:
|
|
@@ -3484,7 +3732,7 @@ def _report_structural_readiness(project_root: Path, config) -> None:
|
|
|
3484
3732
|
if not isinstance(structural, dict):
|
|
3485
3733
|
structural = {}
|
|
3486
3734
|
eq_mode = str(structural.get("equivalence_mode", "strict") or "strict")
|
|
3487
|
-
rr_policy = str(structural.get("on_rr_failure", "
|
|
3735
|
+
rr_policy = str(structural.get("on_rr_failure", "continue") or "continue")
|
|
3488
3736
|
rewrite_mode = str(structural.get("reference_rewrite", "auto") or "auto")
|
|
3489
3737
|
langs = structural.get("languages", [])
|
|
3490
3738
|
rollout = str(structural.get("rollout", "default") or "default")
|
|
@@ -219,3 +219,10 @@ def config(ctx: typer.Context) -> None:
|
|
|
219
219
|
def compliance(ctx: typer.Context) -> None:
|
|
220
220
|
"""Inspect compliance packs/coverage via proprietary runtime."""
|
|
221
221
|
_exec_runtime("compliance", list(ctx.args))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def gate(ctx: typer.Context) -> None:
|
|
225
|
+
"""Show the latest run's merge contract (read-only; handled locally)."""
|
|
226
|
+
# The merge contract is read from the local run report and needs no
|
|
227
|
+
# proprietary runtime, so it is handled locally for every execution mode.
|
|
228
|
+
_delegate_to_run_cmds("gate", list(ctx.args))
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
5
8
|
import typer
|
|
6
9
|
|
|
7
10
|
from refactorai_cli import __version__
|
|
8
11
|
from refactorai_cli.commands import (
|
|
12
|
+
account_cmds,
|
|
9
13
|
auth_cmds,
|
|
10
14
|
cloud_cmds,
|
|
11
15
|
engine_cmds,
|
|
@@ -23,6 +27,43 @@ app = typer.Typer(
|
|
|
23
27
|
)
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
_INTRO_COMMANDS = {"setup", "doctor", "review", "code"}
|
|
31
|
+
_TIP_BY_COMMAND = {
|
|
32
|
+
"setup": "Next: refactor doctor",
|
|
33
|
+
"doctor": "Next: refactor review .",
|
|
34
|
+
"review": "Next: refactor code . --apply",
|
|
35
|
+
"code": "Next: refactor review .",
|
|
36
|
+
}
|
|
37
|
+
_TITLE_BY_COMMAND = {
|
|
38
|
+
"setup": "Refactor Setup Agent",
|
|
39
|
+
"doctor": "Refactor Doctor Agent",
|
|
40
|
+
"review": "Refactor Review Agent",
|
|
41
|
+
"code": "Refactor Code Agent",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_agent_intro(command_name: str) -> None:
|
|
46
|
+
# Keep this lightweight and TTY-only so scripts/non-interactive usage are unchanged.
|
|
47
|
+
if not sys.stdout.isatty():
|
|
48
|
+
return
|
|
49
|
+
title = _TITLE_BY_COMMAND.get(command_name, "Refactor Agent")
|
|
50
|
+
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"
|
|
54
|
+
reset = "\033[0m"
|
|
55
|
+
typer.echo("")
|
|
56
|
+
typer.echo(f"{green_bold}{title}{reset}")
|
|
57
|
+
typer.echo(f"{green_soft}Model: Sonnet 4.5{reset}")
|
|
58
|
+
typer.echo(f"Welcome back, {os.environ.get('USER', 'developer')}")
|
|
59
|
+
typer.echo("")
|
|
60
|
+
typer.echo(f"{green_art} * [■ ■] *{reset}")
|
|
61
|
+
typer.echo(f"{green_art} * \\_/ *{reset}")
|
|
62
|
+
typer.echo("")
|
|
63
|
+
typer.echo(f"{green_bold}Tip:{reset} {tip}")
|
|
64
|
+
typer.echo("")
|
|
65
|
+
|
|
66
|
+
|
|
26
67
|
def _version_callback(value: bool) -> None:
|
|
27
68
|
if value:
|
|
28
69
|
typer.echo(f"refactor {__version__}")
|
|
@@ -31,6 +72,7 @@ def _version_callback(value: bool) -> None:
|
|
|
31
72
|
|
|
32
73
|
@app.callback()
|
|
33
74
|
def main(
|
|
75
|
+
ctx: typer.Context,
|
|
34
76
|
version: bool = typer.Option(
|
|
35
77
|
False,
|
|
36
78
|
"--version",
|
|
@@ -41,11 +83,15 @@ def main(
|
|
|
41
83
|
),
|
|
42
84
|
) -> None:
|
|
43
85
|
"""refactor CLI."""
|
|
86
|
+
command_name = (ctx.invoked_subcommand or "").strip().lower()
|
|
87
|
+
if command_name in _INTRO_COMMANDS:
|
|
88
|
+
_print_agent_intro(command_name)
|
|
44
89
|
|
|
45
90
|
|
|
46
91
|
# Auth (R7.1)
|
|
47
92
|
app.command()(auth_cmds.login)
|
|
48
93
|
app.command()(auth_cmds.whoami)
|
|
94
|
+
app.add_typer(account_cmds.app, name="account")
|
|
49
95
|
|
|
50
96
|
# Project / run surface (proxied to activated runtime artifact)
|
|
51
97
|
_RUNTIME_CTX = {"allow_extra_args": True, "ignore_unknown_options": True}
|
|
@@ -65,6 +111,7 @@ app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.check)
|
|
|
65
111
|
app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.doctor)
|
|
66
112
|
app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.config)
|
|
67
113
|
app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.compliance)
|
|
114
|
+
app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.gate)
|
|
68
115
|
|
|
69
116
|
app.command()(setup_cmds.setup)
|
|
70
117
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactorai-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.0.4
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
@@ -54,7 +54,7 @@ python -m twine upload ./refactorai-cli/dist/*
|
|
|
54
54
|
## Install test (local)
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.
|
|
57
|
+
python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
|
|
58
58
|
refactor --version
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -21,6 +21,7 @@ refactorai_cli.egg-info/entry_points.txt
|
|
|
21
21
|
refactorai_cli.egg-info/requires.txt
|
|
22
22
|
refactorai_cli.egg-info/top_level.txt
|
|
23
23
|
refactorai_cli/commands/__init__.py
|
|
24
|
+
refactorai_cli/commands/account_cmds.py
|
|
24
25
|
refactorai_cli/commands/auth_cmds.py
|
|
25
26
|
refactorai_cli/commands/cloud_cmds.py
|
|
26
27
|
refactorai_cli/commands/engine_cmds.py
|
|
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
|