zai-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. zai/__init__.py +1 -0
  2. zai/__main__.py +4 -0
  3. zai/cli/__init__.py +1 -0
  4. zai/cli/common.py +16 -0
  5. zai/cli/integrations.py +319 -0
  6. zai/cli/interactive.py +518 -0
  7. zai/cli/settings.py +436 -0
  8. zai/cli/utilities.py +227 -0
  9. zai/cli/workflows.py +137 -0
  10. zai/commands/commit.md +24 -0
  11. zai/commands/explain.md +17 -0
  12. zai/commands/feature.md +34 -0
  13. zai/commands/fix.md +14 -0
  14. zai/commands/review.md +22 -0
  15. zai/config.py +307 -0
  16. zai/core/__init__.py +0 -0
  17. zai/core/agent.py +701 -0
  18. zai/core/cancellation.py +67 -0
  19. zai/core/commands.py +85 -0
  20. zai/core/context.py +299 -0
  21. zai/core/errors.py +125 -0
  22. zai/core/fallback.py +171 -0
  23. zai/core/hooks.py +115 -0
  24. zai/core/memory.py +57 -0
  25. zai/core/process.py +204 -0
  26. zai/core/repomap.py +381 -0
  27. zai/core/runtime.py +29 -0
  28. zai/core/security.py +33 -0
  29. zai/core/session.py +425 -0
  30. zai/core/storage.py +193 -0
  31. zai/core/streaming.py +157 -0
  32. zai/core/tool_schema.py +133 -0
  33. zai/core/undo.py +443 -0
  34. zai/core/watch.py +80 -0
  35. zai/main.py +210 -0
  36. zai/mcp/__init__.py +0 -0
  37. zai/mcp/client.py +431 -0
  38. zai/mcp/manager.py +118 -0
  39. zai/plugins/__init__.py +2 -0
  40. zai/plugins/base.py +49 -0
  41. zai/plugins/loader.py +404 -0
  42. zai/providers/__init__.py +22 -0
  43. zai/providers/anthropic.py +131 -0
  44. zai/providers/base.py +67 -0
  45. zai/providers/cerebras.py +57 -0
  46. zai/providers/gemini.py +119 -0
  47. zai/providers/groq.py +116 -0
  48. zai/providers/ollama.py +62 -0
  49. zai/providers/openai.py +124 -0
  50. zai/providers/openrouter.py +63 -0
  51. zai/providers/qwen.py +47 -0
  52. zai/skills/__init__.py +0 -0
  53. zai/skills/registry.py +52 -0
  54. zai/tools/__init__.py +0 -0
  55. zai/tools/browser.py +224 -0
  56. zai/tools/code_runner.py +49 -0
  57. zai/tools/files.py +53 -0
  58. zai/tools/git.py +38 -0
  59. zai/tools/search.py +157 -0
  60. zai/tools/vision.py +128 -0
  61. zai/ui/__init__.py +0 -0
  62. zai/ui/input.py +199 -0
  63. zai_cli-0.1.0.dist-info/METADATA +722 -0
  64. zai_cli-0.1.0.dist-info/RECORD +68 -0
  65. zai_cli-0.1.0.dist-info/WHEEL +5 -0
  66. zai_cli-0.1.0.dist-info/entry_points.txt +2 -0
  67. zai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. zai_cli-0.1.0.dist-info/top_level.txt +1 -0
