PlanOpticon

feat(cli): add init wizard, doctor diagnostics, and tab completion Add `planopticon init` interactive setup wizard for provider selection and API key configuration. Add `planopticon doctor` for diagnosing setup issues. Add tab completion and history to the companion REPL.

lmata 2026-03-08 01:04 trunk
Commit c5a05bb26dd73836eae57cf47084d4ad7a9320d7e47983984fc517fa062765e0
--- a/tests/test_cli_ux.py
+++ b/tests/test_cli_ux.py
@@ -0,0 +1,161 @@
1
+"""Tests for CLI UX improvements — doctor, init wizard, and tab completion."""
2
+
3
+import os
4
+from unittest.mock import MagicMock, patch
5
+
6
+from click.testing import CliRunner
7
+
8
+from video_processor.cli.commands import cli
9
+from video_processor.cli.companion import CompanionREPL
10
+from video_processor.cli.doctor import (
11
+ check_api_keys,
12
+ check_dotenv,
13
+ check_ffmpeg,
14
+ check_optional_deps,
15
+ check_python_version,
16
+ format_results,
17
+ run_all_checks,
18
+)
19
+
20
+
21
+class TestDoctor:
22
+ def test_check_python_version(self):
23
+ name, status, detail = check_python_version()
24
+ assert name == "Python"
25
+ assert status == "ok"
26
+
27
+ def test_check_ffmpeg_found(self):
28
+ with patch("video_processor.cli.doctor.shutil") as mock_shutil:
29
+ mock_shutil.which.return_value = "/usr/bin/ffmpeg"
30
+ name, status, detail = check_ffmpeg()
31
+ assert status == "ok"
32
+
33
+ def test_check_ffmpeg_missing(self):
34
+ with patch("video_processor.cli.doctor.shutil") as mock_shutil:
35
+ mock_shutil.which.return_value = None
36
+ name, status, detail = check_ffmpeg()
37
+ assert status == "missing"
38
+
39
+ def test_check_api_keys_with_key(self):
40
+ with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test1234567890"}):
41
+ results = check_api_keys()
42
+ openai = [r for r in results if r[0].strip() == "OpenAI"]
43
+ assert len(openai) == 1
44
+ assert openai[0][1] == "ok"
45
+ assert "sk-t" in openai[0][2]
46
+
47
+ def test_check_api_keys_without_key(self):
48
+ with patch.dict(os.environ, {}, clear=True):
49
+ results = check_api_keys()
50
+ openai = [r for r in results if "OpenAI" in r[0]]
51
+ assert openai[0][1] == "not set"
52
+
53
+ def test_check_dotenv_exists(self, tmp_path, monkeypatch):
54
+ monkeypatch.chdir(tmp_path)
55
+ (tmp_path / ".env").write_text("KEY=val\n")
56
+ name, status, detail = check_dotenv()
57
+ assert status == "ok"
58
+
59
+ def test_check_dotenv_missing(self, tmp_path, monkeypatch):
60
+ monkeypatch.chdir(tmp_path)
61
+ name, status, detail = check_dotenv()
62
+ assert status == "not found"
63
+
64
+ def test_check_optional_deps(self):
65
+ results = check_optional_deps()
66
+ assert len(results) > 0
67
+ # All results should have 3 elements
68
+ for name, status, detail in results:
69
+ assert status in ("ok", "not installed")
70
+
71
+ def test_format_results(self):
72
+ results = [
73
+ ("Python", "ok", "3.12.0"),
74
+ ("FFmpeg", "missing", "Install it"),
75
+ ]
76
+ output = format_results(results)
77
+ assert "PlanOpticon Doctor" in output
78
+ assert "[ok]" in output
79
+ assert "[XX]" in output
80
+
81
+ def test_run_all_checks(self):
82
+ with patch(
83
+ "video_processor.integrators.graph_discovery.find_nearest_graph",
84
+ return_value=None,
85
+ ):
86
+ results = run_all_checks()
87
+ assert len(results) > 5
88
+ # Should have section headers
89
+ sections = [r for r in results if r[1] == "section"]
90
+ assert len(sections) >= 2
91
+
92
+ def test_doctor_cli_command(self):
93
+ runner = CliRunner()
94
+ with patch(
95
+ "video_processor.integrators.graph_discovery.find_nearest_graph",
96
+ return_value=None,
97
+ ):
98
+ result = runner.invoke(cli, ["doctor"])
99
+ assert result.exit_code == 0
100
+ assert "PlanOpticon Doctor" in result.output
101
+
102
+
103
+class TestInitWizard:
104
+ def test_init_cli_command_exists(self):
105
+ runner = CliRunner()
106
+ result = runner.invoke(cli, ["init", "--help"])
107
+ assert result.exit_code == 0
108
+ assert "setup wizard" in result.output.lower() or "wizard" in result.output.lower()
109
+
110
+ def test_wizard_provider_selection(self):
111
+ """Test the wizard runs with simulated input."""
112
+ runner = CliRunner()
113
+ # Select provider 1 (OpenAI), enter a key, decline additional providers
114
+ result = runner.invoke(
115
+ cli,
116
+ ["init"],
117
+ input="1\nsk-test-key-1234567890\nn\n",
118
+ )
119
+ assert result.exit_code == 0
120
+ assert "Setup complete" in result.output
121
+
122
+ def test_wizard_ollama_provider(self):
123
+ """Test selecting Ollama (no API key needed)."""
124
+ runner = CliRunner()
125
+ with patch(
126
+ "video_processor.cli.init_wizard.shutil.which",
127
+ return_value="/usr/local/bin/ollama",
128
+ ):
129
+ with patch("subprocess.run") as mock_run:
130
+ mock_run.return_value = MagicMock(returncode=0, stdout="NAME\nllama3\n")
131
+ result = runner.invoke(
132
+ cli,
133
+ ["init"],
134
+ input="4\nn\n",
135
+ )
136
+ assert result.exit_code == 0
137
+ assert "Setup complete" in result.output
138
+
139
+
140
+class TestCompanionTabCompletion:
141
+ def test_commands_list_exists(self):
142
+ assert len(CompanionREPL.COMMANDS) > 10
143
+ assert "/help" in CompanionREPL.COMMANDS
144
+ assert "/quit" in CompanionREPL.COMMANDS
145
+
146
+ def test_setup_readline_no_crash(self):
147
+ """Readline setup should not crash even if readline is unavailable."""
148
+ repl = CompanionREPL()
149
+ # Just ensure it doesn't raise
150
+ repl._setup_readline()
151
+
152
+ def test_all_commands_in_dispatch(self):
153
+ """Every command in COMMANDS should be handled by handle_input."""
154
+ repl = CompanionREPL()
155
+ for cmd in CompanionREPL.COMMANDS:
156
+ if cmd in ("/quit", "/exit"):
157
+ result = repl.handle_input(cmd)
158
+ assert result == "__QUIT__"
159
+ else:
160
+ result = repl.handle_input(cmd)
161
+ assert "Unknown command" not in result, f"{cmd} not handled"
--- a/tests/test_cli_ux.py
+++ b/tests/test_cli_ux.py
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_cli_ux.py
+++ b/tests/test_cli_ux.py
@@ -0,0 +1,161 @@
1 """Tests for CLI UX improvements — doctor, init wizard, and tab completion."""
2
3 import os
4 from unittest.mock import MagicMock, patch
5
6 from click.testing import CliRunner
7
8 from video_processor.cli.commands import cli
9 from video_processor.cli.companion import CompanionREPL
10 from video_processor.cli.doctor import (
11 check_api_keys,
12 check_dotenv,
13 check_ffmpeg,
14 check_optional_deps,
15 check_python_version,
16 format_results,
17 run_all_checks,
18 )
19
20
21 class TestDoctor:
22 def test_check_python_version(self):
23 name, status, detail = check_python_version()
24 assert name == "Python"
25 assert status == "ok"
26
27 def test_check_ffmpeg_found(self):
28 with patch("video_processor.cli.doctor.shutil") as mock_shutil:
29 mock_shutil.which.return_value = "/usr/bin/ffmpeg"
30 name, status, detail = check_ffmpeg()
31 assert status == "ok"
32
33 def test_check_ffmpeg_missing(self):
34 with patch("video_processor.cli.doctor.shutil") as mock_shutil:
35 mock_shutil.which.return_value = None
36 name, status, detail = check_ffmpeg()
37 assert status == "missing"
38
39 def test_check_api_keys_with_key(self):
40 with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test1234567890"}):
41 results = check_api_keys()
42 openai = [r for r in results if r[0].strip() == "OpenAI"]
43 assert len(openai) == 1
44 assert openai[0][1] == "ok"
45 assert "sk-t" in openai[0][2]
46
47 def test_check_api_keys_without_key(self):
48 with patch.dict(os.environ, {}, clear=True):
49 results = check_api_keys()
50 openai = [r for r in results if "OpenAI" in r[0]]
51 assert openai[0][1] == "not set"
52
53 def test_check_dotenv_exists(self, tmp_path, monkeypatch):
54 monkeypatch.chdir(tmp_path)
55 (tmp_path / ".env").write_text("KEY=val\n")
56 name, status, detail = check_dotenv()
57 assert status == "ok"
58
59 def test_check_dotenv_missing(self, tmp_path, monkeypatch):
60 monkeypatch.chdir(tmp_path)
61 name, status, detail = check_dotenv()
62 assert status == "not found"
63
64 def test_check_optional_deps(self):
65 results = check_optional_deps()
66 assert len(results) > 0
67 # All results should have 3 elements
68 for name, status, detail in results:
69 assert status in ("ok", "not installed")
70
71 def test_format_results(self):
72 results = [
73 ("Python", "ok", "3.12.0"),
74 ("FFmpeg", "missing", "Install it"),
75 ]
76 output = format_results(results)
77 assert "PlanOpticon Doctor" in output
78 assert "[ok]" in output
79 assert "[XX]" in output
80
81 def test_run_all_checks(self):
82 with patch(
83 "video_processor.integrators.graph_discovery.find_nearest_graph",
84 return_value=None,
85 ):
86 results = run_all_checks()
87 assert len(results) > 5
88 # Should have section headers
89 sections = [r for r in results if r[1] == "section"]
90 assert len(sections) >= 2
91
92 def test_doctor_cli_command(self):
93 runner = CliRunner()
94 with patch(
95 "video_processor.integrators.graph_discovery.find_nearest_graph",
96 return_value=None,
97 ):
98 result = runner.invoke(cli, ["doctor"])
99 assert result.exit_code == 0
100 assert "PlanOpticon Doctor" in result.output
101
102
103 class TestInitWizard:
104 def test_init_cli_command_exists(self):
105 runner = CliRunner()
106 result = runner.invoke(cli, ["init", "--help"])
107 assert result.exit_code == 0
108 assert "setup wizard" in result.output.lower() or "wizard" in result.output.lower()
109
110 def test_wizard_provider_selection(self):
111 """Test the wizard runs with simulated input."""
112 runner = CliRunner()
113 # Select provider 1 (OpenAI), enter a key, decline additional providers
114 result = runner.invoke(
115 cli,
116 ["init"],
117 input="1\nsk-test-key-1234567890\nn\n",
118 )
119 assert result.exit_code == 0
120 assert "Setup complete" in result.output
121
122 def test_wizard_ollama_provider(self):
123 """Test selecting Ollama (no API key needed)."""
124 runner = CliRunner()
125 with patch(
126 "video_processor.cli.init_wizard.shutil.which",
127 return_value="/usr/local/bin/ollama",
128 ):
129 with patch("subprocess.run") as mock_run:
130 mock_run.return_value = MagicMock(returncode=0, stdout="NAME\nllama3\n")
131 result = runner.invoke(
132 cli,
133 ["init"],
134 input="4\nn\n",
135 )
136 assert result.exit_code == 0
137 assert "Setup complete" in result.output
138
139
140 class TestCompanionTabCompletion:
141 def test_commands_list_exists(self):
142 assert len(CompanionREPL.COMMANDS) > 10
143 assert "/help" in CompanionREPL.COMMANDS
144 assert "/quit" in CompanionREPL.COMMANDS
145
146 def test_setup_readline_no_crash(self):
147 """Readline setup should not crash even if readline is unavailable."""
148 repl = CompanionREPL()
149 # Just ensure it doesn't raise
150 repl._setup_readline()
151
152 def test_all_commands_in_dispatch(self):
153 """Every command in COMMANDS should be handled by handle_input."""
154 repl = CompanionREPL()
155 for cmd in CompanionREPL.COMMANDS:
156 if cmd in ("/quit", "/exit"):
157 result = repl.handle_input(cmd)
158 assert result == "__QUIT__"
159 else:
160 result = repl.handle_input(cmd)
161 assert "Unknown command" not in result, f"{cmd} not handled"
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -64,10 +64,29 @@
6464
repl.run()
6565
ctx.exit(0)
6666
elif ctx.invoked_subcommand is None:
6767
_interactive_menu(ctx)
6868
69
+
70
+@cli.command("init")
71
+@click.pass_context
72
+def init_cmd(ctx):
73
+ """Interactive setup wizard — configure providers, API keys, and .env."""
74
+ from video_processor.cli.init_wizard import run_wizard
75
+
76
+ run_wizard()
77
+
78
+
79
+@cli.command()
80
+@click.pass_context
81
+def doctor(ctx):
82
+ """Check setup health — Python, FFmpeg, API keys, dependencies."""
83
+ from video_processor.cli.doctor import format_results, run_all_checks
84
+
85
+ results = run_all_checks()
86
+ click.echo(format_results(results))
87
+
6988
7089
@cli.command()
7190
@click.option(
7291
"--input", "-i", required=True, type=click.Path(exists=True), help="Input video file path"
7392
)
7493
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -64,10 +64,29 @@
64 repl.run()
65 ctx.exit(0)
66 elif ctx.invoked_subcommand is None:
67 _interactive_menu(ctx)
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
70 @cli.command()
71 @click.option(
72 "--input", "-i", required=True, type=click.Path(exists=True), help="Input video file path"
73 )
74
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -64,10 +64,29 @@
64 repl.run()
65 ctx.exit(0)
66 elif ctx.invoked_subcommand is None:
67 _interactive_menu(ctx)
68
69
70 @cli.command("init")
71 @click.pass_context
72 def init_cmd(ctx):
73 """Interactive setup wizard — configure providers, API keys, and .env."""
74 from video_processor.cli.init_wizard import run_wizard
75
76 run_wizard()
77
78
79 @cli.command()
80 @click.pass_context
81 def doctor(ctx):
82 """Check setup health — Python, FFmpeg, API keys, dependencies."""
83 from video_processor.cli.doctor import format_results, run_all_checks
84
85 results = run_all_checks()
86 click.echo(format_results(results))
87
88
89 @cli.command()
90 @click.option(
91 "--input", "-i", required=True, type=click.Path(exists=True), help="Input video file path"
92 )
93
--- video_processor/cli/companion.py
+++ video_processor/cli/companion.py
@@ -442,16 +442,84 @@
442442
return self._cmd_run_skill("prd")
443443
if cmd == "/tasks":
444444
return self._cmd_run_skill("task_breakdown")
445445
446446
return f"Unknown command: {cmd}. Type /help for help."
447
+
448
+ COMMANDS = [
449
+ "/help",
450
+ "/status",
451
+ "/skills",
452
+ "/entities",
453
+ "/search",
454
+ "/neighbors",
455
+ "/export",
456
+ "/analyze",
457
+ "/ingest",
458
+ "/auth",
459
+ "/provider",
460
+ "/model",
461
+ "/run",
462
+ "/plan",
463
+ "/prd",
464
+ "/tasks",
465
+ "/quit",
466
+ "/exit",
467
+ ]
468
+
469
+ def _setup_readline(self) -> None:
470
+ """Set up readline for tab completion and history."""
471
+ try:
472
+ import readline
473
+ except ImportError:
474
+ return
475
+
476
+ commands = self.COMMANDS
477
+
478
+ def completer(text, state):
479
+ if text.startswith("/"):
480
+ matches = [c for c in commands if c.startswith(text)]
481
+ else:
482
+ matches = [c for c in commands if c.startswith("/" + text)]
483
+ matches = [m[1:] for m in matches] # strip leading /
484
+ if state < len(matches):
485
+ return matches[state]
486
+ return None
487
+
488
+ readline.set_completer(completer)
489
+ readline.set_completer_delims(" \t\n")
490
+ # macOS uses libedit which needs a different syntax
491
+ if "libedit" in readline.__doc__:
492
+ readline.parse_and_bind("bind ^I rl_complete")
493
+ else:
494
+ readline.parse_and_bind("tab: complete")
495
+
496
+ # Load history
497
+ history_path = Path.home() / ".planopticon_history"
498
+ try:
499
+ if history_path.exists():
500
+ readline.read_history_file(str(history_path))
501
+ except Exception:
502
+ pass
503
+
504
+ self._history_path = history_path
505
+
506
+ def _save_history(self) -> None:
507
+ """Save readline history."""
508
+ try:
509
+ import readline
510
+
511
+ readline.write_history_file(str(self._history_path))
512
+ except Exception:
513
+ pass
447514
448515
def run(self) -> None:
449516
"""Main REPL loop."""
450517
self._discover()
451518
self._init_provider()
452519
self._init_agent()
520
+ self._setup_readline()
453521
454522
print(self._welcome_banner())
455523
456524
while True:
457525
try:
@@ -464,5 +532,7 @@
464532
if output == "__QUIT__":
465533
print("Bye.")
466534
break
467535
if output:
468536
print(output)
537
+
538
+ self._save_history()
469539
470540
ADDED video_processor/cli/doctor.py
471541
ADDED video_processor/cli/init_wizard.py
--- video_processor/cli/companion.py
+++ video_processor/cli/companion.py
@@ -442,16 +442,84 @@
442 return self._cmd_run_skill("prd")
443 if cmd == "/tasks":
444 return self._cmd_run_skill("task_breakdown")
445
446 return f"Unknown command: {cmd}. Type /help for help."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
448 def run(self) -> None:
449 """Main REPL loop."""
450 self._discover()
451 self._init_provider()
452 self._init_agent()
 
