devorch 0.1.2__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.
cli/main.py ADDED
@@ -0,0 +1,1527 @@
1
+ import os
2
+
3
+ import questionary
4
+ import typer
5
+ from prompt_toolkit import prompt as pt_prompt
6
+ from prompt_toolkit.completion import Completer, Completion
7
+ from prompt_toolkit.formatted_text import HTML
8
+ from prompt_toolkit.styles import Style
9
+ from questionary import Style as QStyle
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from config.permissions import (
14
+ PERMISSIONS_FILE,
15
+ PermissionLevel,
16
+ get_permissions,
17
+ reset_permissions,
18
+ )
19
+ from config.settings import (
20
+ CONFIG_FILE,
21
+ ProviderConfig,
22
+ Settings,
23
+ keyring_available,
24
+ save_config,
25
+ set_api_key,
26
+ )
27
+ from core.agent import Agent
28
+ from core.executor import ToolExecutor
29
+ from core.modes import AgentMode, ModeManager
30
+ from core.planner import Planner
31
+ from core.sessions import DEFAULT_MESSAGE_LIMIT, SessionManager
32
+ from core.tasks import get_task_manager, reset_task_manager
33
+ from providers import PROVIDER_ENV_VARS, PROVIDER_INFO, PROVIDERS, get_provider
34
+ from schemas.message import Message
35
+ from tools.edit import EditTool
36
+ from tools.filesystem import FilesystemTool
37
+ from tools.grep import GrepTool
38
+ from tools.search import SearchTool
39
+ from tools.shell import ShellTool
40
+ from tools.task import TaskTool
41
+ from tools.terminal import OpenTerminalTool
42
+ from tools.terminal_session import TerminalSessionTool
43
+ from tools.websearch import WebFetchTool, WebSearchTool
44
+ from utils.logger import (
45
+ get_console,
46
+ print_error,
47
+ print_info,
48
+ print_panel,
49
+ print_success,
50
+ print_warning,
51
+ )
52
+
53
+ # Custom style for questionary prompts
54
+ QUESTIONARY_STYLE = QStyle(
55
+ [
56
+ ("qmark", "fg:yellow bold"),
57
+ ("question", "fg:white bold"),
58
+ ("answer", "fg:green bold"),
59
+ ("pointer", "fg:cyan bold"),
60
+ ("highlighted", "fg:white"), # Normal white text, no background - arrow shows selection
61
+ ("selected", "fg:white"),
62
+ ("instruction", "fg:gray"),
63
+ ]
64
+ )
65
+
66
+ # ASCII Art Banner
67
+ BANNER = r"""
68
+ [bold blue]
69
+ ____ ____ _ _ _
70
+ | _ \ _____ _| _ \(_) | ___ | |_
71
+ | | | |/ _ \ \ / / |_) | | |/ _ \| __|
72
+ | |_| | __/\ V /| __/| | | (_) | |_
73
+ |____/ \___| \_/ |_| |_|_|\___/ \__|
74
+ [/bold blue]
75
+ """
76
+
77
+ BANNER_SMALL = "[bold blue]DevOrch[/bold blue] - AI Coding Assistant"
78
+
79
+ VERSION = "0.1.0"
80
+
81
+ # Slash commands with descriptions
82
+ SLASH_COMMANDS = {
83
+ "/help": "Show available commands",
84
+ "/mode": "Show or change mode (plan/auto/ask)",
85
+ "/plan": "Switch to plan mode",
86
+ "/auto": "Switch to auto mode",
87
+ "/ask": "Switch to ask mode (default)",
88
+ "/clear": "Clear conversation history",
89
+ "/session": "Show current session info",
90
+ "/config": "Show configuration settings",
91
+ "/permissions": "Show permission settings",
92
+ "/compact": "Summarize and compact history",
93
+ "/models": "List available models for current provider",
94
+ "/model": "Switch to a different model",
95
+ "/providers": "List all available providers",
96
+ "/provider": "Switch to a different provider",
97
+ "/history": "Show conversation history",
98
+ "/undo": "Undo last message",
99
+ "/save": "Save conversation to file",
100
+ "/status": "Show current provider, model, and mode",
101
+ "/tasks": "Show current task list",
102
+ }
103
+
104
+ # Style for prompt_toolkit (including completion menu)
105
+ PROMPT_STYLE = Style.from_dict(
106
+ {
107
+ "prompt": "#00aa00 bold",
108
+ "command": "#00aaff bold",
109
+ "description": "#888888",
110
+ # Completion menu styling
111
+ "completion-menu": "bg:#1a1a2e",
112
+ "completion-menu.completion": "bg:#1a1a2e #e0e0e0",
113
+ "completion-menu.completion.current": "bg:#0066cc #ffffff bold",
114
+ "completion-menu.meta": "bg:#1a1a2e #666666 italic",
115
+ "completion-menu.meta.current": "bg:#0066cc #cccccc italic",
116
+ # Scrollbar
117
+ "scrollbar.background": "bg:#333344",
118
+ "scrollbar.button": "bg:#0066cc",
119
+ }
120
+ )
121
+
122
+
123
+ class SlashCommandCompleter(Completer):
124
+ """Autocomplete for slash commands."""
125
+
126
+ def get_completions(self, document, complete_event):
127
+ text = document.text_before_cursor
128
+
129
+ # Only complete if starts with /
130
+ if not text.startswith("/"):
131
+ return
132
+
133
+ # Get the partial command
134
+ partial = text.lower()
135
+
136
+ for cmd, desc in SLASH_COMMANDS.items():
137
+ if cmd.startswith(partial):
138
+ # Calculate how much to complete
139
+ yield Completion(
140
+ cmd,
141
+ start_position=-len(text),
142
+ display=HTML(f"<command>{cmd}</command> <description>- {desc}</description>"),
143
+ display_meta=desc,
144
+ )
145
+
146
+
147
+ def print_banner(small: bool = False):
148
+ """Print the DevOrch banner."""
149
+ if small:
150
+ console.print(BANNER_SMALL)
151
+ else:
152
+ console.print(BANNER)
153
+ console.print(f" [dim]v{VERSION} - Your AI Coding Assistant[/dim]\n")
154
+
155
+
156
+ SYSTEM_PROMPT = """You are DevOrch, an AI coding assistant with access to tools for interacting with the user's computer.
157
+
158
+ IMPORTANT: You have the following tools available and MUST use them to help the user:
159
+
160
+ 1. **shell** - Execute shell commands (bash/powershell). Use this to:
161
+ - Run commands like `npm install`, `git clone`, `git status`, etc.
162
+ - Navigate directories, create files, run scripts
163
+ - Any short-lived terminal command that returns output
164
+
165
+ 2. **open_terminal** - Open a NEW terminal window and run a command inside it. Use this for:
166
+ - Starting dev servers or daemons: `npm run dev`, `vite`, `uvicorn`, `flask run`, `next dev`, `ng serve`
167
+ - Interactive scaffold tools that prompt the user: `npm create vite@latest`, `npx create-next-app`, `ng new`, `django-admin startproject`
168
+ - Any long-running process that should NOT block the current session
169
+ - ALWAYS prefer this over `shell` for servers and scaffolds
170
+
171
+ 3. **terminal_session** — Manage a long-running background process across turns:
172
+ - `start` — launch command in a named background session
173
+ - `read` — read recent stdout/stderr output
174
+ - `send` — send input to the process stdin
175
+ - `stop` — terminate the session
176
+ - `list` — show all active sessions
177
+ Use this when you need to check server logs, send commands to a running process, or manage multiple background processes.
178
+
179
+ 4. **filesystem** - Read/write/list files. Use this to:
180
+ - Read file contents to understand code
181
+ - Write or create new files
182
+ - List directory contents
183
+
184
+ 5. **search** - Find files by name patterns (like glob)
185
+
186
+ 6. **grep** - Search for text patterns within files
187
+
188
+ 7. **edit** - Make targeted edits to existing files
189
+
190
+ 8. **task** - Track progress on multi-step work. Use this to:
191
+ - Create a task list when working on complex requests (3+ steps)
192
+ - Show the user what you're currently working on
193
+ - Mark tasks complete as you finish them
194
+
195
+ Task guidelines:
196
+ - Use when working on multiple steps or user gives multiple items
197
+ - Only ONE task should be 'in_progress' at a time
198
+ - Mark tasks 'completed' immediately after finishing each one
199
+ - content: imperative form (e.g., "Fix bug", "Run tests")
200
+ - activeForm: present continuous (e.g., "Fixing bug", "Running tests")
201
+
202
+ 9. **websearch** - Search the web for current information. Use when:
203
+ - You need up-to-date information (news, docs, releases)
204
+ - Looking up programming solutions or best practices
205
+ - Finding package/library documentation
206
+ - User asks about something you're unsure about
207
+
208
+ 10. **webfetch** - Fetch content from a specific URL. Use when:
209
+ - You need to read a documentation page
210
+ - User provides a URL to check
211
+ - You found a relevant URL from search results
212
+
213
+ RULES:
214
+ - When the user asks you to CREATE something (app, file, project), USE THE TOOLS to actually do it
215
+ - Do NOT just give instructions - execute the commands yourself using the shell tool
216
+ - Do NOT ask the user to run commands manually - run them for the user
217
+ - Always prefer action over explanation
218
+ - For multi-step tasks, use the task tool to track and show progress
219
+ - IMPORTANT: Use `open_terminal` (not `shell`) for dev servers and interactive scaffold commands
220
+
221
+ When executing shell commands, use the shell tool with the command to run."""
222
+
223
+
224
+ class SimplePlanner(Planner):
225
+ def plan(self, history: list[Message]) -> list[Message]:
226
+ system_prompt = Message(role="system", content=SYSTEM_PROMPT)
227
+ return [system_prompt] + history
228
+
229
+
230
+ # Main app with invoke_without_command=True so we can handle bare `devorch`
231
+ app = typer.Typer(
232
+ help="DevOrch - Your AI Coding Assistant", invoke_without_command=True, no_args_is_help=False
233
+ )
234
+ sessions_app = typer.Typer(help="Manage chat sessions")
235
+ app.add_typer(sessions_app, name="sessions")
236
+
237
+ permissions_app = typer.Typer(help="Manage tool permissions")
238
+ app.add_typer(permissions_app, name="permissions")
239
+
240
+ console = get_console()
241
+
242
+
243
+ def has_any_provider_configured(settings: Settings) -> bool:
244
+ """Check if any provider is configured (has API key or is local/lmstudio with model).
245
+
246
+ Also checks if config file exists - if not, we need onboarding even if keys exist in keyring.
247
+ """
248
+ # If config file doesn't exist, run onboarding (even if keys exist in keyring)
249
+ if not CONFIG_FILE.exists():
250
+ return False
251
+
252
+ # Check if any API-based provider has a key configured (keyring or env var)
253
+ for name in PROVIDERS.keys():
254
+ if name not in ("local", "lmstudio"):
255
+ if settings.get_api_key(name):
256
+ return True
257
+
258
+ # Check if local/lmstudio is configured (has a saved model in config)
259
+ for name in ("local", "lmstudio"):
260
+ config = settings.providers.get(name)
261
+ if config and config.default_model:
262
+ return True
263
+
264
+ return False
265
+
266
+
267
+ def run_onboarding() -> str | None:
268
+ """Run first-time setup with interactive prompts. Returns the configured provider name or None."""
269
+ print_banner()
270
+
271
+ # Welcome panel
272
+ console.print(
273
+ Panel(
274
+ "[bold]Welcome to DevOrch![/bold]\n\nLet's set up your AI provider to get started.",
275
+ border_style="blue",
276
+ padding=(1, 2),
277
+ )
278
+ )
279
+ console.print()
280
+
281
+ # Provider selection with questionary
282
+ provider_choices = [
283
+ questionary.Choice("OpenAI (GPT-4o, GPT-4, etc.)", value="openai"),
284
+ questionary.Choice("Anthropic (Claude Sonnet, Opus, etc.)", value="anthropic"),
285
+ questionary.Choice("Google Gemini (Gemini Pro, Flash, etc.)", value="gemini"),
286
+ questionary.Choice("Groq (Ultra-fast Llama, Mixtral)", value="groq"),
287
+ questionary.Choice("OpenRouter (Access 100+ models)", value="openrouter"),
288
+ questionary.Choice("Mistral (Mistral Large, Codestral)", value="mistral"),
289
+ questionary.Choice("Together AI (Open source models)", value="together"),
290
+ questionary.Separator(),
291
+ questionary.Choice("Ollama - Local (No API key needed)", value="local"),
292
+ questionary.Choice("LM Studio - Local (No API key needed)", value="lmstudio"),
293
+ ]
294
+
295
+ try:
296
+ provider = questionary.select(
297
+ "Select your AI provider:",
298
+ choices=provider_choices,
299
+ style=QUESTIONARY_STYLE,
300
+ instruction="(Use arrow keys to navigate, Enter to select)",
301
+ ).ask()
302
+
303
+ if not provider:
304
+ return None
305
+
306
+ except (KeyboardInterrupt, EOFError):
307
+ return None
308
+
309
+ if provider in ("local", "lmstudio"):
310
+ print_success(f"{provider.title()} provider selected - no API key needed!")
311
+ if provider == "local":
312
+ print_info("Make sure Ollama is running at http://localhost:11434")
313
+ else:
314
+ print_info("Make sure LM Studio is running at http://localhost:1234")
315
+
316
+ # Try to list available models and let user select
317
+ settings = Settings.load()
318
+ try:
319
+ with console.status("[bold cyan]Fetching available models...", spinner="dots"):
320
+ temp_provider = get_provider(provider)
321
+ models = temp_provider.list_models()
322
+
323
+ if models:
324
+ model_choices = [questionary.Choice(m.id, value=m.id) for m in models[:15]]
325
+
326
+ selected_model = questionary.select(
327
+ "Select a model:",
328
+ choices=model_choices,
329
+ style=QUESTIONARY_STYLE,
330
+ instruction="(Use arrow keys)",
331
+ ).ask()
332
+
333
+ if selected_model:
334
+ if provider not in settings.providers:
335
+ settings.providers[provider] = ProviderConfig()
336
+ settings.providers[provider].default_model = selected_model
337
+ settings.default_provider = provider
338
+ save_config(settings)
339
+ print_success(f"Saved: provider={provider}, model={selected_model}")
340
+
341
+ except Exception as e:
342
+ print_warning(f"Could not list models: {e}")
343
+ settings.default_provider = provider
344
+ try:
345
+ save_config(settings)
346
+ except Exception:
347
+ pass
348
+
349
+ return provider
350
+
351
+ # Get API key for cloud providers
352
+ env_var = PROVIDER_ENV_VARS.get(provider, f"{provider.upper()}_API_KEY")
353
+
354
+ console.print()
355
+ console.print(
356
+ Panel(
357
+ f"[bold]Setting up {provider.title()}[/bold]\n\n"
358
+ f"You'll need an API key from {provider.title()}.\n"
359
+ f"Alternatively, set the [cyan]{env_var}[/cyan] environment variable.",
360
+ border_style="yellow",
361
+ padding=(0, 1),
362
+ )
363
+ )
364
+ console.print()
365
+
366
+ api_key = questionary.password(f"Enter your {provider} API key:", style=QUESTIONARY_STYLE).ask()
367
+
368
+ if not api_key or not api_key.strip():
369
+ print_error("API key cannot be empty.")
370
+ return None
371
+
372
+ api_key = api_key.strip()
373
+
374
+ # Try to store in keyring
375
+ if keyring_available():
376
+ if set_api_key(provider, api_key):
377
+ print_success("API key stored securely in system keychain!")
378
+ else:
379
+ print_warning("Could not store in keychain. Key will only be available this session.")
380
+ else:
381
+ print_warning("Keychain not available. Set the environment variable for persistence.")
382
+
383
+ # Save as default provider
384
+ settings = Settings.load()
385
+ settings.default_provider = provider
386
+ if provider not in settings.providers:
387
+ settings.providers[provider] = ProviderConfig()
388
+ settings.providers[provider].api_key = api_key
389
+
390
+ # Let user select a model
391
+ selected_model = None
392
+ try:
393
+ with console.status("[bold cyan]Fetching available models...", spinner="dots"):
394
+ temp_provider = get_provider(provider, api_key=api_key)
395
+ models = temp_provider.list_models()
396
+
397
+ if models:
398
+ model_choices = []
399
+ for m in models[:15]:
400
+ desc = (
401
+ f" - {m.description[:40]}..."
402
+ if m.description and len(m.description) > 40
403
+ else ""
404
+ )
405
+ model_choices.append(questionary.Choice(f"{m.id}{desc}", value=m.id))
406
+
407
+ selected_model = questionary.select(
408
+ "Select a model:",
409
+ choices=model_choices,
410
+ style=QUESTIONARY_STYLE,
411
+ instruction="(Use arrow keys)",
412
+ ).ask()
413
+
414
+ if selected_model:
415
+ settings.providers[provider].default_model = selected_model
416
+
417
+ except Exception as e:
418
+ print_warning(f"Could not fetch models: {e}")
419
+
420
+ try:
421
+ save_config(settings)
422
+ if selected_model:
423
+ print_success(f"Saved: provider={provider}, model={selected_model}")
424
+ else:
425
+ print_success(f"Default provider set to: {provider}")
426
+ except Exception:
427
+ pass # Config save failed, but key is in memory
428
+
429
+ console.print()
430
+ console.print(
431
+ Panel(
432
+ "[bold green]Setup complete![/bold green]\n\n"
433
+ "You're ready to start using DevOrch.\n"
434
+ "Type your questions or commands, or use /help for available commands.",
435
+ border_style="green",
436
+ padding=(0, 1),
437
+ )
438
+ )
439
+
440
+ return provider
441
+
442
+
443
+ def create_provider_safe(provider_name: str, model: str, settings: Settings):
444
+ """Create provider, returning None if API key missing (for onboarding check)."""
445
+ provider_name = provider_name.lower()
446
+
447
+ if provider_name not in PROVIDERS:
448
+ return None
449
+
450
+ api_key = settings.get_api_key(provider_name)
451
+
452
+ if provider_name != "local" and not api_key:
453
+ return None
454
+
455
+ if not model:
456
+ model = settings.get_default_model(provider_name)
457
+
458
+ kwargs = {}
459
+ if provider_name == "local":
460
+ base_url = settings.get_base_url(provider_name)
461
+ if base_url:
462
+ kwargs["base_url"] = base_url
463
+
464
+ return get_provider(provider_name, model=model, api_key=api_key, **kwargs)
465
+
466
+
467
+ def create_provider(provider_name: str, model: str, settings: Settings):
468
+ """Create and validate a provider instance."""
469
+ provider_name = provider_name.lower()
470
+
471
+ if provider_name not in PROVIDERS:
472
+ print_error(f"Unknown provider '{provider_name}'. Available: {', '.join(PROVIDERS.keys())}")
473
+ raise typer.Exit(1)
474
+
475
+ api_key = settings.get_api_key(provider_name)
476
+
477
+ if provider_name != "local" and not api_key:
478
+ env_var_name = {
479
+ "openai": "OPENAI_API_KEY",
480
+ "anthropic": "ANTHROPIC_API_KEY",
481
+ "gemini": "GOOGLE_API_KEY",
482
+ }.get(provider_name, f"{provider_name.upper()}_API_KEY")
483
+
484
+ print_error(f"No API key found for {provider_name}.")
485
+ print_error(
486
+ f"Use 'devorch set-key {provider_name}' or set {env_var_name} environment variable"
487
+ )
488
+ raise typer.Exit(1)
489
+
490
+ if not model:
491
+ model = settings.get_default_model(provider_name)
492
+
493
+ kwargs = {}
494
+ if provider_name == "local":
495
+ base_url = settings.get_base_url(provider_name)
496
+ if base_url:
497
+ kwargs["base_url"] = base_url
498
+
499
+ return get_provider(provider_name, model=model, api_key=api_key, **kwargs)
500
+
501
+
502
+ def start_repl(
503
+ provider: str | None = None,
504
+ model: str | None = None,
505
+ resume: str | None = None,
506
+ message_limit: int = DEFAULT_MESSAGE_LIMIT,
507
+ show_banner: bool = True,
508
+ ):
509
+ """Start the interactive REPL session."""
510
+ if show_banner:
511
+ print_banner()
512
+
513
+ settings = Settings.load()
514
+ session_manager = SessionManager(message_limit=message_limit)
515
+
516
+ # Reset task manager for new session
517
+ reset_task_manager()
518
+
519
+ context_summary = None
520
+
521
+ # Handle session resumption
522
+ if resume:
523
+ try:
524
+ session_info, messages = session_manager.load_session(resume)
525
+ provider = session_info["provider"]
526
+ model = session_info["model"]
527
+ context_summary = session_info.get("summary")
528
+
529
+ print_success(f"Resumed session: {resume}")
530
+ print_info(f"Provider: {provider} | Model: {model} | Messages: {len(messages)}")
531
+
532
+ if context_summary:
533
+ print_info("Session has context from previous conversation")
534
+
535
+ except ValueError as e:
536
+ print_error(str(e))
537
+ raise typer.Exit(1) from e
538
+ else:
539
+ messages = []
540
+
541
+ if not provider:
542
+ provider = settings.default_provider
543
+
544
+ llm = create_provider(provider, model, settings)
545
+
546
+ # Create new session if not resuming
547
+ if not resume:
548
+ session_manager.create_session(llm.name, llm.model)
549
+
550
+ tools = [
551
+ ShellTool(),
552
+ OpenTerminalTool(),
553
+ TerminalSessionTool(),
554
+ FilesystemTool(),
555
+ SearchTool(),
556
+ GrepTool(),
557
+ EditTool(),
558
+ TaskTool(),
559
+ WebSearchTool(),
560
+ WebFetchTool(),
561
+ ]
562
+
563
+ # Create mode manager (shared between agent and executor)
564
+ mode_manager = ModeManager(default_mode=AgentMode.ASK)
565
+
566
+ executor = ToolExecutor(tools=tools, require_confirmation=True, mode_manager=mode_manager)
567
+ planner = SimplePlanner()
568
+
569
+ def on_session_continue(new_session_id: str):
570
+ print_info(f"Session continued: {new_session_id}")
571
+
572
+ agent = Agent(
573
+ provider=llm,
574
+ planner=planner,
575
+ executor=executor,
576
+ tools=tools,
577
+ session_manager=session_manager,
578
+ on_session_continue=on_session_continue,
579
+ mode_manager=mode_manager,
580
+ )
581
+
582
+ if messages:
583
+ agent.set_history(messages)
584
+
585
+ if context_summary:
586
+ agent.set_context_summary(context_summary)
587
+
588
+ # Get current working directory for display
589
+ cwd = os.getcwd()
590
+ cwd_short = os.path.basename(cwd) or cwd
591
+
592
+ # Show session info
593
+ console.print(
594
+ f" [dim]Provider:[/dim] [cyan]{llm.name}[/cyan] [dim]Model:[/dim] [cyan]{llm.model}[/cyan]"
595
+ )
596
+ console.print(
597
+ f" [dim]Session:[/dim] {session_manager.current_session_id} [dim]cwd:[/dim] {cwd_short}"
598
+ )
599
+ console.print(
600
+ f" [dim]Mode:[/dim] {mode_manager.get_mode_display()} [dim]- Type[/dim] / [dim]to see commands[/dim]\n"
601
+ )
602
+
603
+ # Create completer for slash commands
604
+ completer = SlashCommandCompleter()
605
+
606
+ # Track current provider/model for switching
607
+ current_llm = llm
608
+ current_settings = settings
609
+
610
+ def get_prompt():
611
+ """Generate prompt with mode indicator."""
612
+ mode_indicator = {
613
+ AgentMode.PLAN: "[yellow]P[/yellow]",
614
+ AgentMode.AUTO: "[green]A[/green]",
615
+ AgentMode.ASK: "[blue]?[/blue]",
616
+ }.get(mode_manager.mode, "")
617
+ return f"[{mode_indicator}] {cwd_short}> "
618
+
619
+ while True:
620
+ try:
621
+ # Build prompt with mode indicator
622
+ mode_char = {"plan": "P", "auto": "A", "ask": "?"}.get(mode_manager.mode.value, "?")
623
+ prompt_str = f"[{mode_char}] {cwd_short}> "
624
+
625
+ # Use prompt_toolkit with autocomplete
626
+ user_input = pt_prompt(
627
+ prompt_str,
628
+ completer=completer,
629
+ complete_while_typing=True,
630
+ style=PROMPT_STYLE,
631
+ )
632
+
633
+ if user_input.lower() in ("exit", "quit", "q"):
634
+ print_info(f"Session saved: {session_manager.current_session_id}")
635
+ break
636
+
637
+ if user_input.strip() == "":
638
+ continue
639
+
640
+ # Handle slash commands
641
+ if user_input.startswith("/"):
642
+ cmd_parts = user_input[1:].strip().split(maxsplit=1)
643
+ cmd = cmd_parts[0].lower() if cmd_parts else ""
644
+ cmd_arg = cmd_parts[1] if len(cmd_parts) > 1 else None
645
+
646
+ if cmd == "help":
647
+ console.print("\n[bold]Available Commands:[/bold]")
648
+ for slash_cmd, desc in SLASH_COMMANDS.items():
649
+ console.print(f" [cyan]{slash_cmd:<14}[/cyan] - {desc}")
650
+ console.print(f" [cyan]{'exit':<14}[/cyan] - Exit DevOrch")
651
+ console.print("\n[bold]Modes:[/bold]")
652
+ console.print(
653
+ " [yellow]PLAN[/yellow] - Shows plan before executing, asks for approval"
654
+ )
655
+ console.print(
656
+ " [green]AUTO[/green] - Executes tools automatically (trusted mode)"
657
+ )
658
+ console.print(" [blue]ASK[/blue] - Asks before each tool execution (default)")
659
+ console.print("\n[dim]Tip: Type / and use Tab for autocomplete[/dim]\n")
660
+ continue
661
+
662
+ elif cmd == "mode":
663
+ if cmd_arg:
664
+ mode_name = cmd_arg.lower()
665
+ else:
666
+ # Interactive mode selection
667
+ mode_choices = [
668
+ questionary.Choice(
669
+ f"{'> ' if mode_manager.mode == AgentMode.PLAN else ' '}PLAN - Shows plan before executing, asks for approval",
670
+ value="plan",
671
+ ),
672
+ questionary.Choice(
673
+ f"{'> ' if mode_manager.mode == AgentMode.AUTO else ' '}AUTO - Executes tools automatically (trusted mode)",
674
+ value="auto",
675
+ ),
676
+ questionary.Choice(
677
+ f"{'> ' if mode_manager.mode == AgentMode.ASK else ' '}ASK - Asks before each tool execution (default)",
678
+ value="ask",
679
+ ),
680
+ ]
681
+ try:
682
+ mode_name = questionary.select(
683
+ "Select mode:",
684
+ choices=mode_choices,
685
+ style=QUESTIONARY_STYLE,
686
+ instruction="(Use arrow keys)",
687
+ ).ask()
688
+ if not mode_name:
689
+ continue
690
+ except (KeyboardInterrupt, EOFError):
691
+ continue
692
+
693
+ if mode_name in ("plan", "auto", "ask"):
694
+ mode_manager.mode = AgentMode(mode_name)
695
+ print_success(f"Switched to {mode_name.upper()} mode")
696
+ console.print(f" [dim]{mode_manager.get_mode_description()}[/dim]")
697
+ else:
698
+ print_error(f"Unknown mode: {mode_name}")
699
+ continue
700
+
701
+ elif cmd == "plan":
702
+ mode_manager.mode = AgentMode.PLAN
703
+ print_success("Switched to PLAN mode")
704
+ console.print(" [dim]I'll show you the plan before executing anything[/dim]")
705
+ continue
706
+
707
+ elif cmd == "auto":
708
+ mode_manager.mode = AgentMode.AUTO
709
+ print_success("Switched to AUTO mode")
710
+ console.print(
711
+ " [dim]I'll execute tools automatically (dangerous commands still blocked)[/dim]"
712
+ )
713
+ continue
714
+
715
+ elif cmd == "ask":
716
+ mode_manager.mode = AgentMode.ASK
717
+ print_success("Switched to ASK mode")
718
+ console.print(" [dim]I'll ask before each tool execution[/dim]")
719
+ continue
720
+
721
+ elif cmd == "clear":
722
+ agent.history = []
723
+ print_success("Conversation cleared.")
724
+ continue
725
+
726
+ elif cmd == "status":
727
+ console.print("\n[bold]Status[/bold]")
728
+ console.print(f" [dim]Provider:[/dim] [cyan]{current_llm.name}[/cyan]")
729
+ console.print(f" [dim]Model:[/dim] [cyan]{current_llm.model}[/cyan]")
730
+ console.print(f" [dim]Mode:[/dim] {mode_manager.get_mode_display()}")
731
+ console.print()
732
+ continue
733
+
734
+ elif cmd == "session":
735
+ console.print("\n[bold]Current Session[/bold]")
736
+ print_info(f"Session ID: {session_manager.current_session_id}")
737
+ print_info(f"Messages: {len(agent.history)}")
738
+ print_info(f"Provider: {current_llm.name}")
739
+ print_info(f"Model: {current_llm.model}")
740
+ console.print(f" [dim]Mode:[/dim] {mode_manager.get_mode_display()}")
741
+ console.print()
742
+ continue
743
+
744
+ elif cmd == "config":
745
+ console.print("\n[bold]Configuration[/bold]")
746
+ print_info(f"Provider: {current_llm.name}")
747
+ print_info(f"Model: {current_llm.model}")
748
+ print_info(f"Session limit: {session_manager.message_limit} messages")
749
+ print_info(
750
+ f"Keyring: {'available' if keyring_available() else 'not available'}"
751
+ )
752
+ console.print()
753
+ continue
754
+
755
+ elif cmd == "permissions":
756
+ perms = get_permissions()
757
+ console.print("\n[bold]Tool Permissions:[/bold]")
758
+ for tool_name, perm in perms.tools.items():
759
+ level_color = {"allow": "green", "deny": "red", "ask": "yellow"}.get(
760
+ perm.level.value, "white"
761
+ )
762
+ console.print(
763
+ f" {tool_name}: [{level_color}]{perm.level.value}[/{level_color}]"
764
+ )
765
+ if perms.session_allowed:
766
+ console.print(
767
+ f"\n[green]Session allowed:[/green] {len(perms.session_allowed)} patterns"
768
+ )
769
+ console.print()
770
+ continue
771
+
772
+ elif cmd == "compact":
773
+ print_info("Compacting conversation history...")
774
+ summary = agent._generate_summary()
775
+ agent.history = []
776
+ agent.set_context_summary(summary)
777
+ print_success("History compacted. Summary preserved.")
778
+ continue
779
+
780
+ elif cmd == "models":
781
+ console.print(f"\n[bold]Available models for {current_llm.name}:[/bold]")
782
+ try:
783
+ with console.status("[dim]Fetching models...", spinner="dots"):
784
+ models = current_llm.list_models()
785
+ for m in models[:30]: # Limit display
786
+ marker = "[green]>[/green]" if m.id == current_llm.model else " "
787
+ ctx = f" ({m.context_length} ctx)" if m.context_length else ""
788
+ # Show tool capability warning for local models
789
+ desc = ""
790
+ if m.description:
791
+ if "no tool" in m.description.lower():
792
+ desc = f" [yellow]{m.description}[/yellow]"
793
+ else:
794
+ desc = f" [dim]{m.description}[/dim]"
795
+ console.print(f" {marker} {m.id}{ctx}{desc}")
796
+ if len(models) > 30:
797
+ console.print(f" [dim]... and {len(models) - 30} more[/dim]")
798
+ if current_llm.name == "local":
799
+ console.print(
800
+ "\n [dim]For tool/function calling, use 7B+ models[/dim]"
801
+ )
802
+ except Exception as e:
803
+ print_error(f"Failed to fetch models: {e}")
804
+ console.print("\n[dim]Use /model <name> to switch[/dim]\n")
805
+ continue
806
+
807
+ elif cmd == "model":
808
+ selected_model = cmd_arg
809
+
810
+ if not selected_model:
811
+ # Interactive model selection
812
+ try:
813
+ with console.status("[cyan]Fetching models...", spinner="dots"):
814
+ models = current_llm.list_models()
815
+
816
+ if models:
817
+ model_choices = []
818
+ for m in models[:20]:
819
+ is_current = m.id == current_llm.model
820
+ prefix = "> " if is_current else " "
821
+ ctx = f" ({m.context_length} ctx)" if m.context_length else ""
822
+ model_choices.append(
823
+ questionary.Choice(f"{prefix}{m.id}{ctx}", value=m.id)
824
+ )
825
+
826
+ selected_model = questionary.select(
827
+ f"Select model for {current_llm.name}:",
828
+ choices=model_choices,
829
+ style=QUESTIONARY_STYLE,
830
+ instruction="(Use arrow keys)",
831
+ ).ask()
832
+
833
+ if not selected_model:
834
+ continue
835
+ else:
836
+ print_warning("No models available")
837
+ continue
838
+ except Exception as e:
839
+ print_error(f"Failed to fetch models: {e}")
840
+ continue
841
+
842
+ try:
843
+ # Build kwargs for provider (include base_url for local)
844
+ provider_kwargs = {}
845
+ if current_llm.name == "local":
846
+ base_url = current_settings.get_base_url(current_llm.name)
847
+ if base_url:
848
+ provider_kwargs["base_url"] = base_url
849
+
850
+ # Get API key - prefer current provider's key, then settings
851
+ api_key = getattr(
852
+ current_llm, "api_key", None
853
+ ) or current_settings.get_api_key(current_llm.name)
854
+
855
+ new_llm = get_provider(
856
+ current_llm.name,
857
+ model=selected_model,
858
+ api_key=api_key,
859
+ **provider_kwargs,
860
+ )
861
+ current_llm = new_llm
862
+ agent.provider = new_llm
863
+
864
+ # Save model selection to settings
865
+ try:
866
+ if current_llm.name not in current_settings.providers:
867
+ current_settings.providers[current_llm.name] = ProviderConfig()
868
+ current_settings.providers[
869
+ current_llm.name
870
+ ].default_model = selected_model
871
+ save_config(current_settings)
872
+ print_success(f"Switched to model: {selected_model}")
873
+ except Exception:
874
+ print_success(f"Switched to model: {selected_model}")
875
+ except Exception as e:
876
+ print_error(f"Failed to switch model: {e}")
877
+ continue
878
+
879
+ elif cmd == "providers":
880
+ console.print("\n[bold]Available Providers:[/bold]")
881
+ for name, desc in PROVIDER_INFO.items():
882
+ marker = "[green]>[/green]" if name == current_llm.name else " "
883
+ env_var = PROVIDER_ENV_VARS.get(name)
884
+ key_status = ""
885
+ if env_var:
886
+ has_key = bool(current_settings.get_api_key(name))
887
+ key_status = (
888
+ " [green](configured)[/green]"
889
+ if has_key
890
+ else " [yellow](needs key)[/yellow]"
891
+ )
892
+ elif name in ("local", "lmstudio"):
893
+ key_status = " [dim](no key needed)[/dim]"
894
+ console.print(f" {marker} [cyan]{name:<12}[/cyan] - {desc}{key_status}")
895
+ console.print("\n[dim]Use /provider <name> to switch[/dim]\n")
896
+ continue
897
+
898
+ elif cmd == "provider":
899
+ if cmd_arg:
900
+ new_provider = cmd_arg.lower()
901
+ else:
902
+ # Interactive provider selection
903
+ provider_choices = []
904
+ for name, desc in PROVIDER_INFO.items():
905
+ has_key = bool(current_settings.get_api_key(name))
906
+ status = ""
907
+ if name in ("local", "lmstudio"):
908
+ status = " (local)"
909
+ elif has_key:
910
+ status = " (configured)"
911
+ else:
912
+ status = " (needs key)"
913
+
914
+ is_current = name == current_llm.name
915
+ display = f"{'> ' if is_current else ' '}{name} - {desc}{status}"
916
+ provider_choices.append(questionary.Choice(display, value=name))
917
+
918
+ try:
919
+ new_provider = questionary.select(
920
+ "Select provider:",
921
+ choices=provider_choices,
922
+ style=QUESTIONARY_STYLE,
923
+ instruction="(Use arrow keys)",
924
+ ).ask()
925
+ if not new_provider:
926
+ continue
927
+ except (KeyboardInterrupt, EOFError):
928
+ continue
929
+
930
+ if new_provider not in PROVIDERS:
931
+ print_error(f"Unknown provider: {new_provider}")
932
+ continue
933
+
934
+ # Check if provider needs API key and doesn't have one
935
+ needs_key = new_provider not in ("local", "lmstudio")
936
+ has_key = bool(current_settings.get_api_key(new_provider))
937
+
938
+ entered_key = None
939
+ selected_model = None
940
+
941
+ if needs_key and not has_key:
942
+ # Prompt for API key with questionary
943
+ env_var = PROVIDER_ENV_VARS.get(
944
+ new_provider, f"{new_provider.upper()}_API_KEY"
945
+ )
946
+ console.print(
947
+ Panel(
948
+ f"[bold]Setting up {new_provider}[/bold]\n"
949
+ f"[dim]You can also set {env_var} environment variable[/dim]",
950
+ border_style="yellow",
951
+ )
952
+ )
953
+
954
+ try:
955
+ api_key = questionary.password(
956
+ f"Enter your {new_provider} API key:", style=QUESTIONARY_STYLE
957
+ ).ask()
958
+
959
+ if api_key and api_key.strip():
960
+ entered_key = api_key.strip()
961
+
962
+ # Store in keyring
963
+ if keyring_available():
964
+ set_api_key(new_provider, entered_key)
965
+ print_success("API key stored in keychain!")
966
+
967
+ # Update settings
968
+ if new_provider not in current_settings.providers:
969
+ current_settings.providers[new_provider] = ProviderConfig()
970
+ current_settings.providers[new_provider].api_key = entered_key
971
+
972
+ # Offer model selection with questionary
973
+ try:
974
+ with console.status("[cyan]Fetching models...", spinner="dots"):
975
+ temp_llm = get_provider(new_provider, api_key=entered_key)
976
+ models = temp_llm.list_models()
977
+ if models:
978
+ model_choices = [
979
+ questionary.Choice(m.id, value=m.id)
980
+ for m in models[:12]
981
+ ]
982
+ selected_model = questionary.select(
983
+ "Select a model:",
984
+ choices=model_choices,
985
+ style=QUESTIONARY_STYLE,
986
+ ).ask()
987
+ if selected_model:
988
+ current_settings.providers[
989
+ new_provider
990
+ ].default_model = selected_model
991
+ except Exception:
992
+ pass # Model selection is optional
993
+ else:
994
+ print_error("API key cannot be empty.")
995
+ continue
996
+ except (KeyboardInterrupt, EOFError):
997
+ console.print("\nCancelled.")
998
+ continue
999
+
1000
+ try:
1001
+ # Use entered key directly if we just got it, otherwise use settings
1002
+ if entered_key:
1003
+ new_llm = get_provider(
1004
+ new_provider, model=selected_model, api_key=entered_key
1005
+ )
1006
+ else:
1007
+ new_llm = create_provider(new_provider, None, current_settings)
1008
+ current_llm = new_llm
1009
+ agent.provider = new_llm
1010
+
1011
+ # Save provider selection to settings
1012
+ try:
1013
+ current_settings.default_provider = new_provider
1014
+ save_config(current_settings)
1015
+ print_success(f"Switched to: {new_provider} ({new_llm.model})")
1016
+ except Exception:
1017
+ print_success(f"Switched to: {new_provider} ({new_llm.model})")
1018
+ except Exception as e:
1019
+ print_error(f"Failed to switch provider: {e}")
1020
+ continue
1021
+
1022
+ elif cmd == "history":
1023
+ console.print("\n[bold]Conversation History[/bold]")
1024
+ if not agent.history:
1025
+ print_info("No messages yet.")
1026
+ else:
1027
+ for _i, msg in enumerate(agent.history[-10:], 1):
1028
+ role_color = {
1029
+ "user": "green",
1030
+ "assistant": "blue",
1031
+ "tool": "yellow",
1032
+ }.get(msg.role, "white")
1033
+ content_preview = (
1034
+ (msg.content[:80] + "...") if len(msg.content) > 80 else msg.content
1035
+ )
1036
+ console.print(
1037
+ f" [{role_color}]{msg.role}[/{role_color}]: {content_preview}"
1038
+ )
1039
+ if len(agent.history) > 10:
1040
+ console.print(
1041
+ f" [dim]... and {len(agent.history) - 10} more messages[/dim]"
1042
+ )
1043
+ console.print()
1044
+ continue
1045
+
1046
+ elif cmd == "undo":
1047
+ if agent.history:
1048
+ # Remove last user message and any following assistant/tool messages
1049
+ removed = 0
1050
+ while agent.history and agent.history[-1].role != "user":
1051
+ agent.history.pop()
1052
+ removed += 1
1053
+ if agent.history and agent.history[-1].role == "user":
1054
+ agent.history.pop()
1055
+ removed += 1
1056
+ print_success(f"Removed {removed} message(s)")
1057
+ else:
1058
+ print_warning("No messages to undo")
1059
+ continue
1060
+
1061
+ elif cmd == "save":
1062
+ filename = (
1063
+ cmd_arg or f"devorch_session_{session_manager.current_session_id}.txt"
1064
+ )
1065
+ try:
1066
+ with open(filename, "w") as f:
1067
+ for msg in agent.history:
1068
+ f.write(f"[{msg.role}]\n{msg.content}\n\n")
1069
+ print_success(f"Saved to: {filename}")
1070
+ except Exception as e:
1071
+ print_error(f"Failed to save: {e}")
1072
+ continue
1073
+
1074
+ elif cmd == "tasks":
1075
+ task_manager = get_task_manager()
1076
+ if task_manager.task_list.total_count == 0:
1077
+ print_info("No tasks in progress.")
1078
+ else:
1079
+ panel = task_manager._create_panel()
1080
+ console.print(panel)
1081
+ continue
1082
+
1083
+ else:
1084
+ print_warning(f"Unknown command: /{cmd}")
1085
+ print_info("Type /help to see available commands")
1086
+ continue
1087
+
1088
+ result = agent.run(user_input, max_iterations=15)
1089
+ print_panel(result, title="DevOrch", border_style="green")
1090
+
1091
+ except (typer.Abort, EOFError):
1092
+ print_info(f"\nSession saved: {session_manager.current_session_id}")
1093
+ break
1094
+ except KeyboardInterrupt:
1095
+ console.print() # New line after ^C
1096
+ continue # Don't exit on Ctrl+C, just cancel current input
1097
+ except Exception as e:
1098
+ error_str = str(e).lower()
1099
+ print_error(str(e))
1100
+
1101
+ # Provide helpful hints for common errors
1102
+ if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str:
1103
+ console.print("[dim] Tip: Your API key may be invalid. Try:[/dim]")
1104
+ console.print("[dim] - /provider <name> to switch providers[/dim]")
1105
+ console.print(
1106
+ f"[dim] - devorch set-key {current_llm.name} to update the key[/dim]"
1107
+ )
1108
+ elif (
1109
+ "402" in error_str
1110
+ or "payment" in error_str
1111
+ or "quota" in error_str
1112
+ or "rate" in error_str
1113
+ ):
1114
+ console.print("[dim] Tip: You may have exceeded your quota or rate limit.[/dim]")
1115
+ console.print("[dim] - /provider <name> to switch to another provider[/dim]")
1116
+ elif "connection" in error_str or "timeout" in error_str or "network" in error_str:
1117
+ console.print("[dim] Tip: Network error. Check your connection.[/dim]")
1118
+ if current_llm.name == "local":
1119
+ console.print("[dim] - Make sure Ollama is running: ollama serve[/dim]")
1120
+
1121
+
1122
+ @app.callback()
1123
+ def main_callback(
1124
+ ctx: typer.Context,
1125
+ provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"),
1126
+ model: str = typer.Option(None, "--model", "-m", help="Model name"),
1127
+ resume: str = typer.Option(None, "--resume", "-r", help="Resume session by ID"),
1128
+ ):
1129
+ """
1130
+ DevOrch - Your AI Coding Assistant
1131
+
1132
+ Just run 'devorch' to start chatting!
1133
+ """
1134
+ # If a subcommand is being invoked, don't run the default behavior
1135
+ if ctx.invoked_subcommand is not None:
1136
+ return
1137
+
1138
+ # No subcommand - run the default REPL behavior
1139
+ settings = Settings.load()
1140
+
1141
+ # Check if we need onboarding
1142
+ if not has_any_provider_configured(settings):
1143
+ configured_provider = run_onboarding()
1144
+ if not configured_provider:
1145
+ raise typer.Exit(1)
1146
+ # Reload settings after onboarding
1147
+ settings = Settings.load()
1148
+ provider = configured_provider
1149
+
1150
+ # Start REPL
1151
+ start_repl(provider=provider, model=model, resume=resume)
1152
+
1153
+
1154
+ @app.command()
1155
+ def chat(
1156
+ provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"),
1157
+ model: str = typer.Option(None, "--model", "-m", help="Model name"),
1158
+ resume: str = typer.Option(None, "--resume", "-r", help="Resume session by ID"),
1159
+ message_limit: int = typer.Option(
1160
+ DEFAULT_MESSAGE_LIMIT, "--limit", "-l", help="Messages before auto-summarization"
1161
+ ),
1162
+ ):
1163
+ """
1164
+ Start an interactive chat session (alias for running devorch directly).
1165
+ """
1166
+ settings = Settings.load()
1167
+
1168
+ if not has_any_provider_configured(settings):
1169
+ configured_provider = run_onboarding()
1170
+ if not configured_provider:
1171
+ raise typer.Exit(1)
1172
+ settings = Settings.load()
1173
+ provider = configured_provider
1174
+
1175
+ start_repl(provider=provider, model=model, resume=resume, message_limit=message_limit)
1176
+
1177
+
1178
+ @app.command()
1179
+ def ask(
1180
+ prompt: str = typer.Argument(..., help="The prompt or question for DevOrch"),
1181
+ provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"),
1182
+ model: str = typer.Option(None, "--model", "-m", help="Model name"),
1183
+ ):
1184
+ """
1185
+ Ask DevOrch a single question (non-interactive).
1186
+ """
1187
+ settings = Settings.load()
1188
+
1189
+ if not provider:
1190
+ provider = settings.default_provider
1191
+
1192
+ llm = create_provider(provider, model, settings)
1193
+
1194
+ tools = [
1195
+ ShellTool(),
1196
+ OpenTerminalTool(),
1197
+ TerminalSessionTool(),
1198
+ FilesystemTool(),
1199
+ SearchTool(),
1200
+ GrepTool(),
1201
+ EditTool(),
1202
+ TaskTool(),
1203
+ WebSearchTool(),
1204
+ WebFetchTool(),
1205
+ ]
1206
+ executor = ToolExecutor(tools=tools)
1207
+ planner = SimplePlanner()
1208
+
1209
+ agent = Agent(provider=llm, planner=planner, executor=executor, tools=tools)
1210
+
1211
+ console.print(f"[dim]Using {llm.name}/{llm.model}[/dim]")
1212
+ try:
1213
+ result = agent.run(prompt, max_iterations=15)
1214
+ print_panel(result, title="DevOrch", border_style="green")
1215
+ except Exception as e:
1216
+ print_error(str(e))
1217
+
1218
+
1219
+ @app.command()
1220
+ def config():
1221
+ """
1222
+ Show current configuration.
1223
+ """
1224
+ settings = Settings.load()
1225
+
1226
+ console.print("\n[bold]DevOrch Configuration[/bold]\n")
1227
+ console.print(f"Default Provider: [cyan]{settings.default_provider}[/cyan]")
1228
+ console.print(
1229
+ f"Keyring Available: {'[green]yes[/green]' if keyring_available() else '[yellow]no[/yellow]'}"
1230
+ )
1231
+ console.print("\n[bold]Providers:[/bold]")
1232
+
1233
+ for name in PROVIDERS.keys():
1234
+ provider_config = settings.providers.get(name)
1235
+ if provider_config:
1236
+ if name == "local":
1237
+ key_status = "[dim]not required[/dim]"
1238
+ elif provider_config.api_key:
1239
+ if provider_config.key_encrypted:
1240
+ key_status = "[green]configured (encrypted)[/green]"
1241
+ else:
1242
+ key_status = "[green]configured[/green]"
1243
+ else:
1244
+ key_status = "[yellow]not set[/yellow]"
1245
+ model = provider_config.default_model or "default"
1246
+ console.print(f" [bold]{name}[/bold]: {model} (API key: {key_status})")
1247
+
1248
+
1249
+ @app.command("set-key")
1250
+ def set_key(
1251
+ provider: str = typer.Argument(..., help="Provider name (openai, anthropic, gemini)"),
1252
+ set_default: bool = typer.Option(
1253
+ True, "--default/--no-default", help="Set as default provider"
1254
+ ),
1255
+ ):
1256
+ """
1257
+ Securely store an API key for a provider.
1258
+ """
1259
+ if provider.lower() not in PROVIDERS:
1260
+ print_error(f"Unknown provider '{provider}'. Available: {', '.join(PROVIDERS.keys())}")
1261
+ raise typer.Exit(1)
1262
+
1263
+ if provider.lower() == "local":
1264
+ print_warning("Local provider doesn't require an API key.")
1265
+ raise typer.Exit(0)
1266
+
1267
+ if not keyring_available():
1268
+ print_error("Keyring is not available on this system.")
1269
+ print_error("Please set API keys via environment variables instead.")
1270
+ raise typer.Exit(1)
1271
+
1272
+ api_key = typer.prompt(f"Enter API key for {provider}", hide_input=True)
1273
+
1274
+ if not api_key.strip():
1275
+ print_error("API key cannot be empty.")
1276
+ raise typer.Exit(1)
1277
+
1278
+ if set_api_key(provider.lower(), api_key.strip()):
1279
+ print_success(f"API key for {provider} stored securely.")
1280
+
1281
+ # Also set as default provider
1282
+ if set_default:
1283
+ settings = Settings.load()
1284
+ settings.default_provider = provider.lower()
1285
+ try:
1286
+ save_config(settings)
1287
+ print_success(f"Set {provider} as default provider.")
1288
+ except Exception:
1289
+ print_warning(
1290
+ f"Key stored but couldn't save as default. Use: devorch -p {provider}"
1291
+ )
1292
+ else:
1293
+ print_error("Failed to store API key.")
1294
+ raise typer.Exit(1)
1295
+
1296
+
1297
+ @app.command()
1298
+ def providers():
1299
+ """
1300
+ List available providers.
1301
+ """
1302
+ console.print("\n[bold]Available Providers:[/bold]\n")
1303
+ for name in PROVIDERS.keys():
1304
+ console.print(f" - {name}")
1305
+
1306
+
1307
+ # Session commands
1308
+ @sessions_app.command("list")
1309
+ def sessions_list(
1310
+ limit: int = typer.Option(20, "--limit", "-n", help="Number of sessions to show"),
1311
+ ):
1312
+ """
1313
+ List recent chat sessions.
1314
+ """
1315
+ session_manager = SessionManager()
1316
+ sessions = session_manager.list_sessions(limit=limit)
1317
+
1318
+ if not sessions:
1319
+ print_info("No sessions found.")
1320
+ return
1321
+
1322
+ table = Table(title="Chat Sessions")
1323
+ table.add_column("ID", style="cyan")
1324
+ table.add_column("Name", style="white")
1325
+ table.add_column("Provider", style="green")
1326
+ table.add_column("Model", style="blue")
1327
+ table.add_column("Msgs", justify="right")
1328
+ table.add_column("Parent", style="dim")
1329
+ table.add_column("Updated", style="dim")
1330
+
1331
+ for session in sessions:
1332
+ parent = session.get("parent_session_id") or "-"
1333
+ table.add_row(
1334
+ session["id"],
1335
+ (session["name"] or "-")[:20],
1336
+ session["provider"],
1337
+ session["model"][:15],
1338
+ str(session["message_count"]),
1339
+ parent[:8] if parent != "-" else "-",
1340
+ session["updated_at"][:16],
1341
+ )
1342
+
1343
+ console.print(table)
1344
+
1345
+
1346
+ @sessions_app.command("show")
1347
+ def sessions_show(session_id: str = typer.Argument(..., help="Session ID to show details")):
1348
+ """
1349
+ Show details of a specific session.
1350
+ """
1351
+ session_manager = SessionManager()
1352
+
1353
+ try:
1354
+ session_info, messages = session_manager.load_session(session_id)
1355
+ except ValueError as e:
1356
+ print_error(str(e))
1357
+ raise typer.Exit(1) from e
1358
+
1359
+ console.print(f"\n[bold]Session: {session_id}[/bold]")
1360
+ console.print(f"Name: {session_info.get('name', '-')}")
1361
+ console.print(f"Provider: {session_info['provider']}")
1362
+ console.print(f"Model: {session_info['model']}")
1363
+ console.print(f"Messages: {len(messages)}")
1364
+
1365
+ if session_info.get("parent_session_id"):
1366
+ console.print(f"Parent: {session_info['parent_session_id']}")
1367
+
1368
+ if session_info.get("summary"):
1369
+ console.print("\n[bold]Context Summary:[/bold]")
1370
+ summary = session_info["summary"]
1371
+ print_panel(summary[:500] + "..." if len(summary) > 500 else summary, border_style="dim")
1372
+
1373
+
1374
+ @sessions_app.command("delete")
1375
+ def sessions_delete(session_id: str = typer.Argument(..., help="Session ID to delete")):
1376
+ """
1377
+ Delete a chat session.
1378
+ """
1379
+ session_manager = SessionManager()
1380
+
1381
+ if not session_manager.session_exists(session_id):
1382
+ print_error(f"Session '{session_id}' not found.")
1383
+ raise typer.Exit(1)
1384
+
1385
+ if session_manager.delete_session(session_id):
1386
+ print_success(f"Session '{session_id}' deleted.")
1387
+ else:
1388
+ print_error("Failed to delete session.")
1389
+ raise typer.Exit(1)
1390
+
1391
+
1392
+ @sessions_app.command("clear")
1393
+ def sessions_clear(force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation")):
1394
+ """
1395
+ Delete all chat sessions.
1396
+ """
1397
+ session_manager = SessionManager()
1398
+ sessions = session_manager.list_sessions(limit=1000)
1399
+
1400
+ if not sessions:
1401
+ print_info("No sessions to delete.")
1402
+ return
1403
+
1404
+ if not force:
1405
+ confirm = typer.confirm(f"Delete {len(sessions)} sessions?")
1406
+ if not confirm:
1407
+ print_warning("Cancelled.")
1408
+ return
1409
+
1410
+ deleted = 0
1411
+ for session in sessions:
1412
+ if session_manager.delete_session(session["id"]):
1413
+ deleted += 1
1414
+
1415
+ print_success(f"Deleted {deleted} sessions.")
1416
+
1417
+
1418
+ # Permission commands
1419
+ @permissions_app.command("list")
1420
+ def permissions_list():
1421
+ """
1422
+ Show current permission settings.
1423
+ """
1424
+ permissions = get_permissions()
1425
+
1426
+ console.print("\n[bold]Tool Permissions[/bold]\n")
1427
+
1428
+ for tool_name, perm in permissions.tools.items():
1429
+ level_color = {
1430
+ PermissionLevel.ALLOW: "green",
1431
+ PermissionLevel.DENY: "red",
1432
+ PermissionLevel.ASK: "yellow",
1433
+ }.get(perm.level, "white")
1434
+
1435
+ console.print(
1436
+ f"[bold]{tool_name}[/bold]: [{level_color}]{perm.level.value}[/{level_color}]"
1437
+ )
1438
+
1439
+ if perm.allowed_patterns:
1440
+ console.print(f" [green]Allowed patterns:[/green] {len(perm.allowed_patterns)}")
1441
+ for p in perm.allowed_patterns[:5]:
1442
+ console.print(f" - {p}")
1443
+ if len(perm.allowed_patterns) > 5:
1444
+ console.print(f" [dim]... and {len(perm.allowed_patterns) - 5} more[/dim]")
1445
+
1446
+ if perm.denied_patterns:
1447
+ console.print(f" [red]Denied patterns:[/red] {len(perm.denied_patterns)}")
1448
+ for p in perm.denied_patterns[:3]:
1449
+ console.print(f" - {p}")
1450
+
1451
+ # Session permissions
1452
+ if permissions.session_allowed or permissions.session_denied:
1453
+ console.print("\n[bold]Session Permissions (temporary)[/bold]")
1454
+ if permissions.session_allowed:
1455
+ console.print(f" [green]Allowed:[/green] {', '.join(permissions.session_allowed)}")
1456
+ if permissions.session_denied:
1457
+ console.print(f" [red]Denied:[/red] {', '.join(permissions.session_denied)}")
1458
+
1459
+ console.print(f"\n[dim]Config file: {PERMISSIONS_FILE}[/dim]")
1460
+
1461
+
1462
+ @permissions_app.command("allow")
1463
+ def permissions_allow(
1464
+ tool: str = typer.Argument(..., help="Tool name (shell, filesystem)"),
1465
+ pattern: str = typer.Argument(..., help="Command pattern to allow (e.g., 'git *')"),
1466
+ ):
1467
+ """
1468
+ Add a pattern to the allowed list for a tool.
1469
+ """
1470
+ permissions = get_permissions()
1471
+ permissions.add_allowed_pattern(tool, pattern, session_only=False)
1472
+ print_success(f"Added to allowed patterns for {tool}: {pattern}")
1473
+
1474
+
1475
+ @permissions_app.command("deny")
1476
+ def permissions_deny(
1477
+ tool: str = typer.Argument(..., help="Tool name (shell, filesystem)"),
1478
+ pattern: str = typer.Argument(..., help="Command pattern to deny"),
1479
+ ):
1480
+ """
1481
+ Add a pattern to the denied list for a tool.
1482
+ """
1483
+ permissions = get_permissions()
1484
+ permissions.add_denied_pattern(tool, pattern, session_only=False)
1485
+ print_success(f"Added to denied patterns for {tool}: {pattern}")
1486
+
1487
+
1488
+ @permissions_app.command("set")
1489
+ def permissions_set(
1490
+ tool: str = typer.Argument(..., help="Tool name (shell, filesystem, search)"),
1491
+ level: str = typer.Argument(..., help="Permission level (allow, deny, ask)"),
1492
+ ):
1493
+ """
1494
+ Set the default permission level for a tool.
1495
+ """
1496
+ try:
1497
+ perm_level = PermissionLevel(level.lower())
1498
+ except ValueError:
1499
+ print_error(f"Invalid level: {level}. Use: allow, deny, or ask")
1500
+ raise typer.Exit(1) from None
1501
+
1502
+ permissions = get_permissions()
1503
+ permissions.set_tool_permission(tool, perm_level)
1504
+ print_success(f"Set {tool} permission to: {perm_level.value}")
1505
+
1506
+
1507
+ @permissions_app.command("reset")
1508
+ def permissions_reset(force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation")):
1509
+ """
1510
+ Reset all permissions to defaults.
1511
+ """
1512
+ if not force:
1513
+ confirm = typer.confirm("Reset all permissions to defaults?")
1514
+ if not confirm:
1515
+ print_warning("Cancelled.")
1516
+ return
1517
+
1518
+ reset_permissions()
1519
+ print_success("Permissions reset to defaults.")
1520
+
1521
+
1522
+ def main():
1523
+ app()
1524
+
1525
+
1526
+ if __name__ == "__main__":
1527
+ main()