|
1707c67…
|
noreply
|
1 |
"""Interactive setup wizard for PlanOpticon.""" |
|
1707c67…
|
noreply
|
2 |
|
|
1707c67…
|
noreply
|
3 |
import os |
|
1707c67…
|
noreply
|
4 |
import shutil |
|
1707c67…
|
noreply
|
5 |
from pathlib import Path |
|
1707c67…
|
noreply
|
6 |
from typing import Dict, Optional, Tuple |
|
1707c67…
|
noreply
|
7 |
|
|
1707c67…
|
noreply
|
8 |
import click |
|
1707c67…
|
noreply
|
9 |
|
|
1707c67…
|
noreply
|
10 |
PROVIDERS = [ |
|
1707c67…
|
noreply
|
11 |
("openai", "OpenAI", "OPENAI_API_KEY"), |
|
1707c67…
|
noreply
|
12 |
("anthropic", "Anthropic", "ANTHROPIC_API_KEY"), |
|
1707c67…
|
noreply
|
13 |
("gemini", "Google Gemini", "GEMINI_API_KEY"), |
|
1707c67…
|
noreply
|
14 |
("ollama", "Ollama (local)", None), |
|
1707c67…
|
noreply
|
15 |
("azure", "Azure OpenAI", "AZURE_OPENAI_API_KEY"), |
|
1707c67…
|
noreply
|
16 |
("together", "Together AI", "TOGETHER_API_KEY"), |
|
1707c67…
|
noreply
|
17 |
("fireworks", "Fireworks AI", "FIREWORKS_API_KEY"), |
|
1707c67…
|
noreply
|
18 |
("cerebras", "Cerebras", "CEREBRAS_API_KEY"), |
|
1707c67…
|
noreply
|
19 |
("xai", "xAI", "XAI_API_KEY"), |
|
1707c67…
|
noreply
|
20 |
] |
|
1707c67…
|
noreply
|
21 |
|
|
1707c67…
|
noreply
|
22 |
|
|
1707c67…
|
noreply
|
23 |
def _check_ffmpeg() -> bool: |
|
1707c67…
|
noreply
|
24 |
return shutil.which("ffmpeg") is not None |
|
1707c67…
|
noreply
|
25 |
|
|
1707c67…
|
noreply
|
26 |
|
|
1707c67…
|
noreply
|
27 |
def _test_provider(provider_id: str, api_key: Optional[str] = None) -> Tuple[bool, str]: |
|
1707c67…
|
noreply
|
28 |
"""Test that a provider connection works.""" |
|
1707c67…
|
noreply
|
29 |
if provider_id == "ollama": |
|
1707c67…
|
noreply
|
30 |
try: |
|
1707c67…
|
noreply
|
31 |
import subprocess |
|
1707c67…
|
noreply
|
32 |
|
|
1707c67…
|
noreply
|
33 |
result = subprocess.run( |
|
1707c67…
|
noreply
|
34 |
["ollama", "list"], |
|
1707c67…
|
noreply
|
35 |
capture_output=True, |
|
1707c67…
|
noreply
|
36 |
text=True, |
|
1707c67…
|
noreply
|
37 |
timeout=5, |
|
1707c67…
|
noreply
|
38 |
) |
|
1707c67…
|
noreply
|
39 |
if result.returncode == 0: |
|
1707c67…
|
noreply
|
40 |
return True, "Ollama is running" |
|
1707c67…
|
noreply
|
41 |
return False, "Ollama is installed but not running. Start with: ollama serve" |
|
1707c67…
|
noreply
|
42 |
except FileNotFoundError: |
|
1707c67…
|
noreply
|
43 |
return False, "Ollama not found. Install from: https://ollama.ai" |
|
1707c67…
|
noreply
|
44 |
except Exception as e: |
|
1707c67…
|
noreply
|
45 |
return False, f"Could not reach Ollama: {e}" |
|
1707c67…
|
noreply
|
46 |
|
|
1707c67…
|
noreply
|
47 |
if not api_key: |
|
1707c67…
|
noreply
|
48 |
return False, "No API key provided" |
|
1707c67…
|
noreply
|
49 |
|
|
1707c67…
|
noreply
|
50 |
# For API-based providers, just check the key looks valid |
|
1707c67…
|
noreply
|
51 |
if len(api_key) < 8: |
|
1707c67…
|
noreply
|
52 |
return False, "API key looks too short" |
|
1707c67…
|
noreply
|
53 |
return True, "API key configured" |
|
1707c67…
|
noreply
|
54 |
|
|
1707c67…
|
noreply
|
55 |
|
|
1707c67…
|
noreply
|
56 |
def run_wizard() -> None: |
|
1707c67…
|
noreply
|
57 |
"""Run the interactive setup wizard.""" |
|
1707c67…
|
noreply
|
58 |
click.echo() |
|
1707c67…
|
noreply
|
59 |
click.echo(" PlanOpticon Setup Wizard") |
|
1707c67…
|
noreply
|
60 |
click.echo(" " + "-" * 30) |
|
1707c67…
|
noreply
|
61 |
click.echo() |
|
1707c67…
|
noreply
|
62 |
|
|
1707c67…
|
noreply
|
63 |
# Step 1: Check prerequisites |
|
1707c67…
|
noreply
|
64 |
click.echo("Checking prerequisites...") |
|
1707c67…
|
noreply
|
65 |
click.echo() |
|
1707c67…
|
noreply
|
66 |
|
|
1707c67…
|
noreply
|
67 |
if _check_ffmpeg(): |
|
1707c67…
|
noreply
|
68 |
click.echo(" [ok] FFmpeg found") |
|
1707c67…
|
noreply
|
69 |
else: |
|
1707c67…
|
noreply
|
70 |
click.echo(" [!!] FFmpeg not found") |
|
1707c67…
|
noreply
|
71 |
click.echo(" Install: brew install ffmpeg (macOS)") |
|
1707c67…
|
noreply
|
72 |
click.echo(" apt install ffmpeg (Ubuntu)") |
|
1707c67…
|
noreply
|
73 |
click.echo(" winget install ffmpeg (Windows)") |
|
1707c67…
|
noreply
|
74 |
click.echo() |
|
1707c67…
|
noreply
|
75 |
|
|
1707c67…
|
noreply
|
76 |
# Step 2: Choose provider |
|
1707c67…
|
noreply
|
77 |
click.echo() |
|
1707c67…
|
noreply
|
78 |
click.echo("Choose your AI provider:") |
|
1707c67…
|
noreply
|
79 |
click.echo() |
|
1707c67…
|
noreply
|
80 |
for i, (pid, name, _) in enumerate(PROVIDERS, 1): |
|
1707c67…
|
noreply
|
81 |
# Check if already configured |
|
1707c67…
|
noreply
|
82 |
env_key = PROVIDERS[i - 1][2] |
|
1707c67…
|
noreply
|
83 |
status = "" |
|
1707c67…
|
noreply
|
84 |
if pid == "ollama": |
|
1707c67…
|
noreply
|
85 |
if shutil.which("ollama"): |
|
1707c67…
|
noreply
|
86 |
status = " (installed)" |
|
1707c67…
|
noreply
|
87 |
elif env_key and os.environ.get(env_key): |
|
1707c67…
|
noreply
|
88 |
status = " (configured)" |
|
1707c67…
|
noreply
|
89 |
click.echo(f" {i}. {name}{status}") |
|
1707c67…
|
noreply
|
90 |
click.echo() |
|
1707c67…
|
noreply
|
91 |
|
|
1707c67…
|
noreply
|
92 |
choice = click.prompt( |
|
1707c67…
|
noreply
|
93 |
"Select provider", |
|
1707c67…
|
noreply
|
94 |
type=click.IntRange(1, len(PROVIDERS)), |
|
1707c67…
|
noreply
|
95 |
default=1, |
|
1707c67…
|
noreply
|
96 |
) |
|
1707c67…
|
noreply
|
97 |
provider_id, provider_name, env_var = PROVIDERS[choice - 1] |
|
1707c67…
|
noreply
|
98 |
|
|
1707c67…
|
noreply
|
99 |
# Step 3: Configure API key |
|
1707c67…
|
noreply
|
100 |
env_vars: Dict[str, str] = {} |
|
1707c67…
|
noreply
|
101 |
|
|
1707c67…
|
noreply
|
102 |
if provider_id == "ollama": |
|
1707c67…
|
noreply
|
103 |
click.echo() |
|
1707c67…
|
noreply
|
104 |
ok, msg = _test_provider("ollama") |
|
1707c67…
|
noreply
|
105 |
if ok: |
|
1707c67…
|
noreply
|
106 |
click.echo(f" [ok] {msg}") |
|
1707c67…
|
noreply
|
107 |
else: |
|
1707c67…
|
noreply
|
108 |
click.echo(f" [!!] {msg}") |
|
1707c67…
|
noreply
|
109 |
elif env_var: |
|
1707c67…
|
noreply
|
110 |
existing = os.environ.get(env_var, "") |
|
1707c67…
|
noreply
|
111 |
if existing: |
|
1707c67…
|
noreply
|
112 |
click.echo(f"\n {env_var} is already set.") |
|
1707c67…
|
noreply
|
113 |
if not click.confirm(" Update it?", default=False): |
|
1707c67…
|
noreply
|
114 |
env_vars[env_var] = existing |
|
1707c67…
|
noreply
|
115 |
else: |
|
1707c67…
|
noreply
|
116 |
key = click.prompt(f" Enter {env_var}", hide_input=True) |
|
1707c67…
|
noreply
|
117 |
env_vars[env_var] = key |
|
1707c67…
|
noreply
|
118 |
else: |
|
1707c67…
|
noreply
|
119 |
click.echo(f"\n {provider_name} requires {env_var}.") |
|
1707c67…
|
noreply
|
120 |
key = click.prompt(f" Enter {env_var}", hide_input=True) |
|
1707c67…
|
noreply
|
121 |
env_vars[env_var] = key |
|
1707c67…
|
noreply
|
122 |
|
|
1707c67…
|
noreply
|
123 |
if env_var in env_vars: |
|
1707c67…
|
noreply
|
124 |
ok, msg = _test_provider(provider_id, env_vars[env_var]) |
|
1707c67…
|
noreply
|
125 |
if ok: |
|
1707c67…
|
noreply
|
126 |
click.echo(f" [ok] {msg}") |
|
1707c67…
|
noreply
|
127 |
else: |
|
1707c67…
|
noreply
|
128 |
click.echo(f" [!!] {msg}") |
|
1707c67…
|
noreply
|
129 |
|
|
1707c67…
|
noreply
|
130 |
# Step 4: Additional providers? |
|
1707c67…
|
noreply
|
131 |
click.echo() |
|
1707c67…
|
noreply
|
132 |
if click.confirm("Configure additional providers?", default=False): |
|
1707c67…
|
noreply
|
133 |
for pid, pname, evar in PROVIDERS: |
|
1707c67…
|
noreply
|
134 |
if pid == provider_id or not evar: |
|
1707c67…
|
noreply
|
135 |
continue |
|
1707c67…
|
noreply
|
136 |
if os.environ.get(evar): |
|
1707c67…
|
noreply
|
137 |
continue |
|
1707c67…
|
noreply
|
138 |
if click.confirm(f" Set up {pname}?", default=False): |
|
1707c67…
|
noreply
|
139 |
key = click.prompt(f" Enter {evar}", hide_input=True) |
|
1707c67…
|
noreply
|
140 |
env_vars[evar] = key |
|
1707c67…
|
noreply
|
141 |
|
|
1707c67…
|
noreply
|
142 |
# Step 5: Write .env file |
|
1707c67…
|
noreply
|
143 |
env_path = Path.cwd() / ".env" |
|
1707c67…
|
noreply
|
144 |
if env_vars: |
|
1707c67…
|
noreply
|
145 |
click.echo() |
|
1707c67…
|
noreply
|
146 |
|
|
1707c67…
|
noreply
|
147 |
if env_path.exists(): |
|
1707c67…
|
noreply
|
148 |
click.echo(f" .env already exists at {env_path}") |
|
1707c67…
|
noreply
|
149 |
if not click.confirm(" Append new keys?", default=True): |
|
1707c67…
|
noreply
|
150 |
click.echo(" Skipping .env update.") |
|
1707c67…
|
noreply
|
151 |
_print_summary(provider_name, env_vars) |
|
1707c67…
|
noreply
|
152 |
return |
|
1707c67…
|
noreply
|
153 |
|
|
1707c67…
|
noreply
|
154 |
# Read existing content |
|
1707c67…
|
noreply
|
155 |
existing_content = env_path.read_text() if env_path.exists() else "" |
|
1707c67…
|
noreply
|
156 |
existing_keys = set() |
|
1707c67…
|
noreply
|
157 |
for line in existing_content.split("\n"): |
|
1707c67…
|
noreply
|
158 |
if "=" in line and not line.strip().startswith("#"): |
|
1707c67…
|
noreply
|
159 |
existing_keys.add(line.split("=", 1)[0].strip()) |
|
1707c67…
|
noreply
|
160 |
|
|
1707c67…
|
noreply
|
161 |
new_lines = [] |
|
1707c67…
|
noreply
|
162 |
for key, val in env_vars.items(): |
|
1707c67…
|
noreply
|
163 |
if key not in existing_keys: |
|
1707c67…
|
noreply
|
164 |
new_lines.append(f"{key}={val}") |
|
1707c67…
|
noreply
|
165 |
|
|
1707c67…
|
noreply
|
166 |
if new_lines: |
|
1707c67…
|
noreply
|
167 |
with open(env_path, "a") as f: |
|
1707c67…
|
noreply
|
168 |
if existing_content and not existing_content.endswith("\n"): |
|
1707c67…
|
noreply
|
169 |
f.write("\n") |
|
1707c67…
|
noreply
|
170 |
f.write("\n".join(new_lines) + "\n") |
|
1707c67…
|
noreply
|
171 |
click.echo(f" Updated {env_path} with {len(new_lines)} key(s)") |
|
1707c67…
|
noreply
|
172 |
else: |
|
1707c67…
|
noreply
|
173 |
click.echo(" All keys already in .env") |
|
1707c67…
|
noreply
|
174 |
|
|
1707c67…
|
noreply
|
175 |
# Remind about .gitignore |
|
1707c67…
|
noreply
|
176 |
gitignore = Path.cwd() / ".gitignore" |
|
1707c67…
|
noreply
|
177 |
if gitignore.exists(): |
|
1707c67…
|
noreply
|
178 |
content = gitignore.read_text() |
|
1707c67…
|
noreply
|
179 |
if ".env" not in content: |
|
1707c67…
|
noreply
|
180 |
click.echo(" [!!] .env is not in .gitignore — consider adding it") |
|
1707c67…
|
noreply
|
181 |
else: |
|
1707c67…
|
noreply
|
182 |
click.echo(" [!!] No .gitignore found — make sure .env is not committed") |
|
1707c67…
|
noreply
|
183 |
|
|
1707c67…
|
noreply
|
184 |
_print_summary(provider_name, env_vars) |
|
1707c67…
|
noreply
|
185 |
|
|
1707c67…
|
noreply
|
186 |
|
|
1707c67…
|
noreply
|
187 |
def _print_summary(provider_name: str, env_vars: Dict[str, str]) -> None: |
|
1707c67…
|
noreply
|
188 |
"""Print setup summary.""" |
|
1707c67…
|
noreply
|
189 |
click.echo() |
|
1707c67…
|
noreply
|
190 |
click.echo(" Setup complete!") |
|
1707c67…
|
noreply
|
191 |
click.echo() |
|
1707c67…
|
noreply
|
192 |
click.echo(f" Provider: {provider_name}") |
|
1707c67…
|
noreply
|
193 |
if env_vars: |
|
1707c67…
|
noreply
|
194 |
click.echo(f" Keys configured: {len(env_vars)}") |
|
1707c67…
|
noreply
|
195 |
click.echo() |
|
1707c67…
|
noreply
|
196 |
click.echo(" Next steps:") |
|
1707c67…
|
noreply
|
197 |
click.echo(" planopticon doctor Check setup health") |
|
1707c67…
|
noreply
|
198 |
click.echo(" planopticon analyze -i VIDEO -o OUTPUT") |
|
1707c67…
|
noreply
|
199 |
click.echo(" planopticon -I Interactive mode") |
|
1707c67…
|
noreply
|
200 |
click.echo() |