oshell 0.1.1__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.
oshell/cli.py ADDED
@@ -0,0 +1,836 @@
1
+ """oshell command-line interface.
2
+
3
+ Usage examples
4
+ --------------
5
+ oshell # start an interactive chat
6
+ oshell "explain async/await" # one-shot question
7
+ oshell --provider openai --model gpt-4o-mini "summarize this"
8
+ cat error.log | oshell "what is wrong here?"
9
+ oshell models # list available models
10
+ oshell config set provider openai
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import List, Optional
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.live import Live
22
+ from rich.markdown import Markdown
23
+ from rich.panel import Panel
24
+ from rich.prompt import Confirm, Prompt
25
+ from rich.syntax import Syntax
26
+
27
+ from . import __version__, history, personas, retrieval
28
+ from .agent import CodingAgent
29
+ from .config import Config
30
+ from .media import ImageGenerator, VideoGenerator
31
+ from .media_agent import MediaAgent
32
+ from .providers import Message, get_provider
33
+ from .storyboard import StoryboardAgent
34
+ from .tools import Toolbox
35
+
36
+ app = typer.Typer(
37
+ add_completion=False,
38
+ help="A fast, beautiful terminal chat for any LLM — OpenAI or local Ollama.",
39
+ no_args_is_help=False,
40
+ )
41
+ config_app = typer.Typer(help="View and edit configuration.")
42
+ app.add_typer(config_app, name="config")
43
+ persona_app = typer.Typer(help="Manage personas (system-prompt presets).")
44
+ app.add_typer(persona_app, name="persona")
45
+
46
+ console = Console()
47
+
48
+ SLASH_HELP = """\
49
+ [bold]Slash commands[/bold]
50
+ /exit, /quit end the chat
51
+ /clear forget the conversation so far
52
+ /system <txt> set a new system prompt
53
+ /help show this help
54
+ """
55
+
56
+
57
+ def _read_stdin() -> str:
58
+ """Return piped stdin text, if any."""
59
+ if not sys.stdin.isatty():
60
+ return sys.stdin.read().strip()
61
+ return ""
62
+
63
+
64
+ def _render_stream(provider, messages: List[Message]) -> str:
65
+ """Stream a response, rendering live Markdown. Returns full text."""
66
+ full = ""
67
+ try:
68
+ with Live(console=console, refresh_per_second=15, vertical_overflow="visible") as live:
69
+ for chunk in provider.stream_chat(messages):
70
+ full += chunk
71
+ live.update(Markdown(full))
72
+ except KeyboardInterrupt:
73
+ console.print("\n[yellow]Interrupted.[/yellow]")
74
+ return full
75
+
76
+
77
+ def _one_shot(prompt: str, cfg: Config) -> None:
78
+ provider = get_provider(cfg)
79
+ messages: List[Message] = [
80
+ {"role": "system", "content": personas.resolve_system_prompt(cfg)},
81
+ {"role": "user", "content": prompt},
82
+ ]
83
+ _render_stream(provider, messages)
84
+
85
+
86
+ def _interactive(cfg: Config) -> None:
87
+ provider = get_provider(cfg)
88
+ session = history.new_session_path()
89
+ system_prompt = personas.resolve_system_prompt(cfg)
90
+ messages: List[Message] = [{"role": "system", "content": system_prompt}]
91
+
92
+ persona_note = f" · persona [green]{cfg.persona}[/green]" if cfg.persona else ""
93
+ console.print(
94
+ Panel.fit(
95
+ f"[bold cyan]oshell[/bold cyan] v{__version__} "
96
+ f"· provider [green]{cfg.provider}[/green] "
97
+ f"· model [green]{cfg.model}[/green]" + persona_note + "\n"
98
+ "Type [bold]/help[/bold] for commands, [bold]/exit[/bold] to quit.",
99
+ border_style="cyan",
100
+ )
101
+ )
102
+
103
+ while True:
104
+ try:
105
+ user = Prompt.ask("[bold blue]you[/bold blue]")
106
+ except (EOFError, KeyboardInterrupt):
107
+ console.print("\n[dim]Bye![/dim]")
108
+ break
109
+
110
+ if not user.strip():
111
+ continue
112
+
113
+ if user.startswith("/"):
114
+ cmd, _, arg = user[1:].partition(" ")
115
+ cmd = cmd.lower()
116
+ if cmd in ("exit", "quit"):
117
+ console.print("[dim]Bye![/dim]")
118
+ break
119
+ if cmd == "clear":
120
+ messages = [{"role": "system", "content": system_prompt}]
121
+ console.print("[dim]Conversation cleared.[/dim]")
122
+ continue
123
+ if cmd == "system":
124
+ system_prompt = arg.strip() or system_prompt
125
+ messages[0] = {"role": "system", "content": system_prompt}
126
+ console.print("[dim]System prompt updated.[/dim]")
127
+ continue
128
+ if cmd == "help":
129
+ console.print(SLASH_HELP)
130
+ continue
131
+ console.print(f"[red]Unknown command:[/red] /{cmd}")
132
+ continue
133
+
134
+ messages.append({"role": "user", "content": user})
135
+ history.append(session, {"role": "user", "content": user})
136
+
137
+ console.print("[bold green]ai[/bold green]")
138
+ reply = _render_stream(provider, messages)
139
+ if reply:
140
+ messages.append({"role": "assistant", "content": reply})
141
+ history.append(session, {"role": "assistant", "content": reply})
142
+
143
+
144
+ @app.callback(invoke_without_command=True)
145
+ def main(
146
+ version: bool = typer.Option(
147
+ False, "--version", "-V", help="Show version and exit."
148
+ ),
149
+ ) -> None:
150
+ """A fast, beautiful terminal chat for any LLM."""
151
+ if version:
152
+ console.print(f"oshell v{__version__}")
153
+ raise typer.Exit()
154
+
155
+
156
+ @app.command()
157
+ def chat(
158
+ prompt: Optional[List[str]] = typer.Argument(
159
+ None, help="Ask a one-shot question. Omit to start interactive chat."
160
+ ),
161
+ provider: Optional[str] = typer.Option(
162
+ None, "--provider", "-p", help="LLM provider: ollama, openai, anthropic, groq, gemini."
163
+ ),
164
+ model: Optional[str] = typer.Option(
165
+ None, "--model", "-m", help="Model name to use."
166
+ ),
167
+ persona: Optional[str] = typer.Option(
168
+ None, "--persona", help="Named persona/preset (see `ai persona list`)."
169
+ ),
170
+ ) -> None:
171
+ """Start a chat, or answer a one-shot prompt (the default command)."""
172
+ cfg = Config.load()
173
+ if provider:
174
+ cfg.provider = provider
175
+ if model:
176
+ cfg.model = model
177
+ if persona:
178
+ cfg.persona = persona
179
+
180
+ piped = _read_stdin()
181
+ prompt_text = " ".join(prompt) if prompt else ""
182
+ combined = "\n\n".join(p for p in (prompt_text, piped) if p).strip()
183
+
184
+ try:
185
+ if combined:
186
+ _one_shot(combined, cfg)
187
+ else:
188
+ _interactive(cfg)
189
+ except ValueError as exc:
190
+ console.print(f"[red]Error:[/red] {exc}")
191
+ raise typer.Exit(code=1)
192
+ except Exception as exc: # noqa: BLE001 - surface a friendly message
193
+ console.print(f"[red]Request failed:[/red] {exc}")
194
+ raise typer.Exit(code=1)
195
+
196
+
197
+ @app.command()
198
+ def models() -> None:
199
+ """List models available from the current provider."""
200
+ cfg = Config.load()
201
+ try:
202
+ provider = get_provider(cfg)
203
+ except ValueError as exc:
204
+ console.print(f"[red]Error:[/red] {exc}")
205
+ raise typer.Exit(code=1)
206
+
207
+ found = provider.list_models()
208
+ if not found:
209
+ console.print(
210
+ f"[yellow]No models found for provider '{cfg.provider}'.[/yellow]"
211
+ )
212
+ raise typer.Exit(code=1)
213
+ console.print(f"[bold]Models ({cfg.provider}):[/bold]")
214
+ for name in found:
215
+ marker = " [green](current)[/green]" if name == cfg.model else ""
216
+ console.print(f" • {name}{marker}")
217
+
218
+
219
+ def _approve(summary: str, detail: str) -> bool:
220
+ """Ask the user to approve a mutating agent action."""
221
+ console.print(f"\n[bold yellow]⚠ {summary}[/bold yellow]")
222
+ if detail:
223
+ lexer = "python" if summary.lower().endswith(".py") else "text"
224
+ if summary.startswith("Run shell command"):
225
+ lexer = "bash"
226
+ console.print(
227
+ Panel(
228
+ Syntax(detail, lexer, theme="ansi_dark", word_wrap=True),
229
+ border_style="yellow",
230
+ )
231
+ )
232
+ return Confirm.ask("[bold]Proceed?[/bold]", default=True)
233
+
234
+
235
+ @app.command()
236
+ def agent(
237
+ task: Optional[List[str]] = typer.Argument(
238
+ None, help="What you want the agent to do."
239
+ ),
240
+ provider: Optional[str] = typer.Option(
241
+ None, "--provider", "-p", help="LLM provider: 'ollama' or 'openai'."
242
+ ),
243
+ model: Optional[str] = typer.Option(
244
+ None, "--model", "-m", help="Model name to use."
245
+ ),
246
+ workdir: Path = typer.Option(
247
+ Path.cwd(), "--dir", "-d", help="Workspace root the agent may touch."
248
+ ),
249
+ yolo: bool = typer.Option(
250
+ False, "--yolo", help="Skip confirmations (auto-approve writes & commands)."
251
+ ),
252
+ interactive: bool = typer.Option(
253
+ False, "--interactive", "-i", help="Keep taking follow-up tasks after each run."
254
+ ),
255
+ max_steps: int = typer.Option(
256
+ 25, "--max-steps", help="Maximum reasoning/action steps."
257
+ ),
258
+ ) -> None:
259
+ """Run the autonomous coding agent on a task."""
260
+ cfg = Config.load()
261
+ if provider:
262
+ cfg.provider = provider
263
+ if model:
264
+ cfg.model = model
265
+
266
+ task_text = " ".join(task).strip() if task else ""
267
+ # In interactive mode we can start without a task and prompt in the loop.
268
+ if not task_text and not interactive:
269
+ task_text = Prompt.ask("[bold blue]What should the agent build?[/bold blue]")
270
+ if not task_text.strip():
271
+ console.print("[yellow]No task given.[/yellow]")
272
+ raise typer.Exit(code=1)
273
+
274
+ try:
275
+ prov = get_provider(cfg)
276
+ except ValueError as exc:
277
+ console.print(f"[red]Error:[/red] {exc}")
278
+ raise typer.Exit(code=1)
279
+
280
+ root = workdir.resolve()
281
+ toolbox = Toolbox(root=root, approve=_approve, auto_approve=yolo)
282
+ coding_agent = CodingAgent(prov, toolbox, max_steps=max_steps)
283
+
284
+ console.print(
285
+ Panel.fit(
286
+ f"[bold cyan]oshell Agent[/bold cyan] · {cfg.provider}/{cfg.model}\n"
287
+ f"workspace: [green]{root}[/green]"
288
+ + (" [red](--yolo: no confirmations)[/red]" if yolo else "")
289
+ + (" [cyan](interactive)[/cyan]" if interactive else ""),
290
+ border_style="cyan",
291
+ )
292
+ )
293
+
294
+ def _run_one(task_str: str) -> None:
295
+ console.print(f"[bold]Task:[/bold] {task_str}\n")
296
+ for event in coding_agent.run(task_str):
297
+ if event.kind == "thought":
298
+ console.print(f"[dim italic]💭 {event.data['text']}[/dim italic]")
299
+ elif event.kind == "action":
300
+ args = event.data["args"]
301
+ detail = args.get("path") or args.get("command") or ""
302
+ console.print(
303
+ f"[bold magenta]→ {event.data['action']}[/bold magenta] "
304
+ f"[dim]{detail}[/dim]"
305
+ )
306
+ elif event.kind == "observation":
307
+ color = "green" if event.data["ok"] else "red"
308
+ out = event.data["output"]
309
+ snippet = out if len(out) < 500 else out[:500] + "\n… (truncated)"
310
+ console.print(f"[{color}]{snippet}[/{color}]")
311
+ elif event.kind == "finish":
312
+ console.print(
313
+ Panel.fit(
314
+ f"[bold green]✓ {event.data['summary']}[/bold green]",
315
+ border_style="green",
316
+ )
317
+ )
318
+ elif event.kind == "error":
319
+ console.print(f"[red]{event.data['message']}[/red]")
320
+
321
+ try:
322
+ if not interactive:
323
+ _run_one(task_text)
324
+ return
325
+
326
+ # Interactive mode: run the first task (if given), then loop.
327
+ if task_text:
328
+ _run_one(task_text)
329
+ while True:
330
+ try:
331
+ follow = Prompt.ask("\n[bold blue]agent ▸ next task[/bold blue] "
332
+ "[dim](/exit to quit)[/dim]")
333
+ except (EOFError, KeyboardInterrupt):
334
+ console.print("\n[dim]Bye![/dim]")
335
+ break
336
+ if follow.strip().lower() in ("/exit", "/quit", "exit", "quit"):
337
+ console.print("[dim]Bye![/dim]")
338
+ break
339
+ if not follow.strip():
340
+ continue
341
+ console.print()
342
+ _run_one(follow)
343
+ except KeyboardInterrupt:
344
+ console.print("\n[yellow]Agent interrupted.[/yellow]")
345
+ except Exception as exc: # noqa: BLE001 - friendly surface
346
+ console.print(f"[red]Agent failed:[/red] {exc}")
347
+ raise typer.Exit(code=1)
348
+
349
+
350
+ def _chat_provider_for_media(cfg: Config):
351
+ """Return a chat provider for prompt enhancement (best effort)."""
352
+ try:
353
+ return get_provider(cfg)
354
+ except ValueError:
355
+ return None
356
+
357
+
358
+ @app.command()
359
+ def image(
360
+ brief: Optional[List[str]] = typer.Argument(
361
+ None, help="What to draw, e.g. 'a fox in a misty forest'."
362
+ ),
363
+ count: int = typer.Option(1, "--count", "-n", help="How many images."),
364
+ size: Optional[str] = typer.Option(
365
+ None, "--size", "-s", help="Image size, e.g. 1024x1024."
366
+ ),
367
+ model: Optional[str] = typer.Option(
368
+ None, "--model", "-m", help="Image model (default gpt-image-1)."
369
+ ),
370
+ raw: bool = typer.Option(
371
+ False, "--raw", help="Use your brief verbatim (skip LLM enhancement)."
372
+ ),
373
+ ) -> None:
374
+ """Generate image(s) from a brief, with LLM prompt-enhancement."""
375
+ cfg = Config.load()
376
+ if size:
377
+ cfg.image_size = size
378
+ if model:
379
+ cfg.image_model = model
380
+
381
+ brief_text = " ".join(brief).strip() if brief else ""
382
+ if not brief_text:
383
+ brief_text = Prompt.ask("[bold blue]Describe the image[/bold blue]")
384
+ if not brief_text.strip():
385
+ console.print("[yellow]No description given.[/yellow]")
386
+ raise typer.Exit(code=1)
387
+
388
+ try:
389
+ generator = ImageGenerator(cfg)
390
+ except ValueError as exc:
391
+ console.print(f"[red]Error:[/red] {exc}")
392
+ raise typer.Exit(code=1)
393
+
394
+ media_agent = MediaAgent(_chat_provider_for_media(cfg)) if not raw else None
395
+ prompt = brief_text
396
+ if media_agent is not None:
397
+ with console.status("[cyan]Enhancing prompt…[/cyan]"):
398
+ prompt = media_agent.enhance(brief_text, "image")
399
+ console.print(f"[dim]Prompt:[/dim] {prompt}")
400
+
401
+ try:
402
+ with console.status("[cyan]Generating image…[/cyan]"):
403
+ result = generator.generate(prompt, n=count)
404
+ except Exception as exc: # noqa: BLE001
405
+ console.print(f"[red]Image generation failed:[/red] {exc}")
406
+ raise typer.Exit(code=1)
407
+
408
+ console.print(
409
+ Panel.fit(
410
+ "[bold green]✓ Saved:[/bold green]\n"
411
+ + "\n".join(f" • {p}" for p in result.paths),
412
+ border_style="green",
413
+ )
414
+ )
415
+
416
+
417
+ @app.command()
418
+ def video(
419
+ brief: Optional[List[str]] = typer.Argument(
420
+ None, help="What to film, e.g. 'a drone shot over a neon city'."
421
+ ),
422
+ model: Optional[str] = typer.Option(
423
+ None, "--model", "-m", help="Replicate video model (e.g. minimax/video-01)."
424
+ ),
425
+ raw: bool = typer.Option(
426
+ False, "--raw", help="Use your brief verbatim (skip LLM enhancement)."
427
+ ),
428
+ ) -> None:
429
+ """Generate a short video from a brief, with LLM prompt-enhancement."""
430
+ cfg = Config.load()
431
+ if model:
432
+ cfg.video_model = model
433
+
434
+ brief_text = " ".join(brief).strip() if brief else ""
435
+ if not brief_text:
436
+ brief_text = Prompt.ask("[bold blue]Describe the video[/bold blue]")
437
+ if not brief_text.strip():
438
+ console.print("[yellow]No description given.[/yellow]")
439
+ raise typer.Exit(code=1)
440
+
441
+ try:
442
+ generator = VideoGenerator(cfg)
443
+ except ValueError as exc:
444
+ console.print(f"[red]Error:[/red] {exc}")
445
+ raise typer.Exit(code=1)
446
+
447
+ media_agent = MediaAgent(_chat_provider_for_media(cfg)) if not raw else None
448
+ prompt = brief_text
449
+ if media_agent is not None:
450
+ with console.status("[cyan]Enhancing prompt…[/cyan]"):
451
+ prompt = media_agent.enhance(brief_text, "video")
452
+ console.print(f"[dim]Prompt:[/dim] {prompt}")
453
+
454
+ console.print(
455
+ f"[dim]Generating with [bold]{cfg.video_model}[/bold] "
456
+ "— this can take a few minutes…[/dim]"
457
+ )
458
+ try:
459
+ with console.status("[cyan]Rendering video…[/cyan]"):
460
+ result = generator.generate(prompt)
461
+ except Exception as exc: # noqa: BLE001
462
+ console.print(f"[red]Video generation failed:[/red] {exc}")
463
+ raise typer.Exit(code=1)
464
+
465
+ console.print(
466
+ Panel.fit(
467
+ "[bold green]✓ Saved:[/bold green]\n"
468
+ + "\n".join(f" • {p}" for p in result.paths),
469
+ border_style="green",
470
+ )
471
+ )
472
+
473
+
474
+ @app.command()
475
+ def storyboard(
476
+ brief: Optional[List[str]] = typer.Argument(
477
+ None, help="The story to tell, e.g. 'a seed growing into a giant tree'."
478
+ ),
479
+ scenes: int = typer.Option(4, "--scenes", "-n", help="Number of scenes."),
480
+ seconds: float = typer.Option(
481
+ 2.5, "--seconds", help="Seconds each scene is shown in the video."
482
+ ),
483
+ model: Optional[str] = typer.Option(
484
+ None, "--model", "-m", help="Image model (default gpt-image-1)."
485
+ ),
486
+ provider: Optional[str] = typer.Option(None, "--provider", "-p"),
487
+ ) -> None:
488
+ """Plan a multi-scene story, generate an image per scene, stitch into a video."""
489
+ cfg = Config.load()
490
+ if provider:
491
+ cfg.provider = provider
492
+ if model:
493
+ cfg.image_model = model
494
+
495
+ brief_text = " ".join(brief).strip() if brief else ""
496
+ if not brief_text:
497
+ brief_text = Prompt.ask("[bold blue]Describe the story[/bold blue]")
498
+ if not brief_text.strip():
499
+ console.print("[yellow]No story given.[/yellow]")
500
+ raise typer.Exit(code=1)
501
+
502
+ chat_provider = _chat_provider_for_media(cfg)
503
+ if chat_provider is None:
504
+ console.print("[red]Error:[/red] a chat provider is required to plan scenes.")
505
+ raise typer.Exit(code=1)
506
+ try:
507
+ image_gen = ImageGenerator(cfg)
508
+ except ValueError as exc:
509
+ console.print(f"[red]Error:[/red] {exc}")
510
+ raise typer.Exit(code=1)
511
+
512
+ agent = StoryboardAgent(chat_provider, image_gen, cfg)
513
+
514
+ if not agent.ffmpeg_available():
515
+ console.print(
516
+ "[yellow]Note:[/yellow] ffmpeg not found — scene images will be saved "
517
+ "but not stitched into a video. Install ffmpeg to enable stitching."
518
+ )
519
+
520
+ console.print(
521
+ Panel.fit(
522
+ f"[bold cyan]oshell Storyboard[/bold cyan] · {scenes} scenes\n"
523
+ f"[dim]{brief_text}[/dim]",
524
+ border_style="cyan",
525
+ )
526
+ )
527
+
528
+ def _on_scene(i: int, scene) -> None:
529
+ console.print(
530
+ f"[bold magenta]Scene {i + 1}[/bold magenta] "
531
+ f"[dim]{scene.caption or scene.prompt[:60]}[/dim]\n"
532
+ f" [green]{scene.image}[/green]"
533
+ )
534
+
535
+ try:
536
+ console.print("[cyan]Directing and rendering scenes…[/cyan]")
537
+ result = agent.render(
538
+ brief_text, n=scenes, seconds_per_scene=seconds, on_scene=_on_scene
539
+ )
540
+ except Exception as exc: # noqa: BLE001
541
+ console.print(f"[red]Storyboard failed:[/red] {exc}")
542
+ raise typer.Exit(code=1)
543
+
544
+ lines = [f" • {s.image}" for s in result.scenes if s.image]
545
+ if result.video:
546
+ lines.insert(0, f" 🎬 [bold]{result.video}[/bold]")
547
+ console.print(
548
+ Panel.fit(
549
+ "[bold green]✓ Storyboard ready:[/bold green]\n" + "\n".join(lines),
550
+ border_style="green",
551
+ )
552
+ )
553
+
554
+
555
+ @app.command()
556
+ def do(
557
+ request: Optional[List[str]] = typer.Argument(
558
+ None, help="What you want to do, e.g. 'compress all PNGs in this folder'."
559
+ ),
560
+ provider: Optional[str] = typer.Option(None, "--provider", "-p"),
561
+ model: Optional[str] = typer.Option(None, "--model", "-m"),
562
+ ) -> None:
563
+ """Translate a plain-English request into a shell command, then run it."""
564
+ import json as _json
565
+ import platform
566
+ import subprocess
567
+
568
+ cfg = Config.load()
569
+ if provider:
570
+ cfg.provider = provider
571
+ if model:
572
+ cfg.model = model
573
+
574
+ request_text = " ".join(request).strip() if request else ""
575
+ if not request_text:
576
+ request_text = Prompt.ask("[bold blue]What do you want to do?[/bold blue]")
577
+ if not request_text.strip():
578
+ console.print("[yellow]Nothing to do.[/yellow]")
579
+ raise typer.Exit(code=1)
580
+
581
+ shell = "PowerShell" if platform.system() == "Windows" else "bash"
582
+ system = (
583
+ f"You translate natural-language requests into a single {shell} command "
584
+ f"for {platform.system()}. Respond ONLY with a JSON object: "
585
+ '{"command": "<the command>", "explanation": "<one short line>"}. '
586
+ "No markdown, no code fences."
587
+ )
588
+ try:
589
+ prov = get_provider(cfg)
590
+ except ValueError as exc:
591
+ console.print(f"[red]Error:[/red] {exc}")
592
+ raise typer.Exit(code=1)
593
+
594
+ with console.status("[cyan]Thinking…[/cyan]"):
595
+ raw = "".join(
596
+ prov.stream_chat(
597
+ [
598
+ {"role": "system", "content": system},
599
+ {"role": "user", "content": request_text},
600
+ ]
601
+ )
602
+ ).strip()
603
+
604
+ if raw.startswith("```"):
605
+ import re as _re
606
+
607
+ raw = _re.sub(r"^```[a-zA-Z]*\n?", "", raw)
608
+ raw = _re.sub(r"\n?```$", "", raw).strip()
609
+ try:
610
+ data = _json.loads(raw)
611
+ command = data["command"]
612
+ explanation = data.get("explanation", "")
613
+ except (ValueError, KeyError):
614
+ console.print(f"[red]Could not parse a command from the model.[/red]\n{raw}")
615
+ raise typer.Exit(code=1)
616
+
617
+ console.print(Panel(Syntax(command, "bash", theme="ansi_dark", word_wrap=True),
618
+ title="Suggested command", border_style="yellow"))
619
+ if explanation:
620
+ console.print(f"[dim]{explanation}[/dim]")
621
+
622
+ if not Confirm.ask("[bold]Run it?[/bold]", default=False):
623
+ console.print("[dim]Skipped.[/dim]")
624
+ raise typer.Exit()
625
+
626
+ proc = subprocess.run(command, shell=True)
627
+ raise typer.Exit(code=proc.returncode)
628
+
629
+
630
+ @app.command()
631
+ def index(
632
+ folder: Path = typer.Argument(..., help="Folder of files to index."),
633
+ ) -> None:
634
+ """Index a folder so you can chat with your files (`ai ask`)."""
635
+ cfg = Config.load()
636
+ root = folder.resolve()
637
+ if not root.is_dir():
638
+ console.print(f"[red]Not a directory:[/red] {folder}")
639
+ raise typer.Exit(code=1)
640
+
641
+ console.print(f"[cyan]Indexing[/cyan] {root} …")
642
+ count = {"n": 0}
643
+
644
+ def _tick(_src: str) -> None:
645
+ count["n"] += 1
646
+
647
+ try:
648
+ with console.status("[cyan]Embedding chunks…[/cyan]"):
649
+ total = retrieval.build_index(root, cfg, progress=_tick)
650
+ except Exception as exc: # noqa: BLE001
651
+ console.print(
652
+ f"[red]Indexing failed:[/red] {exc}\n"
653
+ "[dim]Tip: with Ollama, run `ollama pull nomic-embed-text` first.[/dim]"
654
+ )
655
+ raise typer.Exit(code=1)
656
+
657
+ console.print(
658
+ Panel.fit(
659
+ f"[bold green]✓ Indexed {total} chunks[/bold green] from {root}",
660
+ border_style="green",
661
+ )
662
+ )
663
+
664
+
665
+ @app.command()
666
+ def ask(
667
+ question: Optional[List[str]] = typer.Argument(
668
+ None, help="A question about your indexed files."
669
+ ),
670
+ top_k: int = typer.Option(5, "--top-k", "-k", help="How many chunks to retrieve."),
671
+ provider: Optional[str] = typer.Option(None, "--provider", "-p"),
672
+ model: Optional[str] = typer.Option(None, "--model", "-m"),
673
+ ) -> None:
674
+ """Answer a question using your indexed files as context (RAG)."""
675
+ cfg = Config.load()
676
+ if provider:
677
+ cfg.provider = provider
678
+ if model:
679
+ cfg.model = model
680
+
681
+ question_text = " ".join(question).strip() if question else ""
682
+ if not question_text:
683
+ question_text = Prompt.ask("[bold blue]Ask about your files[/bold blue]")
684
+ if not question_text.strip():
685
+ console.print("[yellow]No question given.[/yellow]")
686
+ raise typer.Exit(code=1)
687
+
688
+ try:
689
+ with console.status("[cyan]Searching your files…[/cyan]"):
690
+ hits = retrieval.search(question_text, cfg, top_k=top_k)
691
+ except FileNotFoundError as exc:
692
+ console.print(f"[red]{exc}[/red]")
693
+ raise typer.Exit(code=1)
694
+ except Exception as exc: # noqa: BLE001
695
+ console.print(f"[red]Search failed:[/red] {exc}")
696
+ raise typer.Exit(code=1)
697
+
698
+ if not hits:
699
+ console.print("[yellow]No relevant content found in the index.[/yellow]")
700
+ raise typer.Exit(code=1)
701
+
702
+ context = "\n\n".join(
703
+ f"[Source: {src}]\n{text}" for src, text, _score in hits
704
+ )
705
+ sources = sorted({src for src, _t, _s in hits})
706
+
707
+ try:
708
+ prov = get_provider(cfg)
709
+ except ValueError as exc:
710
+ console.print(f"[red]Error:[/red] {exc}")
711
+ raise typer.Exit(code=1)
712
+
713
+ messages: List[Message] = [
714
+ {
715
+ "role": "system",
716
+ "content": "Answer the question using ONLY the provided context. "
717
+ "If the answer isn't in the context, say so. Cite sources by name.",
718
+ },
719
+ {
720
+ "role": "user",
721
+ "content": f"Context:\n{context}\n\nQuestion: {question_text}",
722
+ },
723
+ ]
724
+ _render_stream(prov, messages)
725
+ console.print("\n[dim]Sources: " + ", ".join(sources) + "[/dim]")
726
+
727
+
728
+ @persona_app.command("list")
729
+ def persona_list() -> None:
730
+ """List available personas."""
731
+ cfg = Config.load()
732
+ for name, prompt in sorted(personas.all_personas().items()):
733
+ marker = " [green](active)[/green]" if name == cfg.persona else ""
734
+ preview = prompt if len(prompt) < 70 else prompt[:70] + "…"
735
+ console.print(f"[cyan]{name}[/cyan]{marker}\n [dim]{preview}[/dim]")
736
+
737
+
738
+ @persona_app.command("use")
739
+ def persona_use(name: str) -> None:
740
+ """Set the active persona (persisted to config)."""
741
+ if personas.get(name) is None:
742
+ console.print(f"[red]Unknown persona:[/red] {name}")
743
+ raise typer.Exit(code=1)
744
+ cfg = Config.load()
745
+ cfg.persona = name
746
+ cfg.save()
747
+ console.print(f"[green]Active persona →[/green] {name}")
748
+
749
+
750
+ @persona_app.command("add")
751
+ def persona_add(name: str, prompt: str) -> None:
752
+ """Create or update a custom persona."""
753
+ path = personas.add(name, prompt)
754
+ console.print(f"[green]Saved persona[/green] '{name}' ([dim]{path}[/dim])")
755
+
756
+
757
+ @persona_app.command("remove")
758
+ def persona_remove(name: str) -> None:
759
+ """Delete a custom persona."""
760
+ if personas.remove(name):
761
+ console.print(f"[green]Removed[/green] '{name}'")
762
+ else:
763
+ console.print(f"[yellow]No custom persona named[/yellow] '{name}'")
764
+
765
+
766
+ @persona_app.command("clear")
767
+ def persona_clear() -> None:
768
+ """Stop using any persona (revert to the default system prompt)."""
769
+ cfg = Config.load()
770
+ cfg.persona = ""
771
+ cfg.save()
772
+ console.print("[green]Persona cleared.[/green]")
773
+
774
+
775
+ @config_app.command("show")
776
+ def config_show() -> None:
777
+ """Print the current configuration."""
778
+ cfg = Config.load()
779
+ from dataclasses import asdict
780
+
781
+ for key, value in asdict(cfg).items():
782
+ if key.endswith(("api_key", "api_token")) and value:
783
+ value = value[:6] + "…" + value[-4:]
784
+ console.print(f"[cyan]{key}[/cyan] = {value}")
785
+
786
+
787
+ @config_app.command("set")
788
+ def config_set(key: str, value: str) -> None:
789
+ """Set a config value, e.g. `oshell config set provider openai`."""
790
+ cfg = Config.load()
791
+ if not hasattr(cfg, key):
792
+ console.print(f"[red]Unknown key:[/red] {key}")
793
+ raise typer.Exit(code=1)
794
+
795
+ current = getattr(cfg, key)
796
+ if isinstance(current, float):
797
+ setattr(cfg, key, float(value))
798
+ else:
799
+ setattr(cfg, key, value)
800
+
801
+ path = cfg.save()
802
+ console.print(f"[green]Saved[/green] {key} → {value} ([dim]{path}[/dim])")
803
+
804
+
805
+ @config_app.command("path")
806
+ def config_path() -> None:
807
+ """Print the path to the config file."""
808
+ from .config import CONFIG_PATH
809
+
810
+ console.print(str(CONFIG_PATH))
811
+
812
+
813
+ # Subcommands that should NOT be treated as a chat prompt.
814
+ _SUBCOMMANDS = {"chat", "models", "config", "agent", "image", "video",
815
+ "storyboard", "persona", "do", "index", "ask"}
816
+ _ROOT_FLAGS = {"--version", "-V", "--help"}
817
+
818
+
819
+ def main_entry() -> None:
820
+ """Console-script entry point.
821
+
822
+ Makes ``chat`` the default command so ``ai "question"`` works while
823
+ real subcommands (``models``, ``config``) and root flags still route
824
+ correctly.
825
+ """
826
+ argv = sys.argv[1:]
827
+ if not argv:
828
+ argv = ["chat"]
829
+ elif argv[0] not in _SUBCOMMANDS and argv[0] not in _ROOT_FLAGS:
830
+ argv = ["chat", *argv]
831
+ sys.argv = [sys.argv[0], *argv]
832
+ app()
833
+
834
+
835
+ if __name__ == "__main__":
836
+ main_entry()