453
454 print(self._welcome_banner())
455
456 while True:
457 try:
@@ -464,5 +532,7 @@
464 if output == "__QUIT__":
465 print("Bye.")
466 break
467 if output:
468 print(output)
 
 
469
470 DDED video_processor/cli/doctor.py
471 DDED video_processor/cli/init_wizard.py
--- video_processor/cli/companion.py
+++ video_processor/cli/companion.py
@@ -442,16 +442,84 @@
442 return self._cmd_run_skill("prd")
443 if cmd == "/tasks":
444 return self._cmd_run_skill("task_breakdown")
445
446 return f"Unknown command: {cmd}. Type /help for help."
447
448 COMMANDS = [
449 "/help",
450 "/status",
451 "/skills",
452 "/entities",
453 "/search",
454 "/neighbors",
455 "/export",
456 "/analyze",
457 "/ingest",
458 "/auth",
459 "/provider",
460 "/model",
461 "/run",
462 "/plan",
463 "/prd",
464 "/tasks",
465 "/quit",
466 "/exit",
467 ]
468
469 def _setup_readline(self) -> None:
470 """Set up readline for tab completion and history."""
471 try:
472 import readline
473 except ImportError:
474 return
475
476 commands = self.COMMANDS
477
478 def completer(text, state):
479 if text.startswith("/"):
480 matches = [c for c in commands if c.startswith(text)]
481 else:
482 matches = [c for c in commands if c.startswith("/" + text)]
483 matches = [m[1:] for m in matches] # strip leading /
484 if state < len(matches):
485 return matches[state]
486 return None
487
488 readline.set_completer(completer)
489 readline.set_completer_delims(" \t\n")
490 # macOS uses libedit which needs a different syntax
491 if "libedit" in readline.__doc__:
492 readline.parse_and_bind("bind ^I rl_complete")
493 else:
494 readline.parse_and_bind("tab: complete")
495
496 # Load history
497 history_path = Path.home() / ".planopticon_history"
498 try:
499 if history_path.exists():
500 readline.read_history_file(str(history_path))
501 except Exception:
502 pass
503
504 self._history_path = history_path
505
506 def _save_history(self) -> None:
507 """Save readline history."""
508 try:
509 import readline
510
511 readline.write_history_file(str(self._history_path))
512 except Exception:
513 pass
514
515 def run(self) -> None:
516 """Main REPL loop."""
517 self._discover()
518 self._init_provider()
519 self._init_agent()
520 self._setup_readline()
521
522 print(self._welcome_banner())
523
524 while True:
525 try:
@@ -464,5 +532,7 @@
532 if output == "__QUIT__":
533 print("Bye.")
534 break
535 if output:
536 print(output)
537
538 self._save_history()
539
540 DDED video_processor/cli/doctor.py
541 DDED video_processor/cli/init_wizard.py
--- a/video_processor/cli/doctor.py
+++ b/video_processor/cli/doctor.py
@@ -0,0 +1,194 @@
1
+"""Diagnostic checks for PlanOpticon setup."""
2
+
3
+import logging
4
+import os
5
+import shutil
6
+import sys
7
+from pathlib import Path
8
+from typing import List, Tuple
9
+
10
+logger = logging.getLogger(__name__)
11
+
12
+# (check_name, status, detail)
13
+CheckResult = Tuple[str, str, str]
14
+
15
+
16
+def check_python_version() -> CheckResult:
17
+ """Check Python version meets minimum."""
18
+ v = sys.version_info
19
+ version = f"{v.major}.{v.minor}.{v.micro}"
20
+ if v >= (3, 10):
21
+ return ("Python", "ok", version)
22
+ return ("Python", "warn", f"{version} (3.10+ recommended)")
23
+
24
+
25
+def check_ffmpeg() -> CheckResult:
26
+ """Check if ffmpeg is installed and accessible."""
27
+ path = shutil.which("ffmpeg")
28
+ if path:
29
+ return ("FFmpeg", "ok", path)
30
+ return ("FFmpeg", "missing", "Install via: brew install ffmpeg / apt install ffmpeg")
31
+
32
+
33
+def check_api_keys() -> List[CheckResult]:
34
+ """Check for configured API keys."""
35
+ keys = {
36
+ "OpenAI": "OPENAI_API_KEY",
37
+ "Anthropic": "ANTHROPIC_API_KEY",
38
+ "Google Gemini": "GEMINI_API_KEY",
39
+ "Azure OpenAI": "AZURE_OPENAI_API_KEY",
40
+ "Together": "TOGETHER_API_KEY",
41
+ "Fireworks": "FIREWORKS_API_KEY",
42
+ "Cerebras": "CEREBRAS_API_KEY",
43
+ "xAI": "XAI_API_KEY",
44
+ "Mistral": "MISTRAL_API_KEY",
45
+ "Cohere": "COHERE_API_KEY",
46
+ "HuggingFace": "HUGGINGFACE_API_KEY",
47
+ }
48
+ results = []
49
+ for name, env in keys.items():
50
+ val = os.environ.get(env, "")
51
+ if val:
52
+ masked = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
53
+ results.append((f" {name}", "ok", f"{env}={masked}"))
54
+ else:
55
+ results.append((f" {name}", "not set", env))
56
+ return results
57
+
58
+
59
+def check_ollama() -> CheckResult:
60
+ """Check if Ollama is running locally."""
61
+ path = shutil.which("ollama")
62
+ if not path:
63
+ return ("Ollama", "not installed", "Optional: https://ollama.ai")
64
+ try:
65
+ import subprocess
66
+
67
+ result = subprocess.run(
68
+ ["ollama", "list"],
69
+ capture_output=True,
70
+ text=True,
71
+ timeout=5,
72
+ )
73
+ if result.returncode == 0:
74
+ models = [
75
+ line.split()[0] for line in result.stdout.strip().split("\n")[1:] if line.strip()
76
+ ]
77
+ if models:
78
+ return ("Ollama", "ok", f"{len(models)} models: {', '.join(models[:3])}")
79
+ return ("Ollama", "ok", "Running but no models pulled")
80
+ return ("Ollama", "warn", "Installed but not running")
81
+ except Exception:
82
+ return ("Ollama", "warn", "Installed but not reachable")
83
+
84
+
85
+def check_optional_deps() -> List[CheckResult]:
86
+ """Check optional Python dependencies."""
87
+ deps = [
88
+ ("reportlab", "PDF export"),
89
+ ("pptx", "PPTX export"),
90
+ ("markdown", "HTML reports"),
91
+ ("torch", "GPU acceleration"),
92
+ ("yt_dlp", "YouTube download"),
93
+ ("feedparser", "RSS sources"),
94
+ ("bs4", "Web scraping"),
95
+ ]
96
+ results = []
97
+ for module, purpose in deps:
98
+ try:
99
+ __import__(module)
100
+ results.append((f" {module}", "ok", purpose))
101
+ except ImportError:
102
+ results.append((f" {module}", "not installed", purpose))
103
+ return results
104
+
105
+
106
+def check_dotenv() -> CheckResult:
107
+ """Check if .env file exists in current directory."""
108
+ env_path = Path.cwd() / ".env"
109
+ if env_path.exists():
110
+ return (".env file", "ok", str(env_path))
111
+ return (".env file", "not found", "Run `planopticon init` to create one")
112
+
113
+
114
+def check_knowledge_graph() -> CheckResult:
115
+ """Check for knowledge graph files in common locations."""
116
+ from video_processor.integrators.graph_discovery import find_nearest_graph
117
+
118
+ path = find_nearest_graph()
119
+ if path:
120
+ return ("Knowledge graph", "ok", str(path))
121
+ return ("Knowledge graph", "not found", "Run `planopticon analyze` to create one")
122
+
123
+
124
+def run_all_checks() -> List[CheckResult]:
125
+ """Run all diagnostic checks and return results."""
126
+ results = []
127
+
128
+ results.append(check_python_version())
129
+ results.append(check_ffmpeg())
130
+ results.append(check_dotenv())
131
+
132
+ results.append(("API Keys", "section", ""))
133
+ results.extend(check_api_keys())
134
+
135
+ results.append(check_ollama())
136
+
137
+ results.append(("Optional Dependencies", "section", ""))
138
+ results.extend(check_optional_deps())
139
+
140
+ results.append(check_knowledge_graph())
141
+
142
+ return results
143
+
144
+
145
+def format_results(results: List[CheckResult]) -> str:
146
+ """Format check results for terminal display."""
147
+ lines = ["", "PlanOpticon Doctor", ""]
148
+ status_icons = {
149
+ "ok": "[ok]",
150
+ "warn": "[!!]",
151
+ "missing": "[XX]",
152
+ "not set": "[--]",
153
+ "not found": "[--]",
154
+ "not installed": "[--]",
155
+ "section": "---",
156
+ }
157
+
158
+ any_issues = False
159
+ for name, status, detail in results:
160
+ icon = status_icons.get(status, "[??]")
161
+ if status == "section":
162
+ lines.append(f"\n{name}:")
163
+ continue
164
+ if status in ("missing", "warn"):
165
+ any_issues = True
166
+ detail_str = f" {detail}" if detail else ""
167
+ lines.append(f" {icon} {name}{detail_str}")
168
+
169
+ lines.append("")
170
+ if any_issues:
171
+ lines.append("Some issues found. Run `planopticon init` for guided setup.")
172
+ else:
173
+ has_key = any(
174
+ s == "ok"
175
+ for n, s, _ in results
176
+ if n.strip()
177
+ in (
178
+ "OpenAI",
179
+ "Anthropic",
180
+ "Google Gemini",
181
+ "Azure OpenAI",
182
+ "Together",
183
+ "Fireworks",
184
+ "Cerebras",
185
+ "xAI",
186
+ )
187
+ )
188
+ if has_key:
189
+ lines.append("Setup looks good!")
190
+ else:
191
+ lines.append("No API keys configured. Run `planopticon init` to set up a provider.")
192
+ lines.append("")
193
+
194
+ return "\n".join(lines)
--- a/video_processor/cli/doctor.py
+++ b/video_processor/cli/doctor.py
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/video_processor/cli/doctor.py
+++ b/video_processor/cli/doctor.py
@@ -0,0 +1,194 @@
1 """Diagnostic checks for PlanOpticon setup."""
2
3 import logging
4 import os
5 import shutil
6 import sys
7 from pathlib import Path
8 from typing import List, Tuple
9
10 logger = logging.getLogger(__name__)
11
12 # (check_name, status, detail)
13 CheckResult = Tuple[str, str, str]
14
15
16 def check_python_version() -> CheckResult:
17 """Check Python version meets minimum."""
18 v = sys.version_info
19 version = f"{v.major}.{v.minor}.{v.micro}"
20 if v >= (3, 10):
21 return ("Python", "ok", version)
22 return ("Python", "warn", f"{version} (3.10+ recommended)")
23
24
25 def check_ffmpeg() -> CheckResult:
26 """Check if ffmpeg is installed and accessible."""
27 path = shutil.which("ffmpeg")
28 if path:
29 return ("FFmpeg", "ok", path)
30 return ("FFmpeg", "missing", "Install via: brew install ffmpeg / apt install ffmpeg")
31
32
33 def check_api_keys() -> List[CheckResult]:
34 """Check for configured API keys."""
35 keys = {
36 "OpenAI": "OPENAI_API_KEY",
37 "Anthropic": "ANTHROPIC_API_KEY",
38 "Google Gemini": "GEMINI_API_KEY",
39 "Azure OpenAI": "AZURE_OPENAI_API_KEY",
40 "Together": "TOGETHER_API_KEY",
41 "Fireworks": "FIREWORKS_API_KEY",
42 "Cerebras": "CEREBRAS_API_KEY",
43 "xAI": "XAI_API_KEY",
44 "Mistral": "MISTRAL_API_KEY",
45 "Cohere": "COHERE_API_KEY",
46 "HuggingFace": "HUGGINGFACE_API_KEY",
47 }
48 results = []
49 for name, env in keys.items():
50 val = os.environ.get(env, "")
51 if val:
52 masked = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
53 results.append((f" {name}", "ok", f"{env}={masked}"))
54 else:
55 results.append((f" {name}", "not set", env))
56 return results
57
58
59 def check_ollama() -> CheckResult:
60 """Check if Ollama is running locally."""
61 path = shutil.which("ollama")
62 if not path:
63 return ("Ollama", "not installed", "Optional: https://ollama.ai")
64 try:
65 import subprocess
66
67 result = subprocess.run(
68 ["ollama", "list"],
69 capture_output=True,
70 text=True,
71 timeout=5,
72 )
73 if result.returncode == 0:
74 models = [
75 line.split()[0] for line in result.stdout.strip().split("\n")[1:] if line.strip()
76 ]
77 if models:
78 return ("Ollama", "ok", f"{len(models)} models: {', '.join(models[:3])}")
79 return ("Ollama", "ok", "Running but no models pulled")
80 return ("Ollama", "warn", "Installed but not running")
81 except Exception:
82 return ("Ollama", "warn", "Installed but not reachable")
83
84
85 def check_optional_deps() -> List[CheckResult]:
86 """Check optional Python dependencies."""
87 deps = [
88 ("reportlab", "PDF export"),
89 ("pptx", "PPTX export"),
90 ("markdown", "HTML reports"),
91 ("torch", "GPU acceleration"),
92 ("yt_dlp", "YouTube download"),
93 ("feedparser", "RSS sources"),
94 ("bs4", "Web scraping"),
95 ]
96 results = []
97 for module, purpose in deps:
98 try:
99 __import__(module)
100 results.append((f" {module}", "ok", purpose))
101 except ImportError:
102 results.append((f" {module}", "not installed", purpose))
103 return results
104
105
106 def check_dotenv() -> CheckResult:
107 """Check if .env file exists in current directory."""
108 env_path = Path.cwd() / ".env"
109 if env_path.exists():
110 return (".env file", "ok", str(env_path))
111 return (".env file", "not found", "Run `planopticon init` to create one")
112
113
114 def check_knowledge_graph() -> CheckResult:
115 """Check for knowledge graph files in common locations."""
116 from video_processor.integrators.graph_discovery import find_nearest_graph
117
118 path = find_nearest_graph()
119 if path:
120 return ("Knowledge graph", "ok", str(path))
121 return ("Knowledge graph", "not found", "Run `planopticon analyze` to create one")
122
123
124 def run_all_checks() -> List[CheckResult]:
125 """Run all diagnostic checks and return results."""
126 results = []
127
128 results.append(check_python_version())
129 results.append(check_ffmpeg())
130 results.append(check_dotenv())
131
132 results.append(("API Keys", "section", ""))
133 results.extend(check_api_keys())
134
135 results.append(check_ollama())
136
137 results.append(("Optional Dependencies", "section", ""))
138 results.extend(check_optional_deps())
139
140 results.append(check_knowledge_graph())
141
142 return results
143
144
145 def format_results(results: List[CheckResult]) -> str:
146 """Format check results for terminal display."""
147 lines = ["", "PlanOpticon Doctor", ""]
148 status_icons = {
149 "ok": "[ok]",
150 "warn": "[!!]",
151 "missing": "[XX]",
152 "not set": "[--]",
153 "not found": "[--]",
154 "not installed": "[--]",
155 "section": "---",
156 }
157
158 any_issues = False
159 for name, status, detail in results:
160 icon = status_icons.get(status, "[??]")
161 if status == "section":
162 lines.append(f"\n{name}:")
163 continue
164 if status in ("missing", "warn"):
165 any_issues = True
166 detail_str = f" {detail}" if detail else ""
167 lines.append(f" {icon} {name}{detail_str}")
168
169 lines.append("")
170 if any_issues:
171 lines.append("Some issues found. Run `planopticon init` for guided setup.")
172 else:
173 has_key = any(
174 s == "ok"
175 for n, s, _ in results
176 if n.strip()
177 in (
178 "OpenAI",
179 "Anthropic",
180 "Google Gemini",
181 "Azure OpenAI",
182 "Together",
183 "Fireworks",
184 "Cerebras",
185 "xAI",
186 )
187 )
188 if has_key:
189 lines.append("Setup looks good!")
190 else:
191 lines.append("No API keys configured. Run `planopticon init` to set up a provider.")
192 lines.append("")
193
194 return "\n".join(lines)
--- a/video_processor/cli/init_wizard.py
+++ b/video_processor/cli/init_wizard.py
@@ -0,0 +1,200 @@
1
+"""Interactive setup wizard for PlanOpticon."""
2
+
3
+import os
4
+import shutil
5
+from pathlib import Path
6
+from typing import Dict, Optional, Tuple
7
+
8
+import click
9
+
10
+PROVIDERS = [
11
+ ("openai", "OpenAI", "OPENAI_API_KEY"),
12
+ ("anthropic", "Anthropic", "ANTHROPIC_API_KEY"),
13
+ ("gemini", "Google Gemini", "GEMINI_API_KEY"),
14
+ ("ollama", "Ollama (local)", None),
15
+ ("azure", "Azure OpenAI", "AZURE_OPENAI_API_KEY"),
16
+ ("together", "Together AI", "TOGETHER_API_KEY"),
17
+ ("fireworks", "Fireworks AI", "FIREWORKS_API_KEY"),
18
+ ("cerebras", "Cerebras", "CEREBRAS_API_KEY"),
19
+ ("xai", "xAI", "XAI_API_KEY"),
20
+]
21
+
22
+
23
+def _check_ffmpeg() -> bool:
24
+ return shutil.which("ffmpeg") is not None
25
+
26
+
27
+def _test_provider(provider_id: str, api_key: Optional[str] = None) -> Tuple[bool, str]:
28
+ """Test that a provider connection works."""
29
+ if provider_id == "ollama":
30
+ try:
31
+ import subprocess
32
+
33
+ result = subprocess.run(
34
+ ["ollama", "list"],
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=5,
38
+ )
39
+ if result.returncode == 0:
40
+ return True, "Ollama is running"
41
+ return False, "Ollama is installed but not running. Start with: ollama serve"
42
+ except FileNotFoundError:
43
+ return False, "Ollama not found. Install from: https://ollama.ai"
44
+ except Exception as e:
45
+ return False, f"Could not reach Ollama: {e}"
46
+
47
+ if not api_key:
48
+ return False, "No API key provided"
49
+
50
+ # For API-based providers, just check the key looks valid
51
+ if len(api_key) < 8:
52
+ return False, "API key looks too short"
53
+ return True, "API key configured"
54
+
55
+
56
+def run_wizard() -> None:
57
+ """Run the interactive setup wizard."""
58
+ click.echo()
59
+ click.echo(" PlanOpticon Setup Wizard")
60
+ click.echo(" " + "-" * 30)
61
+ click.echo()
62
+
63
+ # Step 1: Check prerequisites
64
+ click.echo("Checking prerequisites...")
65
+ click.echo()
66
+
67
+ if _check_ffmpeg():
68
+ click.echo(" [ok] FFmpeg found")
69
+ else:
70
+ click.echo(" [!!] FFmpeg not found")
71
+ click.echo(" Install: brew install ffmpeg (macOS)")
72
+ click.echo(" apt install ffmpeg (Ubuntu)")
73
+ click.echo(" winget install ffmpeg (Windows)")
74
+ click.echo()
75
+
76
+ # Step 2: Choose provider
77
+ click.echo()
78
+ click.echo("Choose your AI provider:")
79
+ click.echo()
80
+ for i, (pid, name, _) in enumerate(PROVIDERS, 1):
81
+ # Check if already configured
82
+ env_key = PROVIDERS[i - 1][2]
83
+ status = ""
84
+ if pid == "ollama":
85
+ if shutil.which("ollama"):
86
+ status = " (installed)"
87
+ elif env_key and os.environ.get(env_key):
88
+ status = " (configured)"
89
+ click.echo(f" {i}. {name}{status}")
90
+ click.echo()
91
+
92
+ choice = click.prompt(
93
+ "Select provider",
94
+ type=click.IntRange(1, len(PROVIDERS)),
95
+ default=1,
96
+ )
97
+ provider_id, provider_name, env_var = PROVIDERS[choice - 1]
98
+
99
+ # Step 3: Configure API key
100
+ env_vars: Dict[str, str] = {}
101
+
102
+ if provider_id == "ollama":
103
+ click.echo()
104
+ ok, msg = _test_provider("ollama")
105
+ if ok:
106
+ click.echo(f" [ok] {msg}")
107
+ else:
108
+ click.echo(f" [!!] {msg}")
109
+ elif env_var:
110
+ existing = os.environ.get(env_var, "")
111
+ if existing:
112
+ click.echo(f"\n {env_var} is already set.")
113
+ if not click.confirm(" Update it?", default=False):
114
+ env_vars[env_var] = existing
115
+ else:
116
+ key = click.prompt(f" Enter {env_var}", hide_input=True)
117
+ env_vars[env_var] = key
118
+ else:
119
+ click.echo(f"\n {provider_name} requires {env_var}.")
120
+ key = click.prompt(f" Enter {env_var}", hide_input=True)
121
+ env_vars[env_var] = key
122
+
123
+ if env_var in env_vars:
124
+ ok, msg = _test_provider(provider_id, env_vars[env_var])
125
+ if ok:
126
+ click.echo(f" [ok] {msg}")
127
+ else:
128
+ click.echo(f" [!!] {msg}")
129
+
130
+ # Step 4: Additional providers?
131
+ click.echo()
132
+ if click.confirm("Configure additional providers?", default=False):
133
+ for pid, pname, evar in PROVIDERS:
134
+ if pid == provider_id or not evar:
135
+ continue
136
+ if os.environ.get(evar):
137
+ continue
138
+ if click.confirm(f" Set up {pname}?", default=False):
139
+ key = click.prompt(f" Enter {evar}", hide_input=True)
140
+ env_vars[evar] = key
141
+
142
+ # Step 5: Write .env file
143
+ env_path = Path.cwd() / ".env"
144
+ if env_vars:
145
+ click.echo()
146
+
147
+ if env_path.exists():
148
+ click.echo(f" .env already exists at {env_path}")
149
+ if not click.confirm(" Append new keys?", default=True):
150
+ click.echo(" Skipping .env update.")
151
+ _print_summary(provider_name, env_vars)
152
+ return
153
+
154
+ # Read existing content
155
+ existing_content = env_path.read_text() if env_path.exists() else ""
156
+ existing_keys = set()
157
+ for line in existing_content.split("\n"):
158
+ if "=" in line and not line.strip().startswith("#"):
159
+ existing_keys.add(line.split("=", 1)[0].strip())
160
+
161
+ new_lines = []
162
+ for key, val in env_vars.items():
163
+ if key not in existing_keys:
164
+ new_lines.append(f"{key}={val}")
165
+
166
+ if new_lines:
167
+ with open(env_path, "a") as f:
168
+ if existing_content and not existing_content.endswith("\n"):
169
+ f.write("\n")
170
+ f.write("\n".join(new_lines) + "\n")
171
+ click.echo(f" Updated {env_path} with {len(new_lines)} key(s)")
172
+ else:
173
+ click.echo(" All keys already in .env")
174
+
175
+ # Remind about .gitignore
176
+ gitignore = Path.cwd() / ".gitignore"
177
+ if gitignore.exists():
178
+ content = gitignore.read_text()
179
+ if ".env" not in content:
180
+ click.echo(" [!!] .env is not in .gitignore — consider adding it")
181
+ else:
182
+ click.echo(" [!!] No .gitignore found — make sure .env is not committed")
183
+
184
+ _print_summary(provider_name, env_vars)
185
+
186
+
187
+def _print_summary(provider_name: str, env_vars: Dict[str, str]) -> None:
188
+ """Print setup summary."""
189
+ click.echo()
190
+ click.echo(" Setup complete!")
191
+ click.echo()
192
+ click.echo(f" Provider: {provider_name}")
193
+ if env_vars:
194
+ click.echo(f" Keys configured: {len(env_vars)}")
195
+ click.echo()
196
+ click.echo(" Next steps:")
197
+ click.echo(" planopticon doctor Check setup health")
198
+ click.echo(" planopticon analyze -i VIDEO -o OUTPUT")
199
+ click.echo(" planopticon -I Interactive mode")
200
+ click.echo()
--- a/video_processor/cli/init_wizard.py
+++ b/video_processor/cli/init_wizard.py
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/video_processor/cli/init_wizard.py
+++ b/video_processor/cli/init_wizard.py
@@ -0,0 +1,200 @@
1 """Interactive setup wizard for PlanOpticon."""
2
3 import os
4 import shutil
5 from pathlib import Path
6 from typing import Dict, Optional, Tuple
7
8 import click
9
10 PROVIDERS = [
11 ("openai", "OpenAI", "OPENAI_API_KEY"),
12 ("anthropic", "Anthropic", "ANTHROPIC_API_KEY"),
13 ("gemini", "Google Gemini", "GEMINI_API_KEY"),
14 ("ollama", "Ollama (local)", None),
15 ("azure", "Azure OpenAI", "AZURE_OPENAI_API_KEY"),
16 ("together", "Together AI", "TOGETHER_API_KEY"),
17 ("fireworks", "Fireworks AI", "FIREWORKS_API_KEY"),
18 ("cerebras", "Cerebras", "CEREBRAS_API_KEY"),
19 ("xai", "xAI", "XAI_API_KEY"),
20 ]
21
22
23 def _check_ffmpeg() -> bool:
24 return shutil.which("ffmpeg") is not None
25
26
27 def _test_provider(provider_id: str, api_key: Optional[str] = None) -> Tuple[bool, str]:
28 """Test that a provider connection works."""
29 if provider_id == "ollama":
30 try:
31 import subprocess
32
33 result = subprocess.run(
34 ["ollama", "list"],
35 capture_output=True,
36 text=True,
37 timeout=5,
38 )
39 if result.returncode == 0:
40 return True, "Ollama is running"
41 return False, "Ollama is installed but not running. Start with: ollama serve"
42 except FileNotFoundError:
43 return False, "Ollama not found. Install from: https://ollama.ai"
44 except Exception as e:
45 return False, f"Could not reach Ollama: {e}"
46
47 if not api_key:
48 return False, "No API key provided"
49
50 # For API-based providers, just check the key looks valid
51 if len(api_key) < 8:
52 return False, "API key looks too short"
53 return True, "API key configured"
54
55
56 def run_wizard() -> None:
57 """Run the interactive setup wizard."""
58 click.echo()
59 click.echo(" PlanOpticon Setup Wizard")
60 click.echo(" " + "-" * 30)
61 click.echo()
62
63 # Step 1: Check prerequisites
64 click.echo("Checking prerequisites...")
65 click.echo()
66
67 if _check_ffmpeg():
68 click.echo(" [ok] FFmpeg found")
69 else:
70 click.echo(" [!!] FFmpeg not found")
71 click.echo(" Install: brew install ffmpeg (macOS)")
72 click.echo(" apt install ffmpeg (Ubuntu)")
73 click.echo(" winget install ffmpeg (Windows)")
74 click.echo()
75
76 # Step 2: Choose provider
77 click.echo()
78 click.echo("Choose your AI provider:")
79 click.echo()
80 for i, (pid, name, _) in enumerate(PROVIDERS, 1):
81 # Check if already configured
82 env_key = PROVIDERS[i - 1][2]
83 status = ""
84 if pid == "ollama":
85 if shutil.which("ollama"):
86 status = " (installed)"
87 elif env_key and os.environ.get(env_key):
88 status = " (configured)"
89 click.echo(f" {i}. {name}{status}")
90 click.echo()
91
92 choice = click.prompt(
93 "Select provider",
94 type=click.IntRange(1, len(PROVIDERS)),
95 default=1,
96 )
97 provider_id, provider_name, env_var = PROVIDERS[choice - 1]
98
99 # Step 3: Configure API key
100 env_vars: Dict[str, str] = {}
101
102 if provider_id == "ollama":
103 click.echo()
104 ok, msg = _test_provider("ollama")
105 if ok:
106 click.echo(f" [ok] {msg}")
107 else:
108 click.echo(f" [!!] {msg}")
109 elif env_var:
110 existing = os.environ.get(env_var, "")
111 if existing:
112 click.echo(f"\n {env_var} is already set.")
113 if not click.confirm(" Update it?", default=False):
114 env_vars[env_var] = existing
115 else:
116 key = click.prompt(f" Enter {env_var}", hide_input=True)
117 env_vars[env_var] = key
118 else:
119 click.echo(f"\n {provider_name} requires {env_var}.")
120 key = click.prompt(f" Enter {env_var}", hide_input=True)
121 env_vars[env_var] = key
122
123 if env_var in env_vars:
124 ok, msg = _test_provider(provider_id, env_vars[env_var])
125 if ok:
126 click.echo(f" [ok] {msg}")
127 else:
128 click.echo(f" [!!] {msg}")
129
130 # Step 4: Additional providers?
131 click.echo()
132 if click.confirm("Configure additional providers?", default=False):
133 for pid, pname, evar in PROVIDERS:
134 if pid == provider_id or not evar:
135 continue
136 if os.environ.get(evar):
137 continue
138 if click.confirm(f" Set up {pname}?", default=False):
139 key = click.prompt(f" Enter {evar}", hide_input=True)
140 env_vars[evar] = key
141
142 # Step 5: Write .env file
143 env_path = Path.cwd() / ".env"
144 if env_vars:
145 click.echo()
146
147 if env_path.exists():
148 click.echo(f" .env already exists at {env_path}")
149 if not click.confirm(" Append new keys?", default=True):
150 click.echo(" Skipping .env update.")
151 _print_summary(provider_name, env_vars)
152 return
153
154 # Read existing content
155 existing_content = env_path.read_text() if env_path.exists() else ""
156 existing_keys = set()
157 for line in existing_content.split("\n"):
158 if "=" in line and not line.strip().startswith("#"):
159 existing_keys.add(line.split("=", 1)[0].strip())
160
161 new_lines = []
162 for key, val in env_vars.items():
163 if key not in existing_keys:
164 new_lines.append(f"{key}={val}")
165
166 if new_lines:
167 with open(env_path, "a") as f:
168 if existing_content and not existing_content.endswith("\n"):
169 f.write("\n")
170 f.write("\n".join(new_lines) + "\n")
171 click.echo(f" Updated {env_path} with {len(new_lines)} key(s)")
172 else:
173 click.echo(" All keys already in .env")
174
175 # Remind about .gitignore
176 gitignore = Path.cwd() / ".gitignore"
177 if gitignore.exists():
178 content = gitignore.read_text()
179 if ".env" not in content:
180 click.echo(" [!!] .env is not in .gitignore — consider adding it")
181 else:
182 click.echo(" [!!] No .gitignore found — make sure .env is not committed")
183
184 _print_summary(provider_name, env_vars)
185
186
187 def _print_summary(provider_name: str, env_vars: Dict[str, str]) -> None:
188 """Print setup summary."""
189 click.echo()
190 click.echo(" Setup complete!")
191 click.echo()
192 click.echo(f" Provider: {provider_name}")
193 if env_vars:
194 click.echo(f" Keys configured: {len(env_vars)}")
195 click.echo()
196 click.echo(" Next steps:")
197 click.echo(" planopticon doctor Check setup health")
198 click.echo(" planopticon analyze -i VIDEO -o OUTPUT")
199 click.echo(" planopticon -I Interactive mode")
200 click.echo()

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button