ghostfix 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.
ghostfix/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .router import AIRouter
2
+ from .base import BaseProvider
3
+
4
+ __all__ = ["AIRouter", "BaseProvider"]
ghostfix/ai/base.py ADDED
@@ -0,0 +1,8 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class BaseProvider(ABC):
5
+ @abstractmethod
6
+ def complete(self, system: str, user: str) -> str | None:
7
+ """Send prompt, return raw text response."""
8
+ ...
@@ -0,0 +1,34 @@
1
+ import httpx
2
+ from ghostfix.ai.base import BaseProvider
3
+
4
+ CLAUDE_URL = "https://api.anthropic.com/v1/messages"
5
+
6
+
7
+ class ClaudeProvider(BaseProvider):
8
+ def __init__(self, api_key: str):
9
+ self.api_key = api_key
10
+
11
+ def complete(self, system: str, user: str) -> str | None:
12
+ headers = {
13
+ "x-api-key": self.api_key,
14
+ "anthropic-version": "2023-06-01",
15
+ "Content-Type": "application/json",
16
+ }
17
+ payload = {
18
+ "model": "claude-opus-4-6",
19
+ "max_tokens": 2000,
20
+ "system": system,
21
+ "messages": [
22
+ {"role": "user", "content": user},
23
+ ],
24
+ }
25
+ try:
26
+ with httpx.Client(timeout=60) as client:
27
+ resp = client.post(CLAUDE_URL, json=payload, headers=headers)
28
+ resp.raise_for_status()
29
+ data = resp.json()
30
+ return data["content"][0]["text"]
31
+ except httpx.HTTPStatusError as e:
32
+ raise RuntimeError(f"Claude API error {e.response.status_code}: {e.response.text}")
33
+ except Exception as e:
34
+ raise RuntimeError(f"Claude request failed: {e}")
@@ -0,0 +1,93 @@
1
+ """
2
+ CustomProvider
3
+ Calls any OpenAI-compatible endpoint (Ollama, LM Studio, vLLM,
4
+ your own server, etc.) defined in ghostfix.config.py.
5
+
6
+ Expected request format (OpenAI-compatible):
7
+ POST /v1/chat/completions OR /api/chat (Ollama)
8
+ { "model": "...", "messages": [...] }
9
+
10
+ Expected response (either format):
11
+ OpenAI: { "choices": [{ "message": { "content": "..." } }] }
12
+ Ollama: { "message": { "content": "..." } }
13
+ """
14
+ import httpx
15
+ from ghostfix.ai.base import BaseProvider
16
+
17
+
18
+ class CustomProvider(BaseProvider):
19
+ def __init__(self, model_cfg: dict):
20
+ self.endpoint = model_cfg["endpoint"].rstrip("/")
21
+ self.model_name = model_cfg.get("model_name", "")
22
+ self.api_key = model_cfg.get("api_key", "")
23
+ self.extra_headers = model_cfg.get("headers", {})
24
+ self.timeout = model_cfg.get("timeout_seconds", 120)
25
+
26
+ # Auto-detect Ollama vs OpenAI-compatible
27
+ self._is_ollama = "ollama" in self.endpoint or "/api/chat" in self.endpoint
28
+
29
+ def complete(self, system: str, user: str) -> str | None:
30
+ headers = {"Content-Type": "application/json"}
31
+ if self.api_key:
32
+ headers["Authorization"] = f"Bearer {self.api_key}"
33
+ headers.update(self.extra_headers)
34
+
35
+ if self._is_ollama:
36
+ return self._call_ollama(headers, system, user)
37
+ else:
38
+ return self._call_openai_compat(headers, system, user)
39
+
40
+ def _call_ollama(self, headers: dict, system: str, user: str) -> str | None:
41
+ """Ollama /api/chat format."""
42
+ payload = {
43
+ "model": self.model_name,
44
+ "messages": [
45
+ {"role": "system", "content": system},
46
+ {"role": "user", "content": user},
47
+ ],
48
+ "stream": False,
49
+ "options": {"temperature": 0.2},
50
+ }
51
+ url = self.endpoint
52
+ if not url.endswith("/api/chat"):
53
+ url = url.rstrip("/") + "/api/chat"
54
+
55
+ try:
56
+ with httpx.Client(timeout=self.timeout) as client:
57
+ resp = client.post(url, json=payload, headers=headers)
58
+ resp.raise_for_status()
59
+ data = resp.json()
60
+ # Ollama returns { "message": { "content": "..." } }
61
+ return data.get("message", {}).get("content")
62
+ except httpx.HTTPStatusError as e:
63
+ raise RuntimeError(f"Custom model error {e.response.status_code}: {e.response.text}")
64
+ except Exception as e:
65
+ raise RuntimeError(f"Custom model request failed: {e}")
66
+
67
+ def _call_openai_compat(self, headers: dict, system: str, user: str) -> str | None:
68
+ """OpenAI-compatible /v1/chat/completions format."""
69
+ payload = {
70
+ "messages": [
71
+ {"role": "system", "content": system},
72
+ {"role": "user", "content": user},
73
+ ],
74
+ "temperature": 0.2,
75
+ "max_tokens": 2000,
76
+ }
77
+ if self.model_name:
78
+ payload["model"] = self.model_name
79
+
80
+ url = self.endpoint
81
+ if not url.endswith("/chat/completions"):
82
+ url = url.rstrip("/") + "/v1/chat/completions"
83
+
84
+ try:
85
+ with httpx.Client(timeout=self.timeout) as client:
86
+ resp = client.post(url, json=payload, headers=headers)
87
+ resp.raise_for_status()
88
+ data = resp.json()
89
+ return data["choices"][0]["message"]["content"]
90
+ except httpx.HTTPStatusError as e:
91
+ raise RuntimeError(f"Custom model error {e.response.status_code}: {e.response.text}")
92
+ except Exception as e:
93
+ raise RuntimeError(f"Custom model request failed: {e}")
@@ -0,0 +1,33 @@
1
+ import httpx
2
+ from ghostfix.ai.base import BaseProvider
3
+
4
+
5
+ class GeminiProvider(BaseProvider):
6
+ def __init__(self, api_key: str):
7
+ self.api_key = api_key
8
+ self.url = (
9
+ f"https://generativelanguage.googleapis.com/v1beta/models/"
10
+ f"gemini-2.5-flash:generateContent?key={api_key}"
11
+ )
12
+
13
+ def complete(self, system: str, user: str) -> str | None:
14
+ payload = {
15
+ "system_instruction": {"parts": [{"text": system}]},
16
+ "contents": [
17
+ {"role": "user", "parts": [{"text": user}]}
18
+ ],
19
+ "generationConfig": {
20
+ "temperature": 0.2,
21
+ "maxOutputTokens": 2000,
22
+ },
23
+ }
24
+ try:
25
+ with httpx.Client(timeout=60) as client:
26
+ resp = client.post(self.url, json=payload)
27
+ resp.raise_for_status()
28
+ data = resp.json()
29
+ return data["candidates"][0]["content"]["parts"][0]["text"]
30
+ except httpx.HTTPStatusError as e:
31
+ raise RuntimeError(f"Gemini API error {e.response.status_code}: {e.response.text}")
32
+ except Exception as e:
33
+ raise RuntimeError(f"Gemini request failed: {e}")
@@ -0,0 +1,34 @@
1
+ import httpx
2
+ from ghostfix.ai.base import BaseProvider
3
+
4
+ OPENAI_URL = "https://api.openai.com/v1/chat/completions"
5
+
6
+
7
+ class OpenAIProvider(BaseProvider):
8
+ def __init__(self, api_key: str):
9
+ self.api_key = api_key
10
+
11
+ def complete(self, system: str, user: str) -> str | None:
12
+ headers = {
13
+ "Authorization": f"Bearer {self.api_key}",
14
+ "Content-Type": "application/json",
15
+ }
16
+ payload = {
17
+ "model": "gpt-4o",
18
+ "messages": [
19
+ {"role": "system", "content": system},
20
+ {"role": "user", "content": user},
21
+ ],
22
+ "temperature": 0.2,
23
+ "max_tokens": 2000,
24
+ }
25
+ try:
26
+ with httpx.Client(timeout=60) as client:
27
+ resp = client.post(OPENAI_URL, json=payload, headers=headers)
28
+ resp.raise_for_status()
29
+ data = resp.json()
30
+ return data["choices"][0]["message"]["content"]
31
+ except httpx.HTTPStatusError as e:
32
+ raise RuntimeError(f"OpenAI API error {e.response.status_code}: {e.response.text}")
33
+ except Exception as e:
34
+ raise RuntimeError(f"OpenAI request failed: {e}")
ghostfix/ai/router.py ADDED
@@ -0,0 +1,127 @@
1
+ """
2
+ AIRouter
3
+ Selects and calls the correct AI provider based on config.
4
+ Builds the prompt and parses the JSON response.
5
+ """
6
+ import json
7
+ import re
8
+
9
+ from ghostfix.core.error_parser import ParsedError
10
+ from ghostfix.core.context_builder import CodeContext
11
+
12
+
13
+ SYSTEM_PROMPT = """\
14
+ You are GhostFix, an expert debugging assistant embedded in a developer's terminal.
15
+
16
+ Analyze the error and code context provided. Respond ONLY with a valid JSON object — no markdown, no preamble, no explanation outside the JSON.
17
+
18
+ JSON schema:
19
+ {
20
+ "root_cause": "<2-3 sentence clear explanation of WHY the error happened>",
21
+ "explanation": "<deeper technical explanation>",
22
+ "fix_suggestion": "<what the developer should do to fix it>",
23
+ "patch": "<unified diff patch string, or empty string if no patch needed>",
24
+ "confidence": <float 0.0-1.0>,
25
+ "related_files": ["<other files that may need changes>"]
26
+ }
27
+
28
+ Patch format rules:
29
+ - Use standard unified diff format (--- a/file, +++ b/file, @@ hunks)
30
+ - Include enough context lines (3) around changes
31
+ - Only patch what is necessary
32
+ - If the fix requires running a command (e.g. migrations), set patch to "" and explain in fix_suggestion
33
+ """
34
+
35
+
36
+ def _build_user_prompt(parsed: ParsedError, context: CodeContext) -> str:
37
+ parts = [
38
+ f"LANGUAGE: {parsed.language}",
39
+ f"ERROR TYPE: {parsed.error_type}",
40
+ f"ERROR MESSAGE: {parsed.error_message}",
41
+ "",
42
+ "=== FULL ERROR OUTPUT ===",
43
+ parsed.raw[:3000],
44
+ "",
45
+ ]
46
+
47
+ if context.primary_file and context.primary_snippet:
48
+ parts += [
49
+ f"=== PRIMARY FILE: {context.primary_file} ===",
50
+ context.primary_snippet,
51
+ "",
52
+ ]
53
+
54
+ for rf in context.related_files:
55
+ parts += [
56
+ f"=== RELATED FILE: {rf['path']} (around line {rf['line']}) ===",
57
+ rf["snippet"],
58
+ "",
59
+ ]
60
+
61
+ if context.project_tree:
62
+ parts += [
63
+ "=== PROJECT STRUCTURE ===",
64
+ context.project_tree[:1500],
65
+ "",
66
+ ]
67
+
68
+ return "\n".join(parts)
69
+
70
+
71
+ def _parse_response(text: str) -> dict | None:
72
+ """Extract JSON from AI response, handle markdown fences."""
73
+ text = text.strip()
74
+ # Strip ```json ... ``` fences
75
+ text = re.sub(r"^```(?:json)?\s*", "", text)
76
+ text = re.sub(r"\s*```$", "", text)
77
+ try:
78
+ return json.loads(text)
79
+ except json.JSONDecodeError:
80
+ # Try to find JSON object in response
81
+ m = re.search(r'\{.*\}', text, re.DOTALL)
82
+ if m:
83
+ try:
84
+ return json.loads(m.group(0))
85
+ except Exception:
86
+ pass
87
+ return None
88
+
89
+
90
+ class AIRouter:
91
+ def __init__(self, config: dict):
92
+ self.config = config
93
+ self._provider = None
94
+ self._init_provider()
95
+
96
+ def _init_provider(self):
97
+ model_cfg = self.config.get("model")
98
+ if model_cfg and model_cfg.get("type") == "custom":
99
+ from ghostfix.ai.custom_provider import CustomProvider
100
+ self._provider = CustomProvider(model_cfg)
101
+ return
102
+
103
+ provider_name = self.config.get("provider", "openai")
104
+ api_key = self.config.get("api_key", "")
105
+
106
+ if provider_name == "openai":
107
+ from ghostfix.ai.openai_provider import OpenAIProvider
108
+ self._provider = OpenAIProvider(api_key)
109
+ elif provider_name == "claude":
110
+ from ghostfix.ai.claude_provider import ClaudeProvider
111
+ self._provider = ClaudeProvider(api_key)
112
+ elif provider_name == "gemini":
113
+ from ghostfix.ai.gemini_provider import GeminiProvider
114
+ self._provider = GeminiProvider(api_key)
115
+ else:
116
+ raise ValueError(f"Unknown provider: {provider_name}")
117
+
118
+ def analyze(self, parsed: ParsedError, context: CodeContext) -> dict | None:
119
+ user_msg = _build_user_prompt(parsed, context)
120
+ raw = self._provider.complete(
121
+ system=SYSTEM_PROMPT,
122
+ user=user_msg,
123
+ )
124
+ if not raw:
125
+ return None
126
+ result = _parse_response(raw)
127
+ return result
ghostfix/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ GhostFix CLI — AI-powered terminal error watcher & auto-fixer
3
+ """
4
+ import click
5
+ import sys
6
+ from pathlib import Path
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.text import Text
10
+
11
+ from ghostfix.config.manager import ConfigManager
12
+ from ghostfix.core.watcher import ProcessWatcher
13
+ from ghostfix.ui.renderer import Renderer
14
+
15
+ console = Console()
16
+
17
+
18
+ def print_banner():
19
+ banner = Text()
20
+ banner.append("👻 GhostFix", style="bold magenta")
21
+ banner.append(" v0.1.0", style="dim")
22
+ banner.append(" — AI-powered error watcher & auto-fixer", style="italic cyan")
23
+ console.print(Panel(banner, border_style="magenta", padding=(0, 2)))
24
+
25
+
26
+ @click.group(invoke_without_command=True)
27
+ @click.pass_context
28
+ def main(ctx):
29
+ if ctx.invoked_subcommand is None:
30
+ click.echo(ctx.get_help())
31
+
32
+
33
+ @main.command()
34
+ @click.argument("command")
35
+ @click.option("--fix", is_flag=True, default=False, help="Auto-fix errors when detected")
36
+ @click.option("--ai", is_flag=True, default=False, help="Use cloud AI provider (OpenAI/Claude/Gemini)")
37
+ @click.option("--provider", type=click.Choice(["openai", "claude", "gemini"]), default=None, help="Force specific AI provider")
38
+ @click.option("--auto", is_flag=True, default=False, help="Apply patches without confirmation")
39
+ @click.option("--config", type=click.Path(), default=None, help="Path to ghostfix.config.py")
40
+ @click.option("--verbose", "-v", is_flag=True, default=False, help="Show verbose output")
41
+ def watch(command, fix, ai, provider, auto, config, verbose):
42
+ """Watch a command and auto-fix errors.
43
+
44
+ \b
45
+ Examples:
46
+ ghostfix watch "npm run server" --fix --ai
47
+ ghostfix watch "python manage.py runserver" --fix
48
+ ghostfix watch "flask run" --fix --ai --provider claude
49
+ ghostfix watch "go run main.go" --fix
50
+ """
51
+ print_banner()
52
+
53
+ # Load config
54
+ cfg_manager = ConfigManager(config_path=config)
55
+ cfg = cfg_manager.load()
56
+
57
+ # If --ai flag, ensure provider is configured
58
+ if ai:
59
+ cfg = cfg_manager.ensure_ai_provider(
60
+ cfg,
61
+ force_provider=provider,
62
+ prompt_each_run=True,
63
+ save=False,
64
+ )
65
+
66
+ elif not cfg.get("model"):
67
+ # No --ai and no config model → prompt user
68
+ console.print(
69
+ "\n[yellow]âš  No AI provider configured.[/yellow]\n"
70
+ "Run with [bold]--ai[/bold] to use a cloud provider, or create a "
71
+ "[bold]ghostfix.config.py[/bold] for a custom/local model.\n"
72
+ )
73
+ sys.exit(1)
74
+
75
+ # Override auto_apply if --auto flag
76
+ if auto:
77
+ cfg.setdefault("fix", {})["auto_apply"] = True
78
+
79
+ renderer = Renderer(console=console, verbose=verbose)
80
+
81
+ watcher = ProcessWatcher(
82
+ command=command,
83
+ config=cfg,
84
+ fix_enabled=fix,
85
+ renderer=renderer,
86
+ )
87
+
88
+ try:
89
+ console.print(f"\n[bold green]â–¶ Watching:[/bold green] [cyan]{command}[/cyan]\n")
90
+ watcher.run()
91
+ except KeyboardInterrupt:
92
+ console.print("\n\n[dim]👻 GhostFix stopped.[/dim]\n")
93
+ sys.exit(0)
94
+
95
+
96
+ @main.command()
97
+ def setup():
98
+ """Interactive setup: configure AI provider and API key."""
99
+ print_banner()
100
+ cfg_manager = ConfigManager()
101
+ cfg = cfg_manager.load()
102
+ cfg_manager.ensure_ai_provider(cfg, force_provider=None)
103
+ console.print("\n[green]✅ Setup complete![/green]")
104
+
105
+
106
+ @main.command()
107
+ def config_show():
108
+ """Show current GhostFix configuration."""
109
+ print_banner()
110
+ cfg_manager = ConfigManager()
111
+ cfg = cfg_manager.load()
112
+ import json
113
+ # Mask API keys
114
+ display = dict(cfg)
115
+ if display.get("api_key"):
116
+ display["api_key"] = display["api_key"][:8] + "..."
117
+ console.print_json(json.dumps(display, indent=2))
118
+
119
+
120
+ @main.command()
121
+ def init():
122
+ """Create a ghostfix.config.py in the current directory (for custom models)."""
123
+ target = Path.cwd() / "ghostfix.config.py"
124
+ if target.exists():
125
+ console.print("[yellow]ghostfix.config.py already exists.[/yellow]")
126
+ return
127
+
128
+ template = '''\
129
+ # ghostfix.config.py
130
+ # Place this file in your project root to use a custom/local model
131
+ # without needing any cloud API key.
132
+
133
+ GHOSTFIX_CONFIG = {
134
+ "model": {
135
+ "type": "custom",
136
+
137
+ # --- Ollama (local) example ---
138
+ "endpoint": "http://localhost:11434/api/chat",
139
+ "model_name": "codellama:13b",
140
+
141
+ # --- Your own server example ---
142
+ # "endpoint": "http://192.168.1.100:8000/v1/chat",
143
+ # "api_key": "optional-if-needed",
144
+ # "headers": {"X-Custom-Header": "value"},
145
+ #
146
+ # The endpoint must accept POST with JSON body:
147
+ # { "model": "...", "messages": [...] }
148
+ # and return { "message": { "content": "..." } }
149
+ # (OpenAI-compatible format)
150
+ },
151
+
152
+ "watch": {
153
+ "ignore": [
154
+ "node_modules", ".git", "dist", "__pycache__",
155
+ "venv", ".env", "migrations", ".mypy_cache",
156
+ ],
157
+ "max_file_size_kb": 500,
158
+ "context_lines": 60,
159
+ },
160
+
161
+ "fix": {
162
+ "auto_apply": False, # True = no confirmation prompt
163
+ "create_backup": True, # backup before patching
164
+ "restart_on_fix": True, # restart command after fix
165
+ "max_retries": 3, # retry limit for same error
166
+ },
167
+
168
+ "ui": {
169
+ "language": "english", # "bangla" coming soon
170
+ "show_diff": True,
171
+ "color_theme": "dark",
172
+ },
173
+ }
174
+ '''
175
+ target.write_text(template)
176
+ console.print(f"[green]✅ Created:[/green] {target}")
177
+ console.print("[dim]Edit ghostfix.config.py to point to your local model endpoint.[/dim]")
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()
@@ -0,0 +1,2 @@
1
+ from .manager import ConfigManager
2
+ __all__ = ["ConfigManager"]
@@ -0,0 +1,159 @@
1
+ """
2
+ Config Manager
3
+ Handles:
4
+ - ~/.ghostfix/config.json (global: AI provider + API key)
5
+ - ./ghostfix.config.py (project-level: custom model, watch settings)
6
+ """
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich.prompt import Prompt, Confirm
13
+ from rich.panel import Panel
14
+
15
+ console = Console()
16
+
17
+ GLOBAL_CONFIG_DIR = Path.home() / ".ghostfix"
18
+ GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.json"
19
+
20
+ PROVIDERS = {
21
+ "1": ("openai", "OpenAI (GPT-4o)"),
22
+ "2": ("claude", "Claude (Anthropic)"),
23
+ "3": ("gemini", "Gemini (Google)"),
24
+ }
25
+
26
+
27
+ class ConfigManager:
28
+ def __init__(self, config_path: str | None = None):
29
+ self.config_path = Path(config_path) if config_path else None
30
+
31
+ # ------------------------------------------------------------------ #
32
+ # Load #
33
+ # ------------------------------------------------------------------ #
34
+ def load(self) -> dict:
35
+ cfg: dict = {}
36
+
37
+ # 1. Global config
38
+ if GLOBAL_CONFIG_FILE.exists():
39
+ try:
40
+ cfg.update(json.loads(GLOBAL_CONFIG_FILE.read_text()))
41
+ except Exception:
42
+ pass
43
+
44
+ # 2. Project-level ghostfix.config.py (overrides global)
45
+ project_cfg = self._load_project_config()
46
+ if project_cfg:
47
+ self._merge(cfg, project_cfg)
48
+
49
+ return cfg
50
+
51
+ def _load_project_config(self) -> dict | None:
52
+ """Import ghostfix.config.py from project root (or --config path)."""
53
+ candidates = []
54
+ if self.config_path:
55
+ candidates.append(self.config_path)
56
+ candidates.append(Path.cwd() / "ghostfix.config.py")
57
+
58
+ for p in candidates:
59
+ if p.exists():
60
+ try:
61
+ import importlib.util
62
+ spec = importlib.util.spec_from_file_location("_ghostfix_cfg", p)
63
+ mod = importlib.util.module_from_spec(spec)
64
+ spec.loader.exec_module(mod)
65
+ raw = getattr(mod, "GHOSTFIX_CONFIG", None)
66
+ if isinstance(raw, dict):
67
+ console.print(f"[dim]📄 Loaded project config: {p}[/dim]")
68
+ return raw
69
+ except Exception as e:
70
+ console.print(f"[yellow]âš  Could not load {p}: {e}[/yellow]")
71
+ return None
72
+
73
+ @staticmethod
74
+ def _merge(base: dict, override: dict):
75
+ for k, v in override.items():
76
+ if isinstance(v, dict) and isinstance(base.get(k), dict):
77
+ ConfigManager._merge(base[k], v)
78
+ else:
79
+ base[k] = v
80
+
81
+ # ------------------------------------------------------------------ #
82
+ # Save #
83
+ # ------------------------------------------------------------------ #
84
+ def _save_global(self, cfg: dict):
85
+ GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
86
+ GLOBAL_CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
87
+
88
+ # ------------------------------------------------------------------ #
89
+ # Interactive AI setup #
90
+ # ------------------------------------------------------------------ #
91
+ def ensure_ai_provider(
92
+ self,
93
+ cfg: dict,
94
+ force_provider: str | None = None,
95
+ *,
96
+ prompt_each_run: bool = False,
97
+ save: bool = True,
98
+ ) -> dict:
99
+ """Make sure an AI cloud provider + API key is configured."""
100
+ # Already have everything?
101
+ if cfg.get("provider") and cfg.get("api_key") and not force_provider and not prompt_each_run:
102
+ console.print(
103
+ f"[dim]🔑 Using saved provider: [bold]{cfg['provider']}[/bold][/dim]"
104
+ )
105
+ return cfg
106
+
107
+ # Choose provider
108
+ if force_provider:
109
+ provider = force_provider
110
+ else:
111
+ provider = self._prompt_provider()
112
+
113
+ # Ask for API key
114
+ existing_key = "" if prompt_each_run else cfg.get("api_key", "")
115
+ if existing_key:
116
+ mask = existing_key[:8] + "..."
117
+ use_saved = Confirm.ask(
118
+ f"\n[cyan]Use saved API key[/cyan] [dim]({mask})[/dim]?",
119
+ default=True,
120
+ )
121
+ if not use_saved:
122
+ existing_key = ""
123
+
124
+ if not existing_key:
125
+ key_label = {
126
+ "openai": "OpenAI API key (sk-...)",
127
+ "claude": "Anthropic API key (sk-ant-...)",
128
+ "gemini": "Google Gemini API key",
129
+ }.get(provider, "API key")
130
+ api_key = Prompt.ask(f"\n[cyan]Enter your {key_label}[/cyan]", password=True)
131
+ if not api_key.strip():
132
+ console.print("[red]No API key provided. Exiting.[/red]")
133
+ sys.exit(1)
134
+ existing_key = api_key.strip()
135
+
136
+ cfg["provider"] = provider
137
+ cfg["api_key"] = existing_key
138
+ if save:
139
+ self._save_global(cfg)
140
+ console.print(f"\n[green]✅ Provider saved:[/green] {provider}\n")
141
+ else:
142
+ console.print(f"\n[green]✅ Provider selected for this run:[/green] {provider}\n")
143
+ return cfg
144
+
145
+ def _prompt_provider(self) -> str:
146
+ console.print(
147
+ Panel(
148
+ "\n".join(f" [bold]{k}[/bold]. {label}" for k, (_, label) in PROVIDERS.items()),
149
+ title="[bold cyan]Choose AI Provider[/bold cyan]",
150
+ border_style="cyan",
151
+ padding=(1, 2),
152
+ )
153
+ )
154
+ choice = Prompt.ask(
155
+ "Select", choices=list(PROVIDERS.keys()), default="1"
156
+ )
157
+ provider, label = PROVIDERS[choice]
158
+ console.print(f"[green]✔[/green] {label}")
159
+ return provider