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/interactive.py ADDED
@@ -0,0 +1,518 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+
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 get_api_key, get_models, load_config
11
+ from ..core.agent import plan_agent, run_agent, undo_last
12
+ from ..core.commands import get_command_prompt, list_commands, load_commands
13
+ from ..core.hooks import fire as fire_hook
14
+ from ..core.fallback import has_available_provider
15
+ from ..core.context import ContextManager
16
+ from ..core.memory import add_project, get_last_session, save_session
17
+ from ..core.session import (
18
+ delete_session,
19
+ list_sessions,
20
+ load_history,
21
+ load_latest_session,
22
+ rename_session,
23
+ save_auto_history,
24
+ save_history,
25
+ search_sessions,
26
+ )
27
+ from ..core.watch import is_watching, start_watch, stop_watch
28
+ from ..plugins import loader as plugin_loader
29
+ from ..providers.base import Message
30
+ from ..tools.git import get_diff
31
+ from ..ui.input import InteractiveInput
32
+ from .common import closest_command
33
+
34
+ console = Console()
35
+
36
+ CORE_COMMANDS = {
37
+ "help", "clear", "files", "diff", "undo", "plan", "test",
38
+ "watch", "commit", "review", "fix", "explain", "feature",
39
+ "session", "resume", "model", "memory", "commands",
40
+ }
41
+
42
+
43
+ def _show_help() -> None:
44
+ console.print(
45
+ "[bold cyan]── Core ──[/bold cyan]\n"
46
+ "[cyan]/help[/cyan] This menu\n"
47
+ "[cyan]/clear[/cyan] Clear screen\n"
48
+ "[cyan]/files[/cyan] List files in current folder\n"
49
+ "[cyan]/diff[/cyan] Show git diff (colored)\n"
50
+ "[cyan]/undo[/cyan] Restore last AI-changed file\n"
51
+ "\n[bold cyan]── AI Modes ──[/bold cyan]\n"
52
+ "[cyan]/plan <task>[/cyan] Plan first, approve, then execute\n"
53
+ "[cyan]/test[/cyan] Run pytest + auto-fix failures\n"
54
+ "[cyan]/watch[/cyan] Toggle file-change watcher\n"
55
+ "\n[bold cyan]── Git & Code ──[/bold cyan]\n"
56
+ "[cyan]/commit[/cyan] Auto git commit with AI message\n"
57
+ "[cyan]/review[/cyan] Code review of current changes\n"
58
+ "[cyan]/fix <file>[/cyan] Fix bugs in a file\n"
59
+ "[cyan]/explain <file>[/cyan] Explain code in a file\n"
60
+ "[cyan]/feature <desc>[/cyan] Build a new feature\n"
61
+ "\n[bold cyan]── Session ──[/bold cyan]\n"
62
+ "[cyan]/resume [name][/cyan] Resume latest or named session\n"
63
+ "[cyan]/session save [name][/cyan] Save current conversation\n"
64
+ "[cyan]/session load <name>[/cyan] Load a saved conversation\n"
65
+ "[cyan]/session list[/cyan] List saved sessions\n"
66
+ "[cyan]/session search <query>[/cyan] Search saved conversations\n"
67
+ "[cyan]/session rename <old> <new>[/cyan] Rename a saved session\n"
68
+ "[cyan]/session delete <name>[/cyan] Delete a saved session\n"
69
+ "\n[bold cyan]── Settings ──[/bold cyan]\n"
70
+ "[cyan]/model list[/cyan] Show available AI models\n"
71
+ "[cyan]/model <name>[/cyan] Switch model (gemini/groq/etc)\n"
72
+ "[cyan]/memory[/cyan] Show last session info\n"
73
+ "[cyan]/commands[/cyan] List all slash commands\n"
74
+ "\n[dim]Just type naturally — zai creates files, reads code, "
75
+ "runs commands automatically.[/dim]"
76
+ )
77
+
78
+
79
+ def _correct_slash_command(message: str) -> str:
80
+ stripped = message.strip()
81
+ if not stripped.startswith("/") or stripped.startswith("//"):
82
+ return message
83
+ parts = stripped[1:].split(" ", 1)
84
+ typed_name = parts[0].lower()
85
+ arguments = parts[1] if len(parts) > 1 else ""
86
+ available = (
87
+ CORE_COMMANDS
88
+ | set(load_commands().keys())
89
+ | set(plugin_loader.get_all_commands().keys())
90
+ )
91
+ if typed_name in available:
92
+ return message
93
+ corrected = closest_command(typed_name, available)
94
+ if not corrected:
95
+ return message
96
+ console.print(
97
+ f"[yellow]Command '/{typed_name}' not found; "
98
+ f"using '/{corrected}'.[/yellow]"
99
+ )
100
+ return f"/{corrected}{f' {arguments}' if arguments else ''}"
101
+
102
+
103
+ def _show_files(cwd: str) -> None:
104
+ try:
105
+ for name in sorted(os.listdir(cwd)):
106
+ path = os.path.join(cwd, name)
107
+ if os.path.isdir(path):
108
+ console.print(f" [blue]{name}/[/blue]")
109
+ else:
110
+ console.print(
111
+ f" [cyan]{name}[/cyan] [dim]{os.path.getsize(path)//1024}kb[/dim]"
112
+ )
113
+ except Exception as error:
114
+ console.print(f"[red]{error}[/red]")
115
+
116
+
117
+ def _show_diff() -> None:
118
+ diff = get_diff()
119
+ if not diff:
120
+ console.print("[dim]No changes.[/dim]")
121
+ return
122
+ colored = []
123
+ for line in diff.splitlines():
124
+ if line.startswith("+") and not line.startswith("+++"):
125
+ colored.append(f"[green]{line}[/green]")
126
+ elif line.startswith("-") and not line.startswith("---"):
127
+ colored.append(f"[red]{line}[/red]")
128
+ elif line.startswith("@@"):
129
+ colored.append(f"[cyan]{line}[/cyan]")
130
+ else:
131
+ colored.append(f"[dim]{line}[/dim]")
132
+ console.print(Panel(
133
+ "\n".join(colored),
134
+ title="[cyan]Git Diff[/cyan]",
135
+ border_style="cyan",
136
+ ))
137
+
138
+
139
+ def _connect_mcp_servers() -> None:
140
+ try:
141
+ from ..mcp.client import connect
142
+ from ..mcp.manager import build_server_command, load_mcp
143
+
144
+ for server in load_mcp():
145
+ try:
146
+ command, env = build_server_command(server, os.getcwd())
147
+ except ValueError as error:
148
+ console.print(
149
+ f"[dim]MCP '{server.get('name', 'server')}': {error}[/dim]"
150
+ )
151
+ continue
152
+ connect(server["name"], command, env)
153
+ except Exception:
154
+ pass
155
+
156
+
157
+ def run_interactive(model: str = None) -> None:
158
+ cwd = os.getcwd()
159
+ if not has_available_provider():
160
+ console.print(Panel(
161
+ "[red]No AI provider available![/red]\n"
162
+ "Run [cyan]zai setup[/cyan] or start Ollama.",
163
+ border_style="red",
164
+ ))
165
+ return
166
+
167
+ try:
168
+ visible = [name for name in sorted(os.listdir(cwd)) if not name.startswith(".")]
169
+ except Exception:
170
+ visible = []
171
+ console.print(Panel(
172
+ f"[bold cyan]zai[/bold cyan] — AI coding assistant\n"
173
+ f"[dim]Folder: {cwd}[/dim]\n"
174
+ f"[dim]Files: {', '.join(visible[:12])}"
175
+ f"{'...' if len(visible) > 12 else ''}[/dim]\n"
176
+ "[dim]Ctrl+C to exit | /help for commands[/dim]",
177
+ border_style="cyan",
178
+ ))
179
+
180
+ preferred = model or load_config()["default_model"]
181
+ add_project(Path(cwd).name, cwd)
182
+ fire_hook("SessionStart", {"cwd": cwd, "files": visible})
183
+ if (Path(cwd) / "CLAUDE.md").exists():
184
+ console.print(f"[dim]CLAUDE.md loaded from {cwd}[/dim]")
185
+ if (Path.home() / ".zai" / "CLAUDE.md").exists():
186
+ console.print("[dim]Global CLAUDE.md loaded (~/.zai/CLAUDE.md)[/dim]")
187
+
188
+ loaded_plugins = plugin_loader.load_all()
189
+ if loaded_plugins:
190
+ console.print(f"[dim]Plugins: {', '.join(loaded_plugins.keys())}[/dim]")
191
+ for filename, error in plugin_loader.get_errors().items():
192
+ console.print(f"[yellow]Plugin error ({filename}): {error}[/yellow]")
193
+ _connect_mcp_servers()
194
+
195
+ def available_commands():
196
+ return (
197
+ CORE_COMMANDS
198
+ | set(load_commands().keys())
199
+ | set(plugin_loader.get_all_commands().keys())
200
+ )
201
+
202
+ def saved_session_names():
203
+ return [session["name"] for session in list_sessions()]
204
+
205
+ terminal_input = InteractiveInput(
206
+ cwd,
207
+ command_provider=available_commands,
208
+ model_provider=lambda: get_models().keys(),
209
+ session_provider=saved_session_names,
210
+ )
211
+ if terminal_input.enhanced:
212
+ console.print(
213
+ "[dim]Input history and completion enabled. "
214
+ "Alt+Enter or Ctrl+J inserts a newline.[/dim]"
215
+ )
216
+
217
+ session_context = ContextManager(model=preferred)
218
+ initial_history = [
219
+ Message(
220
+ role="user",
221
+ content=(
222
+ f"My current folder is: {cwd}\nFiles here: {', '.join(visible)}\n"
223
+ "Help me work on this project."
224
+ ),
225
+ pinned=True,
226
+ ),
227
+ Message(
228
+ role="assistant",
229
+ content=(
230
+ f"I can see your folder at {cwd} with these files: "
231
+ f"{', '.join(visible)}. I can read, create, and edit files directly. "
232
+ "What would you like to do?"
233
+ ),
234
+ pinned=True,
235
+ ),
236
+ ]
237
+ session_context.replace_messages(initial_history)
238
+ history = session_context.get_messages()
239
+
240
+ def remember_turn(user_content: str, assistant_content: str) -> None:
241
+ session_context.add("user", user_content)
242
+ session_context.add("assistant", assistant_content)
243
+ if session_context.is_near_limit():
244
+ session_context.compress(preferred)
245
+ save_auto_history(history, cwd)
246
+
247
+ while True:
248
+ try:
249
+ try:
250
+ message = terminal_input.prompt()
251
+ except KeyboardInterrupt:
252
+ console.print("[dim]Input cancelled.[/dim]")
253
+ continue
254
+ if not message.strip():
255
+ continue
256
+ message = _correct_slash_command(message)
257
+ stripped = message.strip()
258
+
259
+ if stripped == "/help":
260
+ _show_help()
261
+ elif stripped == "/clear":
262
+ console.clear()
263
+ elif stripped == "/files":
264
+ _show_files(cwd)
265
+ elif stripped.startswith("/model "):
266
+ name = stripped.split(" ", 1)[1].strip()
267
+ if name == "list":
268
+ for key, data in get_models().items():
269
+ key_status = (
270
+ "[green]ok[/green]"
271
+ if get_api_key(data["provider"])
272
+ else "[red]no key[/red]"
273
+ )
274
+ console.print(
275
+ f" [cyan]{key}[/cyan] — {data['name']} ({key_status})"
276
+ )
277
+ elif name in get_models():
278
+ preferred = name
279
+ session_context.set_model(name)
280
+ console.print(f"[green]Switched to {name}[/green]")
281
+ else:
282
+ console.print(f"[red]Unknown model: {name}[/red]")
283
+ elif stripped == "/memory":
284
+ last = get_last_session()
285
+ if last:
286
+ console.print(f"[dim]{last['task']} — {last['date']}[/dim]")
287
+ elif stripped == "/undo":
288
+ console.print(f"[cyan]{undo_last(cwd)}[/cyan]")
289
+ elif stripped == "/diff":
290
+ _show_diff()
291
+ elif stripped.startswith("/plan"):
292
+ task = stripped[5:].strip()
293
+ if not task:
294
+ console.print("[red]Usage: /plan <what you want to build>[/red]")
295
+ else:
296
+ console.print("[dim]Planning...[/dim]")
297
+ plan = plan_agent(task, history, cwd, preferred)
298
+ if plan and Prompt.ask("[cyan]Proceed?[/cyan]", default="no").lower() in ("yes", "y"):
299
+ console.print("[dim]Executing...[/dim]")
300
+ result = run_agent(task, history, cwd, preferred)
301
+ if result:
302
+ remember_turn(task, result)
303
+ save_session(task=task[:80], model=preferred)
304
+ elif stripped == "/test":
305
+ for round_number in range(3):
306
+ console.print(
307
+ f"[cyan]Running tests (round {round_number + 1}/3)...[/cyan]"
308
+ )
309
+ result = subprocess.run(
310
+ ["python", "-m", "pytest", "--tb=short", "-q"],
311
+ capture_output=True,
312
+ text=True,
313
+ cwd=cwd,
314
+ timeout=120,
315
+ )
316
+ output = (result.stdout + result.stderr).strip()
317
+ if not output:
318
+ console.print("[yellow]No tests found.[/yellow]")
319
+ break
320
+ console.print(Panel(
321
+ output[:2000],
322
+ title="[cyan]Test Output[/cyan]",
323
+ border_style="cyan",
324
+ ))
325
+ if result.returncode == 0:
326
+ console.print("[green]All tests passing![/green]")
327
+ break
328
+ if round_number >= 2:
329
+ console.print("[yellow]Max rounds reached. Tests still failing.[/yellow]")
330
+ break
331
+ console.print("[yellow]Asking AI to fix...[/yellow]")
332
+ fix_prompt = (
333
+ f"Tests are failing:\n\n{output}\n\n"
334
+ "Read the failing files and fix the issues."
335
+ )
336
+ fix_result = run_agent(fix_prompt, history, cwd, preferred)
337
+ if fix_result:
338
+ remember_turn(fix_prompt, fix_result)
339
+ elif stripped.startswith("/resume"):
340
+ parts = stripped.split(None, 1)
341
+ name = parts[1].strip() if len(parts) > 1 else None
342
+ latest = None if name else load_latest_session(cwd)
343
+ loaded = load_history(name) if name else (
344
+ latest[0] if latest else None
345
+ )
346
+ resumed_name = name or (
347
+ latest[1]["name"] if latest else "latest"
348
+ )
349
+ if loaded:
350
+ session_context.replace_messages(loaded)
351
+ console.print(
352
+ f"[green]Session resumed ({resumed_name}):[/green] "
353
+ f"{len(loaded)} messages"
354
+ )
355
+ elif name:
356
+ console.print(f"[red]Session not found: {name}[/red]")
357
+ else:
358
+ console.print(
359
+ "[yellow]No saved sessions. "
360
+ "Use /session save [name] first.[/yellow]"
361
+ )
362
+ elif stripped.startswith("/session"):
363
+ parts = stripped.split()
364
+ action = parts[1] if len(parts) > 1 else "list"
365
+ arguments = parts[2:]
366
+ argument = " ".join(arguments) if arguments else None
367
+ if action == "save":
368
+ path = save_history(history, argument, project_path=cwd)
369
+ console.print(f"[green]Session saved:[/green] {path}")
370
+ elif action == "load":
371
+ if not argument:
372
+ console.print("[red]Usage: /session load <name>[/red]")
373
+ else:
374
+ loaded = load_history(argument)
375
+ if loaded:
376
+ session_context.replace_messages(loaded)
377
+ console.print(
378
+ f"[green]Session loaded:[/green] {len(loaded)} messages"
379
+ )
380
+ else:
381
+ console.print(f"[red]Session not found: {argument}[/red]")
382
+ elif action == "search":
383
+ if not argument:
384
+ console.print("[red]Usage: /session search <query>[/red]")
385
+ else:
386
+ matches = search_sessions(argument, project_path=cwd)
387
+ if not matches:
388
+ console.print("[dim]No matching sessions.[/dim]")
389
+ else:
390
+ table = Table(
391
+ title="Session Search",
392
+ border_style="cyan",
393
+ )
394
+ table.add_column("Name", style="cyan")
395
+ table.add_column("Title")
396
+ table.add_column("Messages", justify="right")
397
+ for session in matches:
398
+ table.add_row(
399
+ session["name"],
400
+ session["title"],
401
+ str(session["messages"]),
402
+ )
403
+ console.print(table)
404
+ elif action == "rename":
405
+ if len(arguments) != 2:
406
+ console.print(
407
+ "[red]Usage: /session rename <old> <new>[/red]"
408
+ )
409
+ else:
410
+ renamed = rename_session(arguments[0], arguments[1])
411
+ if renamed:
412
+ console.print(
413
+ f"[green]Session renamed:[/green] {arguments[1]}"
414
+ )
415
+ else:
416
+ console.print(
417
+ "[red]Rename failed: session missing, ambiguous, "
418
+ "automatic, or destination exists.[/red]"
419
+ )
420
+ elif action == "delete":
421
+ if len(arguments) != 1:
422
+ console.print("[red]Usage: /session delete <name>[/red]")
423
+ elif Confirm.ask(
424
+ f"Delete session '{arguments[0]}'?",
425
+ default=False,
426
+ ) and delete_session(arguments[0]):
427
+ console.print(
428
+ f"[green]Session deleted:[/green] {arguments[0]}"
429
+ )
430
+ else:
431
+ console.print("[yellow]Session was not deleted.[/yellow]")
432
+ elif action == "list":
433
+ sessions = list_sessions(project_path=cwd)
434
+ if not sessions:
435
+ console.print("[dim]No saved sessions.[/dim]")
436
+ else:
437
+ table = Table(title="Saved Sessions", border_style="cyan")
438
+ table.add_column("ID", style="dim")
439
+ table.add_column("Name", style="cyan")
440
+ table.add_column("Title")
441
+ table.add_column("Messages", justify="right")
442
+ for session in sessions:
443
+ table.add_row(
444
+ session["id"][:8],
445
+ session["name"],
446
+ session["title"],
447
+ str(session["messages"]),
448
+ )
449
+ console.print(table)
450
+ else:
451
+ console.print(
452
+ "[red]Usage: /session list | save [name] | load <name> | "
453
+ "search <query> | rename <old> <new> | delete <name>[/red]"
454
+ )
455
+ elif stripped == "/watch":
456
+ if is_watching():
457
+ stop_watch()
458
+ console.print("[yellow]Watch mode off.[/yellow]")
459
+ else:
460
+ def on_file_change(filename):
461
+ prompt = (
462
+ f"File changed: {filename}. "
463
+ "Read it and check for issues or errors."
464
+ )
465
+ result = run_agent(prompt, history, cwd, preferred)
466
+ if result:
467
+ remember_turn(prompt, result)
468
+
469
+ if start_watch(cwd, on_file_change):
470
+ console.print(f"[green]Watch mode on.[/green] Watching: {cwd}")
471
+ elif stripped.startswith("/") and not stripped.startswith("//"):
472
+ parts = stripped[1:].split(" ", 1)
473
+ command_name = parts[0].lower()
474
+ arguments = parts[1] if len(parts) > 1 else ""
475
+ prompt = get_command_prompt(command_name, arguments)
476
+ if not prompt:
477
+ plugin_commands = plugin_loader.get_all_commands()
478
+ if command_name in plugin_commands:
479
+ prompt = plugin_commands[command_name].get("body", "").replace(
480
+ "$ARGUMENTS",
481
+ arguments,
482
+ )
483
+ if prompt:
484
+ console.print(f"[dim]Running /{command_name}...[/dim]")
485
+ result = run_agent(prompt, history, cwd, preferred)
486
+ if result:
487
+ remember_turn(
488
+ f"/{command_name} {arguments}".strip(),
489
+ result,
490
+ )
491
+ save_session(task=f"/{command_name}", model=preferred)
492
+ elif command_name == "commands":
493
+ for command in list_commands():
494
+ console.print(
495
+ f" [cyan]/{command['name']}[/cyan] — "
496
+ f"{command['description']}"
497
+ )
498
+ for name, data in plugin_loader.get_all_commands().items():
499
+ console.print(
500
+ f" [blue]/{name}[/blue] — "
501
+ f"{data.get('description', '')} [dim](plugin)[/dim]"
502
+ )
503
+ else:
504
+ console.print(f"[red]Unknown command: /{command_name}[/red]")
505
+ console.print("[dim]Type /commands to see available commands[/dim]")
506
+ else:
507
+ fire_hook("UserPromptSubmit", {"message": stripped[:200]})
508
+ result = run_agent(stripped, history, cwd, preferred)
509
+ if result:
510
+ remember_turn(stripped, result)
511
+ save_session(task=stripped[:80], model=preferred)
512
+ except KeyboardInterrupt:
513
+ console.print("\n[yellow]Current operation cancelled.[/yellow]")
514
+ continue
515
+ except EOFError:
516
+ fire_hook("SessionEnd", {"cwd": cwd, "messages": len(history)})
517
+ console.print("\n[dim]Goodbye![/dim]")
518
+ break