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.
- zai/__init__.py +1 -0
- zai/__main__.py +4 -0
- zai/cli/__init__.py +1 -0
- zai/cli/common.py +16 -0
- zai/cli/integrations.py +319 -0
- zai/cli/interactive.py +518 -0
- zai/cli/settings.py +436 -0
- zai/cli/utilities.py +227 -0
- zai/cli/workflows.py +137 -0
- zai/commands/commit.md +24 -0
- zai/commands/explain.md +17 -0
- zai/commands/feature.md +34 -0
- zai/commands/fix.md +14 -0
- zai/commands/review.md +22 -0
- zai/config.py +307 -0
- zai/core/__init__.py +0 -0
- zai/core/agent.py +701 -0
- zai/core/cancellation.py +67 -0
- zai/core/commands.py +85 -0
- zai/core/context.py +299 -0
- zai/core/errors.py +125 -0
- zai/core/fallback.py +171 -0
- zai/core/hooks.py +115 -0
- zai/core/memory.py +57 -0
- zai/core/process.py +204 -0
- zai/core/repomap.py +381 -0
- zai/core/runtime.py +29 -0
- zai/core/security.py +33 -0
- zai/core/session.py +425 -0
- zai/core/storage.py +193 -0
- zai/core/streaming.py +157 -0
- zai/core/tool_schema.py +133 -0
- zai/core/undo.py +443 -0
- zai/core/watch.py +80 -0
- zai/main.py +210 -0
- zai/mcp/__init__.py +0 -0
- zai/mcp/client.py +431 -0
- zai/mcp/manager.py +118 -0
- zai/plugins/__init__.py +2 -0
- zai/plugins/base.py +49 -0
- zai/plugins/loader.py +404 -0
- zai/providers/__init__.py +22 -0
- zai/providers/anthropic.py +131 -0
- zai/providers/base.py +67 -0
- zai/providers/cerebras.py +57 -0
- zai/providers/gemini.py +119 -0
- zai/providers/groq.py +116 -0
- zai/providers/ollama.py +62 -0
- zai/providers/openai.py +124 -0
- zai/providers/openrouter.py +63 -0
- zai/providers/qwen.py +47 -0
- zai/skills/__init__.py +0 -0
- zai/skills/registry.py +52 -0
- zai/tools/__init__.py +0 -0
- zai/tools/browser.py +224 -0
- zai/tools/code_runner.py +49 -0
- zai/tools/files.py +53 -0
- zai/tools/git.py +38 -0
- zai/tools/search.py +157 -0
- zai/tools/vision.py +128 -0
- zai/ui/__init__.py +0 -0
- zai/ui/input.py +199 -0
- zai_cli-0.1.0.dist-info/METADATA +722 -0
- zai_cli-0.1.0.dist-info/RECORD +68 -0
- zai_cli-0.1.0.dist-info/WHEEL +5 -0
- zai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- zai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|