zai/cli/settings.py ADDED
@@ -0,0 +1,436 @@
1
+ from typing import Optional
2
+ from pathlib import Path
3
+
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.prompt import Confirm, Prompt
8
+ from rich.table import Table
9
+
10
+ from ..config import (
11
+ MEMORY_FILE,
12
+ MODELS,
13
+ get_api_key,
14
+ get_model_config,
15
+ get_models,
16
+ load_config,
17
+ save_api_key,
18
+ save_config,
19
+ validate_config,
20
+ )
21
+ from ..core.memory import get_last_session, get_projects
22
+ from ..core.session import (
23
+ delete_session,
24
+ export_session,
25
+ get_session_info,
26
+ list_sessions,
27
+ rename_session,
28
+ search_sessions,
29
+ )
30
+ from ..core.undo import (
31
+ clear_actions,
32
+ get_action,
33
+ list_actions,
34
+ redo_last_action,
35
+ undo_action,
36
+ )
37
+
38
+ console = Console()
39
+
40
+
41
+ def setup():
42
+ """Setup API keys and preferences."""
43
+ console.print(Panel("[bold cyan]Welcome to zai setup![/bold cyan]", border_style="cyan"))
44
+ providers = [
45
+ ("gemini", "Google Gemini (FREE — Recommended)"),
46
+ ("groq", "Groq (FREE — Fast)"),
47
+ ("cerebras", "Cerebras (FREE — Ultra Fast)"),
48
+ ("openrouter", "OpenRouter (Some free models)"),
49
+ ("qwen", "Qwen/DashScope"),
50
+ ("anthropic", "Anthropic Claude (Paid)"),
51
+ ("openai", "OpenAI GPT-4o (Paid)"),
52
+ ]
53
+ for key, label in providers:
54
+ if get_api_key(key):
55
+ console.print(f"[green]✓[/green] {label} — already set")
56
+ continue
57
+ value = Prompt.ask(
58
+ f"[cyan]{label}[/cyan] API key [dim](Enter to skip)[/dim]",
59
+ default="",
60
+ )
61
+ if value.strip():
62
+ save_api_key(key, value.strip())
63
+ console.print("[green]✓[/green] Saved!")
64
+
65
+ config = load_config()
66
+ free_models = [
67
+ key for key, data in get_models(config).items()
68
+ if data["free"] and get_api_key(data["provider"])
69
+ ]
70
+ if free_models:
71
+ console.print(f"\n[cyan]Available free models:[/cyan] {', '.join(free_models)}")
72
+ default = Prompt.ask(
73
+ "Default model",
74
+ choices=list(get_models(config).keys()),
75
+ default=free_models[0],
76
+ )
77
+ config["default_model"] = default
78
+ save_config(config)
79
+ console.print(Panel('[green]zai is ready! Try: zai "hello"[/green]', border_style="green"))
80
+
81
+
82
+ def model(
83
+ action: str = typer.Argument(
84
+ "list", help="list, set, fallback, add, remove, configure, info, test, or check"
85
+ ),
86
+ name: Optional[str] = typer.Argument(None),
87
+ provider: Optional[str] = typer.Option(None, "--provider"),
88
+ model_id: Optional[str] = typer.Option(None, "--model-id"),
89
+ context_window: int = typer.Option(128000, "--context-window", min=1024),
90
+ timeout: float = typer.Option(60, "--timeout", min=1, max=600),
91
+ retries: int = typer.Option(2, "--retries", min=0, max=10),
92
+ free: bool = typer.Option(False, "--free"),
93
+ ):
94
+ """List, configure, inspect, or test model aliases."""
95
+ if action == "list":
96
+ table = Table(title="Available Models")
97
+ table.add_column("Name", style="cyan")
98
+ table.add_column("Model", style="dim")
99
+ table.add_column("Context", justify="right")
100
+ table.add_column("Free", justify="center")
101
+ table.add_column("Key Set", justify="center")
102
+ config = load_config()
103
+ for key in get_models(config):
104
+ data = get_model_config(key, config)
105
+ has_key = "[green]✓[/green]" if get_api_key(data["provider"]) else "[red]✗[/red]"
106
+ free_label = "[green]Yes[/green]" if data["free"] else "[yellow]No[/yellow]"
107
+ table.add_row(
108
+ key,
109
+ data["model_id"],
110
+ f"{data['context_window']:,}",
111
+ free_label,
112
+ has_key,
113
+ )
114
+ console.print(table)
115
+ elif action == "set" and name:
116
+ config = load_config()
117
+ if name not in get_models(config):
118
+ console.print(f"[red]Unknown model: {name}[/red]")
119
+ return
120
+ config["default_model"] = name
121
+ save_config(config)
122
+ console.print(f"[green]✓[/green] Default model set to: [cyan]{name}[/cyan]")
123
+ elif action == "fallback" and name in {"on", "off"}:
124
+ config = load_config()
125
+ config["auto_fallback"] = name == "on"
126
+ save_config(config)
127
+ console.print(
128
+ f"[green]✓[/green] Automatic model fallback: [cyan]{name}[/cyan]"
129
+ )
130
+ elif action == "check":
131
+ config = load_config()
132
+ issues = validate_config(config)
133
+ if issues:
134
+ for issue in issues:
135
+ console.print(f"[red]x[/red] {issue}")
136
+ else:
137
+ console.print("[green]OK[/green] Model configuration is valid.")
138
+ for key in get_models(config):
139
+ effective = get_model_config(key, config)["model_id"]
140
+ console.print(f"[dim]{key}: {effective}[/dim]")
141
+ elif action == "add" and name:
142
+ if name in MODELS:
143
+ console.print(f"[red]Built-in model cannot be replaced: {name}[/red]")
144
+ return
145
+ if not provider or not model_id:
146
+ console.print(
147
+ "[red]Usage: zai model add <name> --provider <provider> "
148
+ "--model-id <id> [--context-window <tokens>] [--free][/red]"
149
+ )
150
+ return
151
+ allowed_providers = {data["provider"] for data in MODELS.values()}
152
+ if provider not in allowed_providers:
153
+ console.print(f"[red]Unknown provider: {provider}[/red]")
154
+ return
155
+ config = load_config()
156
+ models = config.setdefault("models", {})
157
+ if name in models:
158
+ console.print(f"[red]Model already exists: {name}[/red]")
159
+ return
160
+ models[name] = {
161
+ "name": name,
162
+ "provider": provider,
163
+ "model_id": model_id,
164
+ "context_window": context_window,
165
+ "timeout": timeout,
166
+ "retries": retries,
167
+ "free": free,
168
+ }
169
+ config.setdefault("fallback_order", []).append(name)
170
+ save_config(config)
171
+ console.print(f"[green]Added model:[/green] [cyan]{name}[/cyan]")
172
+ elif action == "remove" and name:
173
+ if name in MODELS:
174
+ console.print(f"[red]Built-in model cannot be removed: {name}[/red]")
175
+ return
176
+ config = load_config()
177
+ models = config.setdefault("models", {})
178
+ if name not in models:
179
+ console.print(f"[red]Unknown custom model: {name}[/red]")
180
+ return
181
+ del models[name]
182
+ config["fallback_order"] = [
183
+ item for item in config.get("fallback_order", []) if item != name
184
+ ]
185
+ config.get("model_overrides", {}).pop(name, None)
186
+ config.get("model_settings", {}).pop(name, None)
187
+ if config.get("default_model") == name:
188
+ config["default_model"] = "gemini"
189
+ save_config(config)
190
+ console.print(f"[green]Removed model:[/green] [cyan]{name}[/cyan]")
191
+ elif action == "configure" and name:
192
+ config = load_config()
193
+ if name not in get_models(config):
194
+ console.print(f"[red]Unknown model: {name}[/red]")
195
+ return
196
+ config.setdefault("model_settings", {})[name] = {
197
+ "timeout": timeout,
198
+ "retries": retries,
199
+ }
200
+ save_config(config)
201
+ console.print(
202
+ f"[green]Configured model:[/green] [cyan]{name}[/cyan] "
203
+ f"(timeout={timeout}s, retries={retries})"
204
+ )
205
+ elif action == "info" and name:
206
+ config = load_config()
207
+ try:
208
+ data = get_model_config(name, config)
209
+ except KeyError:
210
+ console.print(f"[red]Unknown model: {name}[/red]")
211
+ return
212
+ console.print(Panel(
213
+ "\n".join([
214
+ f"Alias: {name}",
215
+ f"Provider: {data['provider']}",
216
+ f"Model ID: {data['model_id']}",
217
+ f"Context window: {data['context_window']:,}",
218
+ f"Timeout: {data['timeout']} seconds",
219
+ f"Retries: {data['retries']}",
220
+ f"Free: {'yes' if data['free'] else 'no'}",
221
+ f"Source: {'built-in' if name in MODELS else 'custom'}",
222
+ ]),
223
+ title="[cyan]Model information[/cyan]",
224
+ ))
225
+ elif action == "test" and name:
226
+ from ..core.fallback import get_provider
227
+ from ..providers.base import Message
228
+
229
+ selected = get_provider(name)
230
+ if selected is None:
231
+ console.print(f"[red]Unknown model: {name}[/red]")
232
+ return
233
+ if not selected.is_available():
234
+ console.print(
235
+ f"[red]Model unavailable:[/red] {name}. "
236
+ "Check its API key or local service."
237
+ )
238
+ return
239
+ try:
240
+ response = selected.chat([
241
+ Message(role="user", content="Reply with exactly: ok")
242
+ ])
243
+ except Exception as error:
244
+ console.print(f"[red]Model test failed:[/red] {error}")
245
+ return
246
+ console.print(
247
+ f"[green]Model test passed:[/green] {name} "
248
+ f"([cyan]{response.model}[/cyan])"
249
+ )
250
+ else:
251
+ console.print(
252
+ "[red]Usage: zai model list | zai model set <name> | "
253
+ "zai model fallback on|off | "
254
+ "zai model add/remove/configure/info/test <name> | "
255
+ "zai model check[/red]"
256
+ )
257
+
258
+
259
+ def memory(action: str = typer.Argument("show", help="show, projects, clear")):
260
+ """View or manage memory."""
261
+ if action == "show":
262
+ last = get_last_session()
263
+ if last:
264
+ console.print(Panel(
265
+ f"Task: {last['task']}\nModel: {last['model']}\nDate: {last['date']}",
266
+ title="[cyan]Last Session[/cyan]",
267
+ border_style="cyan",
268
+ ))
269
+ else:
270
+ console.print("[dim]No session history yet.[/dim]")
271
+ elif action == "projects":
272
+ projects = get_projects()
273
+ if projects:
274
+ for project in projects:
275
+ console.print(
276
+ f"[cyan]{project['name']}[/cyan] — {project['path']} "
277
+ f"[dim]({project['last_worked']})[/dim]"
278
+ )
279
+ else:
280
+ console.print("[dim]No projects yet.[/dim]")
281
+ elif action == "clear":
282
+ if MEMORY_FILE.exists():
283
+ MEMORY_FILE.unlink()
284
+ console.print("[green]✓[/green] Memory cleared.")
285
+ else:
286
+ console.print("[red]Usage: zai memory show | projects | clear[/red]")
287
+
288
+
289
+ def session(
290
+ action: str = typer.Argument("list", help="list, show, search, rename, delete"),
291
+ name: Optional[str] = typer.Argument(None),
292
+ value: Optional[str] = typer.Argument(None),
293
+ export_format: str = typer.Option("md", "--format", "-f"),
294
+ output: Optional[str] = typer.Option(None, "--out", "-o"),
295
+ ):
296
+ """Inspect and manage saved conversations."""
297
+ if action == "list":
298
+ sessions = list_sessions()
299
+ if not sessions:
300
+ console.print("[dim]No saved sessions.[/dim]")
301
+ return
302
+ table = Table(title="Saved Sessions")
303
+ table.add_column("ID", style="dim")
304
+ table.add_column("Name", style="cyan")
305
+ table.add_column("Title")
306
+ table.add_column("Messages", justify="right")
307
+ table.add_column("Project", style="dim")
308
+ for item in sessions:
309
+ project = (
310
+ Path(item["project_path"]).name
311
+ if item.get("project_path")
312
+ else "-"
313
+ )
314
+ table.add_row(
315
+ item["id"][:8],
316
+ item["name"],
317
+ item["title"],
318
+ str(item["messages"]),
319
+ project,
320
+ )
321
+ console.print(table)
322
+ elif action == "show" and name:
323
+ info = get_session_info(name)
324
+ if not info:
325
+ console.print("[red]Session not found or identifier is ambiguous.[/red]")
326
+ return
327
+ console.print(Panel(
328
+ f"ID: {info['id']}\n"
329
+ f"Name: {info['name']}\n"
330
+ f"Title: {info['title']}\n"
331
+ f"Messages: {info['messages']}\n"
332
+ f"Project: {info.get('project_path') or '-'}\n"
333
+ f"Updated: {info.get('updated_at') or '-'}",
334
+ title="[cyan]Session[/cyan]",
335
+ ))
336
+ elif action == "search" and name:
337
+ matches = search_sessions(" ".join(filter(None, [name, value])))
338
+ if not matches:
339
+ console.print("[dim]No matching sessions.[/dim]")
340
+ return
341
+ for item in matches:
342
+ console.print(
343
+ f"[cyan]{item['name']}[/cyan] [dim]{item['id'][:8]}[/dim] "
344
+ f"— {item['title']}"
345
+ )
346
+ elif action == "rename" and name and value:
347
+ if rename_session(name, value):
348
+ console.print(f"[green]Session renamed:[/green] {value}")
349
+ else:
350
+ console.print(
351
+ "[red]Rename failed: missing, ambiguous, automatic, "
352
+ "or destination exists.[/red]"
353
+ )
354
+ elif action == "delete" and name:
355
+ if Confirm.ask(f"Delete session '{name}'?", default=False):
356
+ if delete_session(name):
357
+ console.print(f"[green]Session deleted:[/green] {name}")
358
+ else:
359
+ console.print("[red]Session not found or identifier is ambiguous.[/red]")
360
+ elif action == "export" and name:
361
+ exported = export_session(
362
+ name,
363
+ export_format=export_format,
364
+ output=output,
365
+ project_path=".",
366
+ )
367
+ if exported:
368
+ console.print(f"[green]Session exported:[/green] {exported}")
369
+ else:
370
+ console.print(
371
+ "[red]Export failed: session missing/ambiguous or format "
372
+ "must be md/json.[/red]"
373
+ )
374
+ else:
375
+ console.print(
376
+ "[red]Usage: zai session list | show <name-or-id> | "
377
+ "search <query> | rename <old> <new> | delete <name-or-id> | "
378
+ "export <name-or-id> --format md|json [--out path][/red]"
379
+ )
380
+
381
+
382
+ def undo(
383
+ action: str = typer.Argument("list", help="list, show, apply, redo, clear"),
384
+ identifier: Optional[str] = typer.Argument(None, help="Undo action ID"),
385
+ ):
386
+ """Inspect, apply, redo, or clear project-local undo history."""
387
+ cwd = str(Path.cwd())
388
+ if action == "list":
389
+ actions = list_actions(cwd)
390
+ if not actions:
391
+ console.print("[dim]No undo actions.[/dim]")
392
+ return
393
+ table = Table(title="Undo History")
394
+ table.add_column("ID", style="cyan")
395
+ table.add_column("Created", style="dim")
396
+ table.add_column("Operation")
397
+ for item in actions:
398
+ table.add_row(
399
+ item["id"][:8],
400
+ item.get("created_at", "-"),
401
+ item["summary"],
402
+ )
403
+ console.print(table)
404
+ elif action == "show" and identifier:
405
+ selected_action = get_action(cwd, identifier)
406
+ if not selected_action:
407
+ console.print("[red]Undo action not found or ID is ambiguous.[/red]")
408
+ return
409
+ console.print(Panel(
410
+ f"ID: {selected_action['id']}\n"
411
+ f"Created: {selected_action.get('created_at', '-')}\n"
412
+ f"Operation: {selected_action['summary']}\n"
413
+ f"Paths: {', '.join(selected_action.get('paths', [])) or '-'}",
414
+ title="[cyan]Undo Action[/cyan]",
415
+ ))
416
+ elif action == "apply":
417
+ console.print(f"[cyan]{undo_action(cwd, identifier)}[/cyan]")
418
+ elif action == "redo":
419
+ console.print(f"[cyan]{redo_last_action(cwd)}[/cyan]")
420
+ elif action == "clear":
421
+ if Confirm.ask("Clear all undo and redo history for this project?", default=False):
422
+ count = clear_actions(cwd)
423
+ console.print(f"[green]Cleared {count} undo/redo actions.[/green]")
424
+ else:
425
+ console.print(
426
+ "[red]Usage: zai undo list | show <id> | apply [id] | "
427
+ "redo | clear[/red]"
428
+ )
429
+
430
+
431
+ def register_settings_commands(app: typer.Typer) -> None:
432
+ app.command()(setup)
433
+ app.command()(model)
434
+ app.command()(memory)
435
+ app.command()(session)
436
+ app.command()(undo)
zai/cli/utilities.py ADDED
@@ -0,0 +1,227 @@
1
+ from typing import Optional
2
+
3
+ import typer
4
+ from rich import print as rprint
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+
8
+ from ..core.errors import show_error
9
+ from ..core.fallback import (
10
+ chat_with_fallback,
11
+ format_model_selection,
12
+ stream_with_fallback,
13
+ )
14
+ from ..core.repomap import build_repomap_result, get_repomap_prompt
15
+ from ..config import get_model_config, load_config
16
+ from ..tools.browser import BrowserError, scrape, screenshot
17
+ from ..tools.code_runner import run_file
18
+ from ..tools.files import list_files, read_file
19
+ from ..tools.search import web_search
20
+ from ..tools.vision import VisionError, analyze_image
21
+
22
+ console = Console()
23
+
24
+
25
+ def register_utility_commands(app: typer.Typer, context, system_prompt: str) -> None:
26
+ """Register utility commands using the shared conversation context."""
27
+
28
+ @app.command()
29
+ def search(query: str = typer.Argument(..., help="Search query")):
30
+ """Search the web."""
31
+ with console.status("[cyan]Searching...[/cyan]"):
32
+ results = web_search(query)
33
+ context.add("user", f"Web search results for '{query}':\n{results}\nSummarize this.")
34
+ with console.status("[cyan]Summarizing...[/cyan]"):
35
+ response, _ = chat_with_fallback(
36
+ context.get_messages(),
37
+ system=system_prompt,
38
+ )
39
+ context.add("assistant", response.content)
40
+ rprint(Panel(
41
+ response.content,
42
+ title="[cyan]Search Results[/cyan]",
43
+ border_style="cyan",
44
+ ))
45
+
46
+ @app.command()
47
+ def file(
48
+ action: str = typer.Argument(..., help="Action: read, write, list"),
49
+ path: Optional[str] = typer.Argument(None),
50
+ instruction: Optional[str] = typer.Option(
51
+ None,
52
+ "--do",
53
+ "-d",
54
+ help="What to do with the file",
55
+ ),
56
+ ):
57
+ """File operations: read, write, list."""
58
+ if action == "read" and path:
59
+ content = read_file(path)
60
+ if instruction:
61
+ context.add(
62
+ "user",
63
+ f"File: {path}\n\n{content}\n\nInstruction: {instruction}",
64
+ )
65
+ with console.status("[cyan]Processing...[/cyan]"):
66
+ response, used_model = chat_with_fallback(
67
+ context.get_messages(),
68
+ system=system_prompt,
69
+ )
70
+ context.add("assistant", response.content)
71
+ used_model = format_model_selection(used_model)
72
+ rprint(Panel(
73
+ response.content,
74
+ border_style="cyan",
75
+ title=f"[dim]{used_model}[/dim]",
76
+ ))
77
+ else:
78
+ console.print(Panel(content[:3000], title=f"[cyan]{path}[/cyan]"))
79
+ elif action == "list":
80
+ for filename in list_files(path or ".")[:50]:
81
+ console.print(f" [dim]{filename}[/dim]")
82
+ else:
83
+ console.print(
84
+ "[red]Usage: zai file read <path> | zai file list [dir][/red]"
85
+ )
86
+
87
+ @app.command()
88
+ def run(path: str = typer.Argument(..., help="Python file to run")):
89
+ """Run a Python file and show output."""
90
+ console.print(f"[dim]Running {path}...[/dim]")
91
+ output = run_file(path)
92
+ console.print(Panel(output, title=f"[cyan]{path}[/cyan]", border_style="cyan"))
93
+
94
+ @app.command()
95
+ def repo(
96
+ action: str = typer.Argument("map", help="map, ask"),
97
+ question: Optional[str] = typer.Argument(
98
+ None,
99
+ help="Question about the codebase",
100
+ ),
101
+ directory: Optional[str] = typer.Option(".", "--dir", "-d"),
102
+ model: Optional[str] = typer.Option(None, "--model", "-m"),
103
+ ):
104
+ """Understand your codebase: map structure or ask questions about it."""
105
+ if action == "map":
106
+ with console.status("[cyan]Mapping codebase...[/cyan]") as status:
107
+ result = build_repomap_result(
108
+ directory,
109
+ progress_callback=lambda stats: status.update(
110
+ f"[cyan]Mapping codebase... {stats.scanned:,} files scanned[/cyan]"
111
+ ),
112
+ )
113
+ console.print(Panel(
114
+ result.text,
115
+ title=f"[cyan]Repo Map — {directory}[/cyan]",
116
+ border_style="cyan",
117
+ ))
118
+ elif action == "ask" and question:
119
+ preferred = model or load_config()["default_model"]
120
+ context_window = get_model_config(preferred)["context_window"]
121
+ map_budget = max(2_000, min(20_000, context_window // 8))
122
+ with console.status("[cyan]Reading codebase...[/cyan]"):
123
+ repomap_prompt = get_repomap_prompt(
124
+ directory,
125
+ max_tokens=map_budget,
126
+ )
127
+ context.add("user", f"{repomap_prompt}\n\nQuestion: {question}")
128
+ console.print("[dim]── Answering about codebase ──[/dim]")
129
+ try:
130
+ content, used_model = stream_with_fallback(
131
+ context.get_messages(),
132
+ system=system_prompt,
133
+ preferred=preferred,
134
+ )
135
+ context.add("assistant", content)
136
+ used_model = format_model_selection(used_model)
137
+ console.print(f"[dim]── {used_model} ──[/dim]")
138
+ except Exception as error:
139
+ show_error(str(error))
140
+ else:
141
+ console.print(
142
+ '[red]Usage: zai repo map | zai repo ask "how does auth work?"[/red]'
143
+ )
144
+
145
+ @app.command()
146
+ def vision(
147
+ image: str = typer.Argument(
148
+ ...,
149
+ help="Image file path (png/jpg/gif/webp)",
150
+ ),
151
+ prompt: str = typer.Option(
152
+ "Describe this image in detail.",
153
+ "--prompt",
154
+ "-p",
155
+ ),
156
+ ):
157
+ """Analyze a local image or screenshot with AI vision."""
158
+ try:
159
+ with console.status("[cyan]Analyzing image...[/cyan]"):
160
+ result, used_model = analyze_image(image, prompt)
161
+ console.print(f"[dim]── {used_model} vision ──[/dim]")
162
+ rprint(Panel(result, title=f"[cyan]{image}[/cyan]", border_style="cyan"))
163
+ except VisionError as error:
164
+ show_error(str(error))
165
+
166
+ @app.command()
167
+ def browser(
168
+ action: str = typer.Argument(..., help="scrape, screenshot, analyze"),
169
+ url: str = typer.Argument(..., help="URL to open"),
170
+ output: Optional[str] = typer.Option(
171
+ None,
172
+ "--out",
173
+ "-o",
174
+ help="Output file for screenshot",
175
+ ),
176
+ model: Optional[str] = typer.Option(None, "--model", "-m"),
177
+ ):
178
+ """Browser automation for public HTTP(S) webpages."""
179
+ try:
180
+ if action == "scrape":
181
+ with console.status(f"[cyan]Opening {url}...[/cyan]"):
182
+ content = scrape(url)
183
+ console.print(Panel(
184
+ content[:3000],
185
+ title=f"[cyan]{url}[/cyan]",
186
+ border_style="cyan",
187
+ ))
188
+ elif action == "screenshot":
189
+ output_path = output or "screenshot.png"
190
+ with console.status(f"[cyan]Taking screenshot of {url}...[/cyan]"):
191
+ saved = screenshot(url, output_path)
192
+ console.print(f"[green]Screenshot saved:[/green] {saved}")
193
+ elif action == "analyze":
194
+ with console.status(f"[cyan]Loading {url}...[/cyan]"):
195
+ content = scrape(url)
196
+ prompt = (
197
+ "Analyze this webpage content and summarize what it is about:"
198
+ f"\n\nURL: {url}\n\n{content}"
199
+ )
200
+ context.add("user", prompt)
201
+ with console.status("[cyan]Analyzing...[/cyan]"):
202
+ response, _ = chat_with_fallback(
203
+ context.get_messages(),
204
+ system=system_prompt,
205
+ preferred=model,
206
+ )
207
+ context.add("assistant", response.content)
208
+ rprint(Panel(
209
+ response.content,
210
+ title=f"[cyan]Analysis — {url}[/cyan]",
211
+ border_style="cyan",
212
+ ))
213
+ else:
214
+ console.print(
215
+ "[red]Usage: zai browser scrape <url> | "
216
+ "screenshot <url> | analyze <url>[/red]"
217
+ )
218
+ except BrowserError as error:
219
+ show_error(str(error))
220
+ if (
221
+ "playwright install" in str(error).lower()
222
+ or "not installed" in str(error).lower()
223
+ ):
224
+ console.print(
225
+ "[dim]Fix: pip install playwright && "
226
+ "playwright install chromium[/dim]"
227
+ )