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/tools/vision.py ADDED
@@ -0,0 +1,128 @@
1
+ import base64
2
+ from pathlib import Path
3
+
4
+ from PIL import Image, UnidentifiedImageError
5
+
6
+ from ..core.errors import ZaiError, NoAPIKeyError
7
+ from ..config import get_api_key, get_model_config
8
+
9
+ SUPPORTED = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
10
+ MAX_IMAGE_BYTES = 10 * 1024 * 1024
11
+ MAX_IMAGE_PIXELS = 40_000_000
12
+
13
+
14
+ class VisionError(ZaiError):
15
+ pass
16
+
17
+
18
+ def _validate_image(path: str) -> tuple[Path, str]:
19
+ p = Path(path)
20
+ if not p.exists():
21
+ raise VisionError(f"Image not found: {path}")
22
+ if not p.is_file():
23
+ raise VisionError(f"Not an image file: {path}")
24
+ suffix = p.suffix.lower()
25
+ if suffix not in SUPPORTED:
26
+ allowed = ", ".join(sorted(SUPPORTED))
27
+ raise VisionError(f"Unsupported format: {suffix}. Use: {allowed}")
28
+ try:
29
+ size = p.stat().st_size
30
+ except OSError as error:
31
+ raise VisionError(f"Cannot inspect image: {error}") from error
32
+ if size > MAX_IMAGE_BYTES:
33
+ raise VisionError("Image exceeds the 10 MB size limit")
34
+ try:
35
+ with Image.open(p) as image:
36
+ width, height = image.size
37
+ if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS:
38
+ raise VisionError("Image exceeds the 40 megapixel limit")
39
+ image.verify()
40
+ except VisionError:
41
+ raise
42
+ except (UnidentifiedImageError, OSError, ValueError) as error:
43
+ raise VisionError(f"Invalid or corrupt image: {error}") from error
44
+ mime = {
45
+ ".png": "image/png",
46
+ ".jpg": "image/jpeg",
47
+ ".jpeg": "image/jpeg",
48
+ ".gif": "image/gif",
49
+ ".webp": "image/webp",
50
+ ".bmp": "image/bmp",
51
+ }[suffix]
52
+ return p, mime
53
+
54
+
55
+ def _encode_image(path: str) -> tuple[str, str]:
56
+ p, mime = _validate_image(path)
57
+ try:
58
+ data = base64.b64encode(p.read_bytes()).decode()
59
+ except OSError as error:
60
+ raise VisionError(f"Cannot read image: {error}") from error
61
+ return data, mime
62
+
63
+
64
+ def analyze_with_gemini(image_path: str, prompt: str = "Describe this image in detail.") -> str:
65
+ key = get_api_key("gemini")
66
+ if not key:
67
+ raise NoAPIKeyError("gemini")
68
+ from google import genai
69
+ from google.genai import types
70
+
71
+ data, mime = _encode_image(image_path)
72
+ client = genai.Client(api_key=key)
73
+ try:
74
+ result = client.models.generate_content(
75
+ model=get_model_config("gemini")["model_id"],
76
+ contents=[
77
+ prompt,
78
+ types.Part.from_bytes(
79
+ data=base64.b64decode(data),
80
+ mime_type=mime,
81
+ ),
82
+ ],
83
+ )
84
+ return result.text or ""
85
+ except Exception as e:
86
+ raise VisionError(f"Gemini vision failed: {e}")
87
+
88
+
89
+ def analyze_with_groq(image_path: str, prompt: str = "Describe this image in detail.") -> str:
90
+ key = get_api_key("groq")
91
+ if not key:
92
+ raise NoAPIKeyError("groq")
93
+ from groq import Groq
94
+ data, mime = _encode_image(image_path)
95
+ client = Groq(api_key=key)
96
+ try:
97
+ result = client.chat.completions.create(
98
+ model="llama-4-scout-17b-16e-instruct",
99
+ messages=[{
100
+ "role": "user",
101
+ "content": [
102
+ {"type": "text", "text": prompt},
103
+ {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{data}"}},
104
+ ],
105
+ }],
106
+ )
107
+ return result.choices[0].message.content or ""
108
+ except Exception as e:
109
+ raise VisionError(f"Groq vision failed: {e}")
110
+
111
+
112
+ def analyze_image(image_path: str, prompt: str = "Describe this image in detail.") -> tuple[str, str]:
113
+ """Try Gemini first, then Groq for vision."""
114
+ _validate_image(image_path)
115
+ failures = []
116
+ if get_api_key("gemini"):
117
+ try:
118
+ return analyze_with_gemini(image_path, prompt), "gemini"
119
+ except (VisionError, NoAPIKeyError) as error:
120
+ failures.append(str(error))
121
+ if get_api_key("groq"):
122
+ try:
123
+ return analyze_with_groq(image_path, prompt), "groq"
124
+ except (VisionError, NoAPIKeyError) as error:
125
+ failures.append(str(error))
126
+ if failures:
127
+ raise VisionError("Vision analysis failed: " + "; ".join(failures))
128
+ raise VisionError("No vision-capable model available. Add Gemini or Groq API key: zai setup")
zai/ui/__init__.py ADDED
File without changes
zai/ui/input.py ADDED
@@ -0,0 +1,199 @@
1
+ """Interactive terminal input with history, completion, and multiline editing."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Callable, Iterable
7
+
8
+ from rich.prompt import Prompt
9
+
10
+ from ..config import ZAI_DIR
11
+
12
+ INPUT_HISTORY_FILE = ZAI_DIR / "input_history"
13
+ PATH_COMMANDS = {
14
+ "/fix",
15
+ "/explain",
16
+ "/review",
17
+ "/file",
18
+ }
19
+
20
+
21
+ def _path_candidates(fragment: str, cwd: str) -> list[tuple[str, str]]:
22
+ """Return (insert_text, display_text) project-relative path candidates."""
23
+ normalized = fragment.replace("\\", "/")
24
+ parent_text, separator, leaf = normalized.rpartition("/")
25
+ search_root = Path(cwd) / parent_text if separator else Path(cwd)
26
+ if not search_root.is_dir():
27
+ return []
28
+ candidates = []
29
+ try:
30
+ children = sorted(
31
+ search_root.iterdir(),
32
+ key=lambda path: (not path.is_dir(), path.name.lower()),
33
+ )
34
+ except OSError:
35
+ return []
36
+ for child in children:
37
+ if child.name.startswith(".") or not child.name.lower().startswith(leaf.lower()):
38
+ continue
39
+ relative = child.relative_to(Path(cwd)).as_posix()
40
+ insert = relative + ("/" if child.is_dir() else "")
41
+ display = insert
42
+ candidates.append((insert, display))
43
+ return candidates[:100]
44
+
45
+
46
+ def completion_candidates(
47
+ text: str,
48
+ cwd: str,
49
+ commands: Iterable[str],
50
+ models: Iterable[str] = (),
51
+ sessions: Iterable[str] = (),
52
+ ) -> list[tuple[str, str, str]]:
53
+ """Return (insert_text, display, metadata) completion candidates."""
54
+ before = text.lstrip()
55
+ if not before.startswith("/"):
56
+ return []
57
+
58
+ parts = before.split(maxsplit=1)
59
+ command_token = parts[0]
60
+ if len(parts) == 1 and not before.endswith(" "):
61
+ return [
62
+ (f"/{name}", f"/{name}", "command")
63
+ for name in sorted(set(commands))
64
+ if f"/{name}".startswith(command_token.lower())
65
+ ]
66
+
67
+ argument = parts[1] if len(parts) > 1 else ""
68
+ command = command_token.lower()
69
+ if command == "/model":
70
+ options = ["list", *models]
71
+ return [
72
+ (option, option, "model")
73
+ for option in options
74
+ if option.lower().startswith(argument.lower())
75
+ ]
76
+ if command == "/session":
77
+ subcommands = ["list", "save", "load", "search", "rename", "delete"]
78
+ if " " not in argument:
79
+ return [
80
+ (option, option, "session action")
81
+ for option in subcommands
82
+ if option.startswith(argument.lower())
83
+ ]
84
+ action, value = argument.split(" ", 1)
85
+ if action in {"load", "rename", "delete"}:
86
+ session_value = value.split(" ", 1)[0]
87
+ return [
88
+ (name, name, "saved session")
89
+ for name in sessions
90
+ if name.lower().startswith(session_value.lower())
91
+ ]
92
+ if command == "/resume":
93
+ return [
94
+ (name, name, "saved session")
95
+ for name in sessions
96
+ if name.lower().startswith(argument.lower())
97
+ ]
98
+ if command in PATH_COMMANDS:
99
+ return [
100
+ (insert, display, "directory" if insert.endswith("/") else "file")
101
+ for insert, display in _path_candidates(argument, cwd)
102
+ ]
103
+ return []
104
+
105
+
106
+ class InteractiveInput:
107
+ """Reusable input session. Enter submits; Alt+Enter/Ctrl+J inserts newline."""
108
+
109
+ def __init__(
110
+ self,
111
+ cwd: str,
112
+ command_provider: Callable[[], Iterable[str]],
113
+ model_provider: Callable[[], Iterable[str]] = lambda: (),
114
+ session_provider: Callable[[], Iterable[str]] = lambda: (),
115
+ history_file: str | Path = INPUT_HISTORY_FILE,
116
+ input=None,
117
+ output=None,
118
+ ):
119
+ self.cwd = cwd
120
+ self.command_provider = command_provider
121
+ self.model_provider = model_provider
122
+ self.session_provider = session_provider
123
+ self.history_file = Path(history_file)
124
+ self.input = input
125
+ self.output = output
126
+ self._session = self._build_session()
127
+
128
+ def _build_session(self):
129
+ try:
130
+ from prompt_toolkit import PromptSession
131
+ from prompt_toolkit.completion import Completer, Completion
132
+ from prompt_toolkit.history import FileHistory
133
+ from prompt_toolkit.key_binding import KeyBindings
134
+ except ImportError:
135
+ return None
136
+
137
+ owner = self
138
+
139
+ class ZaiCompleter(Completer):
140
+ def get_completions(self, document, complete_event):
141
+ before = document.text_before_cursor
142
+ candidates = completion_candidates(
143
+ before,
144
+ owner.cwd,
145
+ owner.command_provider(),
146
+ owner.model_provider(),
147
+ owner.session_provider(),
148
+ )
149
+ current = before.split()[-1] if before.split() else before
150
+ for insert, display, metadata in candidates:
151
+ yield Completion(
152
+ insert,
153
+ start_position=-len(current),
154
+ display=display,
155
+ display_meta=metadata,
156
+ )
157
+
158
+ bindings = KeyBindings()
159
+
160
+ @bindings.add("enter")
161
+ def submit(event):
162
+ event.current_buffer.validate_and_handle()
163
+
164
+ @bindings.add("escape", "enter")
165
+ @bindings.add("c-j")
166
+ def newline(event):
167
+ event.current_buffer.insert_text("\n")
168
+
169
+ self.history_file.parent.mkdir(parents=True, exist_ok=True)
170
+ try:
171
+ return PromptSession(
172
+ history=FileHistory(str(self.history_file)),
173
+ completer=ZaiCompleter(),
174
+ complete_while_typing=True,
175
+ multiline=True,
176
+ key_bindings=bindings,
177
+ enable_history_search=True,
178
+ input=self.input,
179
+ output=self.output,
180
+ )
181
+ except Exception:
182
+ # Redirected stdin/stdout and some Windows hosts have no console
183
+ # screen buffer. Rich's basic prompt remains usable there.
184
+ return None
185
+
186
+ @property
187
+ def enhanced(self) -> bool:
188
+ return self._session is not None
189
+
190
+ def prompt(self) -> str:
191
+ if self._session is None:
192
+ return Prompt.ask("[cyan]>[/cyan]")
193
+ return self._session.prompt(
194
+ "> ",
195
+ bottom_toolbar=(
196
+ "Enter submit | Alt+Enter/Ctrl+J newline | "
197
+ "Tab complete | Ctrl+C cancel input"
198
+ ),
199
+ )