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 +1 -0
- ghostfix/ai/__init__.py +4 -0
- ghostfix/ai/base.py +8 -0
- ghostfix/ai/claude_provider.py +34 -0
- ghostfix/ai/custom_provider.py +93 -0
- ghostfix/ai/gemini_provider.py +33 -0
- ghostfix/ai/openai_provider.py +34 -0
- ghostfix/ai/router.py +127 -0
- ghostfix/cli.py +181 -0
- ghostfix/config/__init__.py +2 -0
- ghostfix/config/manager.py +159 -0
- ghostfix/core/__init__.py +5 -0
- ghostfix/core/context_builder.py +129 -0
- ghostfix/core/error_parser.py +144 -0
- ghostfix/core/patcher.py +164 -0
- ghostfix/core/watcher.py +186 -0
- ghostfix/parsers/__init__.py +2 -0
- ghostfix/ui/__init__.py +2 -0
- ghostfix/ui/renderer.py +129 -0
- ghostfix-0.1.0.dist-info/METADATA +222 -0
- ghostfix-0.1.0.dist-info/RECORD +25 -0
- ghostfix-0.1.0.dist-info/WHEEL +5 -0
- ghostfix-0.1.0.dist-info/entry_points.txt +2 -0
- ghostfix-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghostfix-0.1.0.dist-info/top_level.txt +1 -0
ghostfix/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ghostfix/ai/__init__.py
ADDED
ghostfix/ai/base.py
ADDED
|
@@ -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,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
|