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.
@@ -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}")