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/core/undo.py ADDED
@@ -0,0 +1,443 @@
1
+ import hashlib
2
+ import json
3
+ import uuid
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from .errors import FileError
8
+ from .security import resolve_project_path
9
+ from .storage import atomic_write_json, atomic_write_text, file_lock
10
+
11
+ MAX_ACTIONS = 50
12
+ MAX_BACKUP_BYTES = 2 * 1024 * 1024
13
+ MAX_TOTAL_BACKUP_BYTES = 10 * 1024 * 1024
14
+ SENSITIVE_NAMES = {
15
+ ".env",
16
+ ".env.local",
17
+ ".env.production",
18
+ "credentials.json",
19
+ "secrets.json",
20
+ "id_rsa",
21
+ "id_ed25519",
22
+ }
23
+ SENSITIVE_SUFFIXES = {".pem", ".key", ".p12", ".pfx"}
24
+
25
+
26
+ def _undo_dir(cwd: str | Path) -> Path:
27
+ return Path(cwd).resolve() / ".zai" / "undo"
28
+
29
+
30
+ def _journal_path(cwd: str | Path) -> Path:
31
+ return _undo_dir(cwd) / "journal.json"
32
+
33
+
34
+ def _redo_path(cwd: str | Path) -> Path:
35
+ return _undo_dir(cwd) / "redo.json"
36
+
37
+
38
+ def _load_actions_unlocked(cwd: str | Path) -> list[dict]:
39
+ journal = _journal_path(cwd)
40
+ if not journal.exists():
41
+ return []
42
+ try:
43
+ data = json.loads(journal.read_text(encoding="utf-8"))
44
+ return data if isinstance(data, list) else []
45
+ except (OSError, UnicodeError, json.JSONDecodeError):
46
+ corrupt = journal.with_name(
47
+ f"{journal.name}.corrupt-"
48
+ f"{datetime.now().strftime('%Y%m%d-%H%M%S')}"
49
+ )
50
+ try:
51
+ journal.replace(corrupt)
52
+ except OSError:
53
+ pass
54
+ return []
55
+
56
+
57
+ def _load_actions(cwd: str | Path) -> list[dict]:
58
+ with file_lock(_journal_path(cwd)):
59
+ return _load_actions_unlocked(cwd)
60
+
61
+
62
+ def _save_actions_unlocked(cwd: str | Path, actions: list[dict]) -> None:
63
+ atomic_write_json(_journal_path(cwd), actions, lock=False)
64
+
65
+
66
+ def _load_redo_unlocked(cwd: str | Path) -> list[dict]:
67
+ path = _redo_path(cwd)
68
+ if not path.exists():
69
+ return []
70
+ try:
71
+ data = json.loads(path.read_text(encoding="utf-8"))
72
+ return data if isinstance(data, list) else []
73
+ except (OSError, UnicodeError, json.JSONDecodeError):
74
+ return []
75
+
76
+
77
+ def _save_redo_unlocked(cwd: str | Path, actions: list[dict]) -> None:
78
+ atomic_write_json(_redo_path(cwd), actions, lock=False)
79
+
80
+
81
+ def _relative(cwd: str | Path, path: str | Path) -> str:
82
+ return str(Path(path).resolve().relative_to(Path(cwd).resolve()))
83
+
84
+
85
+ def _hash_file(path: Path) -> str | None:
86
+ if not path.is_file():
87
+ return None
88
+ digest = hashlib.sha256()
89
+ try:
90
+ with path.open("rb") as handle:
91
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
92
+ digest.update(chunk)
93
+ return digest.hexdigest()
94
+ except OSError:
95
+ return None
96
+
97
+
98
+ def _is_sensitive(path: Path) -> bool:
99
+ name = path.name.lower()
100
+ return (
101
+ name in SENSITIVE_NAMES
102
+ or path.suffix.lower() in SENSITIVE_SUFFIXES
103
+ or name.startswith(".env.")
104
+ or any(part.lower() in {".ssh", ".aws", ".gnupg"} for part in path.parts)
105
+ )
106
+
107
+
108
+ def _delete_backup(cwd: str | Path, action: dict) -> None:
109
+ backup = action.get("backup")
110
+ if backup:
111
+ (_undo_dir(cwd) / backup).unlink(missing_ok=True)
112
+ redo_backup = action.get("redo_backup")
113
+ if redo_backup:
114
+ (_undo_dir(cwd) / redo_backup).unlink(missing_ok=True)
115
+
116
+
117
+ def _action_paths(action: dict) -> list[str]:
118
+ if action.get("type") == "rename":
119
+ return [action.get("source", ""), action.get("destination", "")]
120
+ return [action.get("path", "")]
121
+
122
+
123
+ def _summary(action: dict) -> str:
124
+ action_type = action.get("type")
125
+ paths = _action_paths(action)
126
+ if action_type == "restore_file":
127
+ return f"Restore previous content of {paths[0]}"
128
+ if action_type == "delete_file":
129
+ return f"Delete newly created file {paths[0]}"
130
+ if action_type == "rename":
131
+ return f"Rename {paths[1]} back to {paths[0]}"
132
+ if action_type == "remove_folder":
133
+ return f"Remove newly created folder {paths[0]}"
134
+ return f"Unknown operation on {', '.join(filter(None, paths))}"
135
+
136
+
137
+ def _normalize_action(action: dict) -> dict:
138
+ normalized = dict(action)
139
+ normalized.setdefault(
140
+ "id",
141
+ hashlib.sha256(
142
+ json.dumps(action, sort_keys=True).encode("utf-8")
143
+ ).hexdigest()[:12],
144
+ )
145
+ normalized.setdefault("summary", _summary(normalized))
146
+ normalized.setdefault("paths", list(filter(None, _action_paths(normalized))))
147
+ return normalized
148
+
149
+
150
+ def _prune(cwd: str | Path, actions: list[dict]) -> list[dict]:
151
+ while len(actions) > MAX_ACTIONS:
152
+ _delete_backup(cwd, actions.pop(0))
153
+
154
+ def backup_size(action: dict) -> int:
155
+ backup = action.get("backup")
156
+ if not backup:
157
+ return 0
158
+ try:
159
+ return (_undo_dir(cwd) / backup).stat().st_size
160
+ except OSError:
161
+ return 0
162
+
163
+ total = sum(backup_size(action) for action in actions)
164
+ while actions and total > MAX_TOTAL_BACKUP_BYTES:
165
+ removed = actions.pop(0)
166
+ total -= backup_size(removed)
167
+ _delete_backup(cwd, removed)
168
+ return actions
169
+
170
+
171
+ def _append_action(cwd: str | Path, action: dict) -> None:
172
+ journal = _journal_path(cwd)
173
+ with file_lock(journal):
174
+ actions = _load_actions_unlocked(cwd)
175
+ actions.append(_normalize_action({
176
+ **action,
177
+ "id": uuid.uuid4().hex,
178
+ "created_at": datetime.now(timezone.utc).isoformat(),
179
+ }))
180
+ for redo_action in _load_redo_unlocked(cwd):
181
+ _delete_backup(cwd, redo_action)
182
+ _save_redo_unlocked(cwd, [])
183
+ _save_actions_unlocked(cwd, _prune(cwd, actions))
184
+
185
+
186
+ def record_file_change(
187
+ cwd: str,
188
+ path: str | Path,
189
+ previous_content: str | None,
190
+ ) -> bool:
191
+ """Record a verified file mutation. Returns False when backup is skipped."""
192
+ target = Path(path).resolve()
193
+ action = {
194
+ "type": "delete_file" if previous_content is None else "restore_file",
195
+ "path": _relative(cwd, target),
196
+ "expected_hash": _hash_file(target),
197
+ }
198
+ if previous_content is not None:
199
+ encoded = previous_content.encode("utf-8")
200
+ if _is_sensitive(target) or len(encoded) > MAX_BACKUP_BYTES:
201
+ return False
202
+ backup_name = f"{uuid.uuid4().hex}.txt"
203
+ backup_path = _undo_dir(cwd) / backup_name
204
+ atomic_write_text(backup_path, previous_content, lock=False)
205
+ action["backup"] = backup_name
206
+ action["backup_hash"] = hashlib.sha256(encoded).hexdigest()
207
+ _append_action(cwd, action)
208
+ return True
209
+
210
+
211
+ def record_rename(cwd: str, source: str | Path, destination: str | Path) -> None:
212
+ destination_path = Path(destination).resolve()
213
+ _append_action(cwd, {
214
+ "type": "rename",
215
+ "source": _relative(cwd, source),
216
+ "destination": _relative(cwd, destination_path),
217
+ "expected_hash": _hash_file(destination_path),
218
+ })
219
+
220
+
221
+ def record_created_folder(cwd: str, path: str | Path) -> None:
222
+ _append_action(cwd, {
223
+ "type": "remove_folder",
224
+ "path": _relative(cwd, path),
225
+ })
226
+
227
+
228
+ def _conflict(path: Path, expected_hash: str | None) -> bool:
229
+ if expected_hash is None:
230
+ return False
231
+ return _hash_file(path) != expected_hash
232
+
233
+
234
+ def list_actions(cwd: str) -> list[dict]:
235
+ """Return inspectable undo actions, newest first."""
236
+ return [_normalize_action(item) for item in reversed(_load_actions(cwd))]
237
+
238
+
239
+ def get_action(cwd: str, identifier: str) -> dict | None:
240
+ matches = [
241
+ item
242
+ for item in list_actions(cwd)
243
+ if item["id"] == identifier or item["id"].startswith(identifier)
244
+ ]
245
+ return matches[0] if len(matches) == 1 else None
246
+
247
+
248
+ def _write_redo_backup(cwd: str, content: str) -> tuple[str, str]:
249
+ encoded = content.encode("utf-8")
250
+ backup_name = f"redo-{uuid.uuid4().hex}.txt"
251
+ atomic_write_text(_undo_dir(cwd) / backup_name, content, lock=False)
252
+ return backup_name, hashlib.sha256(encoded).hexdigest()
253
+
254
+
255
+ def _undo_one(cwd: str, action: dict) -> tuple[str, dict | None]:
256
+ action_type = action.get("type")
257
+ redo_action = dict(action)
258
+ try:
259
+ if action_type == "restore_file":
260
+ path = resolve_project_path(cwd, action["path"])
261
+ backup = _undo_dir(cwd) / action["backup"]
262
+ if not backup.is_file():
263
+ return "Cannot undo: backup file is missing.", None
264
+ if _conflict(path, action.get("expected_hash")):
265
+ return (
266
+ f"Cannot undo: {path.name} changed after the AI edit. "
267
+ "Current content was preserved."
268
+ ), None
269
+ current = path.read_text(encoding="utf-8")
270
+ redo_backup, redo_hash = _write_redo_backup(cwd, current)
271
+ previous = backup.read_text(encoding="utf-8")
272
+ if hashlib.sha256(previous.encode("utf-8")).hexdigest() != action.get(
273
+ "backup_hash"
274
+ ):
275
+ _delete_backup(cwd, {"redo_backup": redo_backup})
276
+ return "Cannot undo: backup integrity check failed.", None
277
+ atomic_write_text(path, previous, mode=0o644, lock=False)
278
+ redo_action.update({
279
+ "redo_backup": redo_backup,
280
+ "redo_hash": redo_hash,
281
+ "undo_hash": _hash_file(path),
282
+ })
283
+ message = f"Restored: {path.name}"
284
+ elif action_type == "delete_file":
285
+ path = resolve_project_path(cwd, action["path"])
286
+ if path.exists() and not path.is_file():
287
+ return f"Cannot undo: not a file: {path.name}", None
288
+ if path.exists() and _conflict(path, action.get("expected_hash")):
289
+ return (
290
+ f"Cannot undo: {path.name} changed after creation. "
291
+ "Current content was preserved."
292
+ ), None
293
+ if path.exists():
294
+ current = path.read_text(encoding="utf-8")
295
+ redo_backup, redo_hash = _write_redo_backup(cwd, current)
296
+ redo_action.update({
297
+ "redo_backup": redo_backup,
298
+ "redo_hash": redo_hash,
299
+ })
300
+ path.unlink()
301
+ message = f"Deleted new file: {path.name}"
302
+ elif action_type == "rename":
303
+ source = resolve_project_path(cwd, action["source"])
304
+ destination = resolve_project_path(cwd, action["destination"])
305
+ if not destination.exists():
306
+ return f"Cannot undo rename: path missing: {destination.name}", None
307
+ if source.exists():
308
+ return f"Cannot undo rename: destination exists: {source.name}", None
309
+ if destination.is_file() and _conflict(
310
+ destination,
311
+ action.get("expected_hash"),
312
+ ):
313
+ return (
314
+ f"Cannot undo rename: {destination.name} changed after rename."
315
+ ), None
316
+ source.parent.mkdir(parents=True, exist_ok=True)
317
+ destination.rename(source)
318
+ redo_action["undo_hash"] = _hash_file(source)
319
+ message = f"Restored name: {destination.name} -> {source.name}"
320
+ elif action_type == "remove_folder":
321
+ path = resolve_project_path(cwd, action["path"])
322
+ if path.exists() and not path.is_dir():
323
+ return f"Cannot undo: not a folder: {path.name}", None
324
+ if path.exists():
325
+ try:
326
+ path.rmdir()
327
+ except OSError:
328
+ return f"Cannot undo: folder is not empty: {path.name}", None
329
+ message = f"Deleted new folder: {path.name}"
330
+ else:
331
+ return "Cannot undo: unknown journal action.", None
332
+ except (FileError, OSError, UnicodeError) as error:
333
+ return f"Cannot undo: {error}", None
334
+ return message, redo_action
335
+
336
+
337
+ def undo_action(cwd: str, identifier: str | None = None) -> str:
338
+ """Undo the latest action, or all actions through a selected ID."""
339
+ journal = _journal_path(cwd)
340
+ with file_lock(journal):
341
+ actions = _load_actions_unlocked(cwd)
342
+ if not actions:
343
+ return "Nothing to undo."
344
+ normalized = [_normalize_action(item) for item in actions]
345
+ target_index = len(actions) - 1
346
+ if identifier:
347
+ matches = [
348
+ index
349
+ for index, item in enumerate(normalized)
350
+ if item["id"] == identifier or item["id"].startswith(identifier)
351
+ ]
352
+ if len(matches) != 1:
353
+ return "Cannot undo: action ID not found or ambiguous."
354
+ target_index = matches[0]
355
+ redo_actions = _load_redo_unlocked(cwd)
356
+ messages: list[str] = []
357
+ while len(actions) - 1 >= target_index:
358
+ action = normalized[len(actions) - 1]
359
+ message, redo_action = _undo_one(cwd, action)
360
+ if redo_action is None:
361
+ _save_actions_unlocked(cwd, actions)
362
+ _save_redo_unlocked(cwd, redo_actions)
363
+ return "\n".join(messages + [message])
364
+ actions.pop()
365
+ redo_actions.append(redo_action)
366
+ messages.append(message)
367
+ _save_actions_unlocked(cwd, actions)
368
+ _save_redo_unlocked(cwd, redo_actions)
369
+ return "\n".join(messages)
370
+
371
+
372
+ def undo_last_action(cwd: str) -> str:
373
+ return undo_action(cwd)
374
+
375
+
376
+ def redo_last_action(cwd: str) -> str:
377
+ journal = _journal_path(cwd)
378
+ with file_lock(journal):
379
+ redo_actions = _load_redo_unlocked(cwd)
380
+ if not redo_actions:
381
+ return "Nothing to redo."
382
+ action = redo_actions[-1]
383
+ action_type = action.get("type")
384
+ try:
385
+ if action_type in {"restore_file", "delete_file"}:
386
+ path = resolve_project_path(cwd, action["path"])
387
+ expected = action.get("undo_hash")
388
+ if path.exists() and _conflict(path, expected):
389
+ return f"Cannot redo: {path.name} changed after undo."
390
+ backup = _undo_dir(cwd) / action.get("redo_backup", "")
391
+ if not backup.is_file():
392
+ return "Cannot redo: redo backup is missing."
393
+ content = backup.read_text(encoding="utf-8")
394
+ if hashlib.sha256(content.encode("utf-8")).hexdigest() != action.get(
395
+ "redo_hash"
396
+ ):
397
+ return "Cannot redo: redo backup integrity check failed."
398
+ atomic_write_text(path, content, mode=0o644, lock=False)
399
+ action["expected_hash"] = _hash_file(path)
400
+ message = f"Redone file change: {path.name}"
401
+ elif action_type == "rename":
402
+ source = resolve_project_path(cwd, action["source"])
403
+ destination = resolve_project_path(cwd, action["destination"])
404
+ if not source.exists() or destination.exists():
405
+ return "Cannot redo rename: source missing or destination exists."
406
+ if source.is_file() and _conflict(source, action.get("undo_hash")):
407
+ return f"Cannot redo rename: {source.name} changed after undo."
408
+ destination.parent.mkdir(parents=True, exist_ok=True)
409
+ source.rename(destination)
410
+ action["expected_hash"] = _hash_file(destination)
411
+ message = f"Redone rename: {source.name} -> {destination.name}"
412
+ elif action_type == "remove_folder":
413
+ path = resolve_project_path(cwd, action["path"])
414
+ if path.exists():
415
+ return f"Cannot redo: path already exists: {path.name}"
416
+ path.mkdir(parents=True)
417
+ message = f"Redone folder creation: {path.name}"
418
+ else:
419
+ return "Cannot redo: unknown journal action."
420
+ except (FileError, OSError, UnicodeError) as error:
421
+ return f"Cannot redo: {error}"
422
+ redo_actions.pop()
423
+ _delete_backup(cwd, {"redo_backup": action.get("redo_backup")})
424
+ action.pop("redo_backup", None)
425
+ action.pop("redo_hash", None)
426
+ action.pop("undo_hash", None)
427
+ actions = _load_actions_unlocked(cwd)
428
+ actions.append(action)
429
+ _save_actions_unlocked(cwd, _prune(cwd, actions))
430
+ _save_redo_unlocked(cwd, redo_actions)
431
+ return message
432
+
433
+
434
+ def clear_actions(cwd: str) -> int:
435
+ journal = _journal_path(cwd)
436
+ with file_lock(journal):
437
+ actions = _load_actions_unlocked(cwd)
438
+ redo_actions = _load_redo_unlocked(cwd)
439
+ for action in actions + redo_actions:
440
+ _delete_backup(cwd, action)
441
+ _save_actions_unlocked(cwd, [])
442
+ _save_redo_unlocked(cwd, [])
443
+ return len(actions) + len(redo_actions)
zai/core/watch.py ADDED
@@ -0,0 +1,80 @@
1
+ import os
2
+ import time
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+
6
+ console = Console()
7
+
8
+ _observer = None
9
+ _watching = False
10
+
11
+ SKIP_NAMES = {'.git', '__pycache__', 'node_modules', '.venv', 'dist', 'build', '.mypy_cache'}
12
+ SKIP_EXT = {'.pyc', '.pyo', '.pyd', '.swp', '.tmp', '.log'}
13
+
14
+
15
+ def _should_skip(path: str) -> bool:
16
+ p = Path(path)
17
+ if p.suffix in SKIP_EXT:
18
+ return True
19
+ for part in p.parts:
20
+ if part in SKIP_NAMES:
21
+ return True
22
+ return False
23
+
24
+
25
+ def start_watch(cwd: str, callback) -> bool:
26
+ """
27
+ Start watching `cwd` for file changes.
28
+ `callback(filename)` is called when a relevant file is modified.
29
+ Returns True if started, False if watchdog not installed.
30
+ """
31
+ global _observer, _watching
32
+
33
+ try:
34
+ from watchdog.observers import Observer
35
+ from watchdog.events import FileSystemEventHandler
36
+ except ImportError:
37
+ console.print("[yellow]Install watchdog to use watch mode: pip install watchdog[/yellow]")
38
+ return False
39
+
40
+ class Handler(FileSystemEventHandler):
41
+ def __init__(self):
42
+ self._cooldown: dict = {}
43
+
44
+ def on_modified(self, event):
45
+ if event.is_directory:
46
+ return
47
+ src = event.src_path
48
+ if _should_skip(src):
49
+ return
50
+ now = time.time()
51
+ if now - self._cooldown.get(src, 0) < 3:
52
+ return
53
+ self._cooldown[src] = now
54
+ fname = os.path.basename(src)
55
+ console.print(f"\n[dim]~ {fname} changed[/dim]")
56
+ callback(fname)
57
+
58
+ if _observer:
59
+ _observer.stop()
60
+ _observer.join()
61
+
62
+ _observer = Observer()
63
+ _observer.schedule(Handler(), cwd, recursive=True)
64
+ _observer.start()
65
+ _watching = True
66
+ return True
67
+
68
+
69
+ def stop_watch():
70
+ """Stop the file watcher."""
71
+ global _observer, _watching
72
+ if _observer:
73
+ _observer.stop()
74
+ _observer.join()
75
+ _observer = None
76
+ _watching = False
77
+
78
+
79
+ def is_watching() -> bool:
80
+ return _watching