refactorai-cli 0.1.0__py3-none-any.whl
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.
- refactor_cli/__init__.py +8 -0
- refactor_cli/auth.py +120 -0
- refactor_cli/client.py +46 -0
- refactor_cli/commands/__init__.py +1 -0
- refactor_cli/commands/auth_cmds.py +85 -0
- refactor_cli/commands/engine_cmds.py +147 -0
- refactor_cli/commands/model_cmds.py +121 -0
- refactor_cli/commands/rules_cmds.py +131 -0
- refactor_cli/commands/run_cmds.py +2159 -0
- refactor_cli/commands/runtime_cmds.py +164 -0
- refactor_cli/commands/setup_cmds.py +69 -0
- refactor_cli/control_plane.py +240 -0
- refactor_cli/credentials.py +71 -0
- refactor_cli/main.py +68 -0
- refactor_cli/model_policy.py +171 -0
- refactor_cli/runtime_manager.py +241 -0
- refactor_cli/settings.py +33 -0
- refactor_cli/setup_flow.py +412 -0
- refactorai_cli-0.1.0.dist-info/METADATA +55 -0
- refactorai_cli-0.1.0.dist-info/RECORD +23 -0
- refactorai_cli-0.1.0.dist-info/WHEEL +5 -0
- refactorai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- refactorai_cli-0.1.0.dist-info/top_level.txt +1 -0
refactor_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The `refactor` local-first CLI.
|
|
2
|
+
|
|
3
|
+
Provides a git-style workflow (`refactor init`, `refactor code`, ...) that runs
|
|
4
|
+
the shared `refactor_core` pipeline from a project folder while staying
|
|
5
|
+
authenticated to the hosted platform via a developer key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
refactor_cli/auth.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Authentication guard: resolve the developer key, validate it (with a cached
|
|
2
|
+
short-lived token), and fail closed when it is missing, invalid, or expired.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from refactor_core.paths import ensure_dir, validation_cache_dir
|
|
15
|
+
|
|
16
|
+
from refactor_cli.client import PlatformClient, PlatformError
|
|
17
|
+
from refactor_cli.credentials import ResolvedKey, resolve_developer_key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthError(RuntimeError):
|
|
21
|
+
"""Raised when authentication cannot be established (fail closed)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AuthContext:
|
|
26
|
+
key: ResolvedKey
|
|
27
|
+
account_id: str
|
|
28
|
+
quota_remaining: int | None
|
|
29
|
+
cached: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fingerprint(key: str) -> str:
|
|
33
|
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cache_file(key: str) -> Path:
|
|
37
|
+
return validation_cache_dir() / f"{_fingerprint(key)}.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_cache(key: str) -> dict | None:
|
|
41
|
+
path = _cache_file(key)
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except (json.JSONDecodeError, OSError):
|
|
47
|
+
return None
|
|
48
|
+
expires_at = data.get("expires_at_epoch", 0)
|
|
49
|
+
if not isinstance(expires_at, (int, float)) or expires_at <= time.time():
|
|
50
|
+
return None
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_cache(key: str, payload: dict) -> None:
|
|
55
|
+
ensure_dir(validation_cache_dir())
|
|
56
|
+
expires_epoch = _parse_expiry(payload.get("expires_at"))
|
|
57
|
+
record = {
|
|
58
|
+
"account_id": payload.get("account_id"),
|
|
59
|
+
"quota_remaining": payload.get("quota_remaining"),
|
|
60
|
+
"validation_token": payload.get("validation_token"),
|
|
61
|
+
"expires_at": payload.get("expires_at"),
|
|
62
|
+
"expires_at_epoch": expires_epoch,
|
|
63
|
+
}
|
|
64
|
+
_cache_file(key).write_text(json.dumps(record, indent=2), encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_expiry(value) -> float:
|
|
68
|
+
if not value:
|
|
69
|
+
return time.time() + 300
|
|
70
|
+
try:
|
|
71
|
+
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
72
|
+
return dt.replace(tzinfo=dt.tzinfo or timezone.utc).timestamp()
|
|
73
|
+
except ValueError:
|
|
74
|
+
return time.time() + 300
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_authenticated(
|
|
78
|
+
project_root: Path | None = None,
|
|
79
|
+
*,
|
|
80
|
+
force_remote: bool = False,
|
|
81
|
+
client: PlatformClient | None = None,
|
|
82
|
+
) -> AuthContext:
|
|
83
|
+
"""Resolve and validate the developer key, or raise ``AuthError``.
|
|
84
|
+
|
|
85
|
+
Uses a cached validation token when present and unexpired unless
|
|
86
|
+
``force_remote`` is set.
|
|
87
|
+
"""
|
|
88
|
+
resolved = resolve_developer_key(project_root)
|
|
89
|
+
if not resolved:
|
|
90
|
+
raise AuthError(
|
|
91
|
+
"No developer key configured. Run `refactor login`, set REFACTOR_API_KEY, "
|
|
92
|
+
"or add developer_key to refactor.config."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if not force_remote:
|
|
96
|
+
cached = _read_cache(resolved.key)
|
|
97
|
+
if cached:
|
|
98
|
+
return AuthContext(
|
|
99
|
+
key=resolved,
|
|
100
|
+
account_id=cached.get("account_id", "unknown"),
|
|
101
|
+
quota_remaining=cached.get("quota_remaining"),
|
|
102
|
+
cached=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
client = client or PlatformClient()
|
|
106
|
+
try:
|
|
107
|
+
payload = client.validate_key(resolved.key)
|
|
108
|
+
except PlatformError as exc:
|
|
109
|
+
raise AuthError(str(exc)) from exc
|
|
110
|
+
|
|
111
|
+
if not payload.get("valid", False):
|
|
112
|
+
raise AuthError("Developer key is invalid or revoked")
|
|
113
|
+
|
|
114
|
+
_write_cache(resolved.key, payload)
|
|
115
|
+
return AuthContext(
|
|
116
|
+
key=resolved,
|
|
117
|
+
account_id=payload.get("account_id", "unknown"),
|
|
118
|
+
quota_remaining=payload.get("quota_remaining"),
|
|
119
|
+
cached=False,
|
|
120
|
+
)
|
refactor_cli/client.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Thin HTTP client for the hosted platform key endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from refactor_cli.settings import platform_url
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PlatformError(RuntimeError):
|
|
11
|
+
"""Raised when the platform rejects a request."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status_code = status_code
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlatformClient:
|
|
19
|
+
def __init__(self, base_url: str | None = None, *, timeout: float = 20.0) -> None:
|
|
20
|
+
self.base_url = (base_url or platform_url()).rstrip("/")
|
|
21
|
+
self.timeout = timeout
|
|
22
|
+
|
|
23
|
+
def validate_key(self, developer_key: str) -> dict:
|
|
24
|
+
"""Validate a developer key; returns the validation payload.
|
|
25
|
+
|
|
26
|
+
Raises ``PlatformError`` on rejection or transport failure so callers
|
|
27
|
+
can fail closed.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
response = httpx.post(
|
|
31
|
+
f"{self.base_url}/v1/keys/validate",
|
|
32
|
+
headers={"Authorization": f"Bearer {developer_key}"},
|
|
33
|
+
timeout=self.timeout,
|
|
34
|
+
)
|
|
35
|
+
except httpx.HTTPError as exc:
|
|
36
|
+
raise PlatformError(f"Could not reach platform at {self.base_url}: {exc}") from exc
|
|
37
|
+
|
|
38
|
+
if response.status_code == 401:
|
|
39
|
+
raise PlatformError("Developer key is invalid or revoked", status_code=401)
|
|
40
|
+
if response.status_code == 429:
|
|
41
|
+
raise PlatformError("Developer key quota exhausted", status_code=429)
|
|
42
|
+
if response.status_code >= 400:
|
|
43
|
+
raise PlatformError(
|
|
44
|
+
f"Key validation failed ({response.status_code})", status_code=response.status_code
|
|
45
|
+
)
|
|
46
|
+
return response.json()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""`refactor login` and `refactor whoami`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from refactor_cli.auth import AuthError, ensure_authenticated
|
|
9
|
+
from refactor_cli.client import PlatformClient, PlatformError
|
|
10
|
+
from refactor_cli.credentials import load_credentials, save_credentials
|
|
11
|
+
from refactor_cli.settings import mask_key, platform_url
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def login(
|
|
17
|
+
key: str = typer.Option(
|
|
18
|
+
None,
|
|
19
|
+
"--key",
|
|
20
|
+
"-k",
|
|
21
|
+
help="Developer key issued by the refactor platform. Prompted if omitted.",
|
|
22
|
+
),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Validate a developer key and store it in the central credential file."""
|
|
25
|
+
developer_key = key or typer.prompt("Developer key", hide_input=True).strip()
|
|
26
|
+
if not developer_key:
|
|
27
|
+
console.print("[red]No key provided.[/red]")
|
|
28
|
+
raise typer.Exit(code=1)
|
|
29
|
+
|
|
30
|
+
client = PlatformClient()
|
|
31
|
+
try:
|
|
32
|
+
payload = client.validate_key(developer_key)
|
|
33
|
+
except PlatformError as exc:
|
|
34
|
+
console.print(f"[red]Login failed:[/red] {exc}")
|
|
35
|
+
raise typer.Exit(code=1) from exc
|
|
36
|
+
|
|
37
|
+
if not payload.get("valid", False):
|
|
38
|
+
console.print("[red]Login failed:[/red] key is invalid or revoked")
|
|
39
|
+
raise typer.Exit(code=1)
|
|
40
|
+
|
|
41
|
+
creds = load_credentials()
|
|
42
|
+
creds.update(
|
|
43
|
+
{
|
|
44
|
+
"developer_key": developer_key,
|
|
45
|
+
"account_id": payload.get("account_id"),
|
|
46
|
+
"platform_url": platform_url(),
|
|
47
|
+
"masked_key": mask_key(developer_key),
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
path = save_credentials(creds)
|
|
51
|
+
console.print(
|
|
52
|
+
f"[green]Logged in[/green] as account [bold]{payload.get('account_id', 'unknown')}[/bold] "
|
|
53
|
+
f"(key {mask_key(developer_key)}). Saved to {path}."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def whoami() -> None:
|
|
58
|
+
"""Show the active account and masked developer key."""
|
|
59
|
+
try:
|
|
60
|
+
ctx = ensure_authenticated()
|
|
61
|
+
except AuthError as exc:
|
|
62
|
+
console.print(f"[red]Not authenticated:[/red] {exc}")
|
|
63
|
+
raise typer.Exit(code=1) from exc
|
|
64
|
+
except OSError:
|
|
65
|
+
console.print(
|
|
66
|
+
"[red]Could not resolve the current project directory.[/red] "
|
|
67
|
+
"Please switch to a valid directory and retry. "
|
|
68
|
+
"If the problem continues, update/reinstall Refactor."
|
|
69
|
+
)
|
|
70
|
+
raise typer.Exit(code=1) from None
|
|
71
|
+
except Exception:
|
|
72
|
+
console.print(
|
|
73
|
+
"[red]Could not determine authentication status.[/red] "
|
|
74
|
+
"Please retry, and if this continues update/reinstall Refactor."
|
|
75
|
+
)
|
|
76
|
+
raise typer.Exit(code=1) from None
|
|
77
|
+
|
|
78
|
+
quota = "unknown" if ctx.quota_remaining is None else str(ctx.quota_remaining)
|
|
79
|
+
source = ctx.key.source
|
|
80
|
+
suffix = " (cached)" if ctx.cached else ""
|
|
81
|
+
console.print(
|
|
82
|
+
f"[bold]account[/bold]: {ctx.account_id}\n"
|
|
83
|
+
f"[bold]key[/bold]: {mask_key(ctx.key.key)} (source: {source})\n"
|
|
84
|
+
f"[bold]quota_remaining[/bold]: {quota}{suffix}"
|
|
85
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Dedicated local engine commands (R13)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from refactor_core.engine_runtime import (
|
|
11
|
+
DEFAULT_ENGINE_CONTAINER,
|
|
12
|
+
DEFAULT_ENGINE_IMAGE,
|
|
13
|
+
DEFAULT_ENGINE_PORT,
|
|
14
|
+
DEFAULT_ENGINE_PROFILE,
|
|
15
|
+
engine_status,
|
|
16
|
+
ensure_engine_up,
|
|
17
|
+
pull_model,
|
|
18
|
+
read_engine_state,
|
|
19
|
+
resolve_runtime,
|
|
20
|
+
run_engine_logs,
|
|
21
|
+
stop_engine,
|
|
22
|
+
)
|
|
23
|
+
from refactor_cli.model_policy import detect_machine_profile, evaluate_model, get_policy_bundle
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
app = typer.Typer(help="Manage the persistent local refactor engine.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_engine_runtime() -> str:
|
|
30
|
+
preferred = os.environ.get("REFACTOR_ENGINE_RUNTIME", "podman")
|
|
31
|
+
runtime, reason = resolve_runtime(preferred)
|
|
32
|
+
if runtime:
|
|
33
|
+
return runtime
|
|
34
|
+
console.print(f"[red]Engine runtime unavailable:[/red] {reason}")
|
|
35
|
+
raise typer.Exit(code=1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("up")
|
|
39
|
+
def up(
|
|
40
|
+
profile: str = typer.Option(DEFAULT_ENGINE_PROFILE, "--profile", help="Engine profile (cpu-small, cpu-balanced, gpu-standard)."),
|
|
41
|
+
image: str = typer.Option(DEFAULT_ENGINE_IMAGE, "--image", help="Engine container image."),
|
|
42
|
+
port: int = typer.Option(DEFAULT_ENGINE_PORT, "--port", help="Loopback port for local engine API."),
|
|
43
|
+
rebuild: bool = typer.Option(False, "--rebuild", help="Recreate container before startup."),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Create or start the persistent local engine container."""
|
|
46
|
+
runtime = _resolve_engine_runtime()
|
|
47
|
+
ok, message, state = ensure_engine_up(
|
|
48
|
+
runtime=runtime,
|
|
49
|
+
image=image,
|
|
50
|
+
container_name=DEFAULT_ENGINE_CONTAINER,
|
|
51
|
+
profile=profile,
|
|
52
|
+
port=port,
|
|
53
|
+
rebuild=rebuild,
|
|
54
|
+
)
|
|
55
|
+
if not ok:
|
|
56
|
+
console.print(f"[red]Engine start failed:[/red] {message}")
|
|
57
|
+
raise typer.Exit(code=1)
|
|
58
|
+
console.print(
|
|
59
|
+
f"[green]Engine ready.[/green] runtime={state.get('runtime')} "
|
|
60
|
+
f"container={state.get('container_name')} profile={state.get('profile')} "
|
|
61
|
+
f"port={state.get('port')}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command("down")
|
|
66
|
+
def down() -> None:
|
|
67
|
+
"""Stop the local engine container."""
|
|
68
|
+
state = read_engine_state()
|
|
69
|
+
runtime = str(state.get("runtime") or _resolve_engine_runtime())
|
|
70
|
+
ok, message = stop_engine(runtime=runtime, container_name=DEFAULT_ENGINE_CONTAINER)
|
|
71
|
+
color = "green" if ok else "yellow"
|
|
72
|
+
console.print(f"[{color}]engine[/{color}]: {message}")
|
|
73
|
+
if not ok:
|
|
74
|
+
raise typer.Exit(code=1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("status")
|
|
78
|
+
def status() -> None:
|
|
79
|
+
"""Show local engine status and runtime metadata."""
|
|
80
|
+
state = read_engine_state()
|
|
81
|
+
runtime = str(state.get("runtime") or _resolve_engine_runtime())
|
|
82
|
+
data = engine_status(runtime=runtime, container_name=DEFAULT_ENGINE_CONTAINER)
|
|
83
|
+
console.print(f"[bold]status[/bold]: {data.get('status')}")
|
|
84
|
+
console.print(f"[bold]runtime[/bold]: {data.get('runtime')}")
|
|
85
|
+
console.print(f"[bold]container[/bold]: {data.get('container_name')}")
|
|
86
|
+
console.print(f"[bold]profile[/bold]: {data.get('profile')}")
|
|
87
|
+
console.print(f"[bold]image[/bold]: {data.get('image')}")
|
|
88
|
+
console.print(f"[bold]bind[/bold]: 127.0.0.1:{data.get('port')}")
|
|
89
|
+
console.print(f"[bold]exists[/bold]: {'yes' if data.get('exists') else 'no'}")
|
|
90
|
+
console.print(f"[bold]running[/bold]: {'yes' if data.get('running') else 'no'}")
|
|
91
|
+
console.print(f"[bold]healthy[/bold]: {'yes' if data.get('healthy') else 'no'}")
|
|
92
|
+
console.print(f"[bold]updated at[/bold]: {data.get('updated_at') or '<never>'}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("logs")
|
|
96
|
+
def logs(
|
|
97
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Follow log stream."),
|
|
98
|
+
tail: int = typer.Option(200, "--tail", help="Number of log lines to show."),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Show local engine container logs."""
|
|
101
|
+
state = read_engine_state()
|
|
102
|
+
runtime = str(state.get("runtime") or _resolve_engine_runtime())
|
|
103
|
+
exit_code = run_engine_logs(
|
|
104
|
+
runtime=runtime,
|
|
105
|
+
container_name=DEFAULT_ENGINE_CONTAINER,
|
|
106
|
+
follow=follow,
|
|
107
|
+
tail=tail,
|
|
108
|
+
)
|
|
109
|
+
if exit_code != 0:
|
|
110
|
+
raise typer.Exit(code=exit_code)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command("pull-model")
|
|
114
|
+
def pull_model_cmd(
|
|
115
|
+
model_id: str = typer.Argument(..., help="Model id to pull inside the local engine container."),
|
|
116
|
+
force_policy_refresh: bool = typer.Option(False, "--force-policy-refresh", help="Refresh signed policy before enforcement."),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Pull a model in the engine container (requires model-capable image)."""
|
|
119
|
+
try:
|
|
120
|
+
bundle = get_policy_bundle(force_refresh=force_policy_refresh)
|
|
121
|
+
except RuntimeError as exc:
|
|
122
|
+
console.print(f"[red]Policy resolve failed:[/red] {exc}")
|
|
123
|
+
raise typer.Exit(code=1) from exc
|
|
124
|
+
profile_info = detect_machine_profile()
|
|
125
|
+
decision = evaluate_model(
|
|
126
|
+
model_id=model_id,
|
|
127
|
+
policy_bundle=bundle,
|
|
128
|
+
profile=str(profile_info["profile"]),
|
|
129
|
+
)
|
|
130
|
+
if not decision.allowed:
|
|
131
|
+
console.print(f"[red]Model blocked:[/red] {decision.message}")
|
|
132
|
+
if decision.suggested_model_id:
|
|
133
|
+
console.print(f"[yellow]Suggested model:[/yellow] {decision.suggested_model_id}")
|
|
134
|
+
raise typer.Exit(code=1)
|
|
135
|
+
if decision.override_applied:
|
|
136
|
+
console.print("[yellow]Policy override applied for this model.[/yellow]")
|
|
137
|
+
state = read_engine_state()
|
|
138
|
+
runtime = str(state.get("runtime") or _resolve_engine_runtime())
|
|
139
|
+
ok, message = pull_model(
|
|
140
|
+
runtime=runtime,
|
|
141
|
+
container_name=DEFAULT_ENGINE_CONTAINER,
|
|
142
|
+
model_id=model_id,
|
|
143
|
+
)
|
|
144
|
+
if not ok:
|
|
145
|
+
console.print(f"[red]Model pull failed:[/red] {message}")
|
|
146
|
+
raise typer.Exit(code=1)
|
|
147
|
+
console.print(f"[green]Model pull complete.[/green] {message}")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Model policy commands (R15)."""
|
|
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 refactor_core.engine_runtime import DEFAULT_ENGINE_CONTAINER, probe_model, read_engine_state, resolve_runtime
|
|
10
|
+
|
|
11
|
+
from refactor_cli.model_policy import (
|
|
12
|
+
detect_machine_profile,
|
|
13
|
+
evaluate_model,
|
|
14
|
+
get_policy_bundle,
|
|
15
|
+
list_model_entries,
|
|
16
|
+
recommended_model_id,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
app = typer.Typer(help="Inspect and enforce local model policy.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("policy")
|
|
24
|
+
def policy(force_refresh: bool = typer.Option(False, "--force-refresh", help="Refresh from control plane.")) -> None:
|
|
25
|
+
"""Show effective signed model policy metadata."""
|
|
26
|
+
try:
|
|
27
|
+
bundle = get_policy_bundle(force_refresh=force_refresh)
|
|
28
|
+
except RuntimeError as exc:
|
|
29
|
+
console.print(f"[red]Policy resolve failed:[/red] {exc}")
|
|
30
|
+
raise typer.Exit(code=1) from exc
|
|
31
|
+
inner = dict(bundle.get("bundle") or {})
|
|
32
|
+
revision = str(inner.get("revision_id") or "<unknown>")
|
|
33
|
+
signature = str(bundle.get("signature") or "<missing>")
|
|
34
|
+
policy = dict(inner.get("model_policy") or {})
|
|
35
|
+
console.print(f"[bold]policy revision[/bold]: {revision}")
|
|
36
|
+
console.print(f"[bold]signature[/bold]: {signature}")
|
|
37
|
+
console.print(f"[bold]allowlist entries[/bold]: {len(policy.get('allowlist') or [])}")
|
|
38
|
+
console.print(f"[bold]blocklist entries[/bold]: {len(policy.get('blocklist') or [])}")
|
|
39
|
+
console.print(f"[bold]override entries[/bold]: {len(policy.get('overrides') or [])}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("list")
|
|
43
|
+
def list_models(force_refresh: bool = typer.Option(False, "--force-refresh", help="Refresh from control plane.")) -> None:
|
|
44
|
+
"""List allowed/blocked models from signed policy."""
|
|
45
|
+
try:
|
|
46
|
+
bundle = get_policy_bundle(force_refresh=force_refresh)
|
|
47
|
+
except RuntimeError as exc:
|
|
48
|
+
console.print(f"[red]Policy resolve failed:[/red] {exc}")
|
|
49
|
+
raise typer.Exit(code=1) from exc
|
|
50
|
+
entries = list_model_entries(bundle)
|
|
51
|
+
if not entries:
|
|
52
|
+
console.print("[yellow]No model entries found in current policy.[/yellow]")
|
|
53
|
+
return
|
|
54
|
+
table = Table(title="Model policy entries")
|
|
55
|
+
table.add_column("model_id")
|
|
56
|
+
table.add_column("status")
|
|
57
|
+
table.add_column("license")
|
|
58
|
+
table.add_column("commercial")
|
|
59
|
+
table.add_column("gpu")
|
|
60
|
+
for item in entries:
|
|
61
|
+
table.add_row(
|
|
62
|
+
str(item.get("model_id") or ""),
|
|
63
|
+
str(item.get("status") or ""),
|
|
64
|
+
str(item.get("license_class") or ""),
|
|
65
|
+
"yes" if bool(item.get("commercial_allowed", False)) else "no",
|
|
66
|
+
"yes" if bool(item.get("requires_gpu", False)) else "no",
|
|
67
|
+
)
|
|
68
|
+
console.print(table)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("recommend")
|
|
72
|
+
def recommend(force_refresh: bool = typer.Option(False, "--force-refresh", help="Refresh from control plane.")) -> None:
|
|
73
|
+
"""Recommend a default model for this machine profile."""
|
|
74
|
+
profile_info = detect_machine_profile()
|
|
75
|
+
try:
|
|
76
|
+
bundle = get_policy_bundle(force_refresh=force_refresh)
|
|
77
|
+
except RuntimeError as exc:
|
|
78
|
+
console.print(f"[red]Policy resolve failed:[/red] {exc}")
|
|
79
|
+
raise typer.Exit(code=1) from exc
|
|
80
|
+
recommended = recommended_model_id(bundle, profile=profile_info["profile"])
|
|
81
|
+
if not recommended:
|
|
82
|
+
console.print("[yellow]No recommended model configured for this profile.[/yellow]")
|
|
83
|
+
raise typer.Exit(code=1)
|
|
84
|
+
console.print(f"[bold]machine profile[/bold]: {profile_info['profile']}")
|
|
85
|
+
console.print(f"[bold]memory (MB)[/bold]: {profile_info['memory_mb']}")
|
|
86
|
+
console.print(f"[bold]gpu detected[/bold]: {'yes' if profile_info['has_gpu'] else 'no'}")
|
|
87
|
+
console.print(f"[green]recommended model[/green]: {recommended}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command("doctor")
|
|
91
|
+
def doctor(
|
|
92
|
+
model_id: str | None = typer.Option(None, "--model-id", help="Model id to validate against policy."),
|
|
93
|
+
force_refresh: bool = typer.Option(False, "--force-refresh", help="Refresh from control plane."),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Validate model selection policy and local availability hints."""
|
|
96
|
+
profile_info = detect_machine_profile()
|
|
97
|
+
try:
|
|
98
|
+
bundle = get_policy_bundle(force_refresh=force_refresh)
|
|
99
|
+
except RuntimeError as exc:
|
|
100
|
+
console.print(f"[red]Policy resolve failed:[/red] {exc}")
|
|
101
|
+
raise typer.Exit(code=1) from exc
|
|
102
|
+
chosen = str(model_id or recommended_model_id(bundle, profile=profile_info["profile"])).strip()
|
|
103
|
+
if not chosen:
|
|
104
|
+
console.print("[red]No model id provided and no recommendation available.[/red]")
|
|
105
|
+
raise typer.Exit(code=1)
|
|
106
|
+
decision = evaluate_model(model_id=chosen, policy_bundle=bundle, profile=profile_info["profile"])
|
|
107
|
+
console.print(f"[bold]model[/bold]: {chosen}")
|
|
108
|
+
console.print(f"[bold]decision[/bold]: {decision.reason_code}")
|
|
109
|
+
console.print(decision.message)
|
|
110
|
+
if decision.suggested_model_id:
|
|
111
|
+
console.print(f"[bold]suggested[/bold]: {decision.suggested_model_id}")
|
|
112
|
+
if not decision.allowed:
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
state = read_engine_state()
|
|
115
|
+
runtime, reason = resolve_runtime(str(state.get("runtime") or "podman"))
|
|
116
|
+
if not runtime:
|
|
117
|
+
console.print(f"[yellow]local availability[/yellow]: {reason}")
|
|
118
|
+
raise typer.Exit(code=1)
|
|
119
|
+
ok, detail = probe_model(runtime=runtime, container_name=DEFAULT_ENGINE_CONTAINER, model_id=chosen)
|
|
120
|
+
color = "green" if ok else "yellow"
|
|
121
|
+
console.print(f"[{color}]local availability[/{color}]: {detail}")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Rule policy introspection commands (R12)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from refactor_core.rules import RuleLoadError, resolve_filter_for_path, resolve_rule_for_path
|
|
11
|
+
from refactor_core.rules.loader import load_rule_file, load_system_rules
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
app = typer.Typer(help="Inspect deterministic review rule resolution.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("check")
|
|
18
|
+
def check(
|
|
19
|
+
file_path: str = typer.Argument(..., help="Project-relative file path to inspect."),
|
|
20
|
+
rule: str | None = typer.Option(
|
|
21
|
+
None,
|
|
22
|
+
"--rule",
|
|
23
|
+
help="Path to a custom rule JSON file (highest precedence for this command).",
|
|
24
|
+
),
|
|
25
|
+
repo: str = typer.Option(
|
|
26
|
+
".",
|
|
27
|
+
"--repo",
|
|
28
|
+
help="Project root directory (defaults to current working directory).",
|
|
29
|
+
),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Show which rule applies to a file path and why."""
|
|
32
|
+
project_root = Path(repo).resolve()
|
|
33
|
+
custom_rule_path = Path(rule).resolve() if rule else None
|
|
34
|
+
try:
|
|
35
|
+
scope = resolve_filter_for_path(
|
|
36
|
+
file_path,
|
|
37
|
+
project_root=project_root,
|
|
38
|
+
custom_rule_path=custom_rule_path,
|
|
39
|
+
)
|
|
40
|
+
detail = resolve_rule_for_path(
|
|
41
|
+
file_path,
|
|
42
|
+
project_root=project_root,
|
|
43
|
+
custom_rule_path=custom_rule_path,
|
|
44
|
+
)
|
|
45
|
+
except RuleLoadError as exc:
|
|
46
|
+
console.print(f"[red]Rule resolution failed:[/red] {exc}")
|
|
47
|
+
raise typer.Exit(code=1) from exc
|
|
48
|
+
|
|
49
|
+
source_label = {
|
|
50
|
+
"custom": "Custom (--rule)",
|
|
51
|
+
"project": "Project (.refactor/rules.json)",
|
|
52
|
+
"global": "Global (~/.refactor/rules.json)",
|
|
53
|
+
"system": "System built-in",
|
|
54
|
+
}.get(detail.source, detail.source)
|
|
55
|
+
|
|
56
|
+
console.print(f"File: {file_path}")
|
|
57
|
+
console.print(f"Scope: {'included' if scope.included else 'excluded'}")
|
|
58
|
+
console.print(f"Scope Source: {scope.source}")
|
|
59
|
+
console.print(f"Scope Pattern: {scope.pattern}")
|
|
60
|
+
console.print(f"Source: {source_label}")
|
|
61
|
+
console.print(f"Pattern: {detail.pattern}")
|
|
62
|
+
console.print("Rule:")
|
|
63
|
+
console.print("-" * 40)
|
|
64
|
+
console.print(detail.rule)
|
|
65
|
+
console.print("-" * 40)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _warn_if_shadowed(patterns: list[str], *, idx: int) -> bool:
|
|
69
|
+
"""Best-effort shadow check: earlier catch-all can mask later rules."""
|
|
70
|
+
current = (patterns[idx] or "").strip().lower()
|
|
71
|
+
if not current:
|
|
72
|
+
return False
|
|
73
|
+
for prior in patterns[:idx]:
|
|
74
|
+
p = (prior or "").strip().lower()
|
|
75
|
+
if p in {"**", "**/*", "*"}:
|
|
76
|
+
return True
|
|
77
|
+
if p == current:
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("lint")
|
|
83
|
+
def lint(
|
|
84
|
+
rule: str = typer.Argument(..., help="Path to a rule JSON file to lint."),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Lint one rule file for broad and likely-shadowed patterns."""
|
|
87
|
+
rule_path = Path(rule).resolve()
|
|
88
|
+
try:
|
|
89
|
+
rule_file = load_rule_file(rule_path)
|
|
90
|
+
except RuleLoadError as exc:
|
|
91
|
+
console.print(f"[red]Rule lint failed:[/red] {exc}")
|
|
92
|
+
raise typer.Exit(code=1) from exc
|
|
93
|
+
|
|
94
|
+
warnings: list[str] = []
|
|
95
|
+
patterns = [entry.path for entry in rule_file.rules]
|
|
96
|
+
for i, pattern in enumerate(patterns):
|
|
97
|
+
pat = pattern.strip().lower()
|
|
98
|
+
if pat in {"**", "**/*", "*"}:
|
|
99
|
+
warnings.append(
|
|
100
|
+
f"rules[{i}] '{pattern}' is a broad catch-all and can mask later rules."
|
|
101
|
+
)
|
|
102
|
+
if _warn_if_shadowed(patterns, idx=i):
|
|
103
|
+
warnings.append(
|
|
104
|
+
f"rules[{i}] '{pattern}' may be shadowed by an earlier broad/same pattern."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if warnings:
|
|
108
|
+
console.print(f"[yellow]Lint warnings[/yellow] ({len(warnings)}):")
|
|
109
|
+
for item in warnings:
|
|
110
|
+
console.print(f" - {item}")
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
console.print("[green]OK[/green]: no obvious broad/shadowed rule pattern issues found.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("catalog")
|
|
117
|
+
def catalog() -> None:
|
|
118
|
+
"""Show built-in system rule catalog for governance/audit."""
|
|
119
|
+
try:
|
|
120
|
+
rule_file = load_system_rules()
|
|
121
|
+
except RuleLoadError as exc:
|
|
122
|
+
console.print(f"[red]Could not load built-in catalog:[/red] {exc}")
|
|
123
|
+
raise typer.Exit(code=1) from exc
|
|
124
|
+
|
|
125
|
+
console.print("Built-in system rule catalog")
|
|
126
|
+
console.print(f"Rules: {len(rule_file.rules)}")
|
|
127
|
+
console.print(f"Has default: {'yes' if bool(rule_file.default_rule) else 'no'}")
|
|
128
|
+
console.print("")
|
|
129
|
+
for idx, entry in enumerate(rule_file.rules, start=1):
|
|
130
|
+
console.print(f"{idx:02d}. Pattern: {entry.path}")
|
|
131
|
+
console.print(f" Rule: {entry.rule}")
|