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/workflows.py ADDED
@@ -0,0 +1,137 @@
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
+ from rich.prompt import Confirm
8
+
9
+ from ..core.fallback import chat_with_fallback
10
+ from ..plugins import loader as plugin_loader
11
+ from ..skills.registry import SKILLS, get_skill_prompt
12
+ from ..tools.files import read_file
13
+ from ..tools.git import (
14
+ commit,
15
+ get_diff,
16
+ get_log,
17
+ get_staged_diff,
18
+ get_status,
19
+ )
20
+
21
+ console = Console()
22
+
23
+
24
+ def register_workflow_commands(app: typer.Typer, context, system_prompt: str) -> None:
25
+ """Register AI-assisted skill and Git workflows."""
26
+
27
+ @app.command()
28
+ def skill(
29
+ name: str = typer.Argument(
30
+ ...,
31
+ help="Skill: review, test, commit, docs, fix, explain, refactor, summarize",
32
+ ),
33
+ path: Optional[str] = typer.Argument(
34
+ None,
35
+ help="File to apply skill to",
36
+ ),
37
+ model: Optional[str] = typer.Option(None, "--model", "-m"),
38
+ ):
39
+ """Run a built-in skill: /review, /test, /commit, /docs, /fix, /explain..."""
40
+ plugin_loader.load_all()
41
+ plugin_skills = plugin_loader.get_all_skills()
42
+ all_skills = {**SKILLS, **plugin_skills}
43
+ if name not in all_skills:
44
+ console.print(f"[red]Unknown skill: {name}[/red]")
45
+ console.print(f"Available: {', '.join(all_skills.keys())}")
46
+ return
47
+
48
+ if name == "commit":
49
+ diff = get_staged_diff() or get_diff()
50
+ prompt = get_skill_prompt("commit", text=diff)
51
+ elif name in plugin_skills:
52
+ content = read_file(path) if path else ""
53
+ prompt = plugin_skills[name]["prompt"](content)
54
+ elif path:
55
+ prompt = get_skill_prompt(name, file_path=path)
56
+ else:
57
+ console.print(f"[red]Provide a file: zai skill {name} <file>[/red]")
58
+ return
59
+
60
+ context.add("user", prompt)
61
+ with console.status(f"[cyan]Running /{name}...[/cyan]"):
62
+ response, _ = chat_with_fallback(
63
+ context.get_messages(),
64
+ system=system_prompt,
65
+ preferred=model,
66
+ )
67
+ context.add("assistant", response.content)
68
+ rprint(Panel(
69
+ response.content,
70
+ title=f"[cyan]/{name}[/cyan]",
71
+ border_style="cyan",
72
+ ))
73
+
74
+ @app.command()
75
+ def git(
76
+ action: str = typer.Argument(
77
+ "status",
78
+ help="status, log, diff, commit, review",
79
+ ),
80
+ model: Optional[str] = typer.Option(None, "--model", "-m"),
81
+ ):
82
+ """Git operations with AI assistance."""
83
+ if action == "status":
84
+ console.print(Panel(
85
+ get_status() or "Clean.",
86
+ title="[cyan]Git Status[/cyan]",
87
+ ))
88
+ elif action == "log":
89
+ console.print(Panel(get_log(), title="[cyan]Git Log[/cyan]"))
90
+ elif action == "diff":
91
+ console.print(Panel(
92
+ get_diff() or "No changes.",
93
+ title="[cyan]Git Diff[/cyan]",
94
+ ))
95
+ elif action == "commit":
96
+ diff = get_staged_diff() or get_diff()
97
+ if not diff:
98
+ console.print("[yellow]No changes to commit.[/yellow]")
99
+ return
100
+ prompt = get_skill_prompt("commit", text=diff)
101
+ context.add("user", prompt)
102
+ with console.status("[cyan]Generating commit message...[/cyan]"):
103
+ response, _ = chat_with_fallback(
104
+ context.get_messages(),
105
+ system=system_prompt,
106
+ preferred=model,
107
+ )
108
+ message = response.content.strip().strip("`").strip()
109
+ console.print(Panel(
110
+ message,
111
+ title="[cyan]Suggested Commit Message[/cyan]",
112
+ ))
113
+ if Confirm.ask("Use this message?"):
114
+ console.print(f"[green]{commit(message)}[/green]")
115
+ elif action == "review":
116
+ diff = get_diff()
117
+ if not diff:
118
+ console.print("[yellow]No changes to review.[/yellow]")
119
+ return
120
+ prompt = get_skill_prompt("review", text=diff)
121
+ context.add("user", prompt)
122
+ with console.status("[cyan]Reviewing changes...[/cyan]"):
123
+ response, _ = chat_with_fallback(
124
+ context.get_messages(),
125
+ system=system_prompt,
126
+ preferred=model,
127
+ )
128
+ context.add("assistant", response.content)
129
+ rprint(Panel(
130
+ response.content,
131
+ title="[cyan]Code Review[/cyan]",
132
+ border_style="cyan",
133
+ ))
134
+ else:
135
+ console.print(
136
+ "[red]Usage: zai git status | log | diff | commit | review[/red]"
137
+ )
zai/commands/commit.md ADDED
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: Create a git commit with auto-generated message
3
+ allowed-tools: run_command
4
+ ---
5
+
6
+ ## Context
7
+
8
+ - Git status: !`git status`
9
+ - Staged changes: !`git diff --cached`
10
+ - All changes: !`git diff HEAD`
11
+ - Recent commits: !`git log --oneline -5`
12
+ - Current branch: !`git branch --show-current`
13
+
14
+ ## Task
15
+
16
+ Based on the changes above, create a descriptive git commit message and commit the changes.
17
+
18
+ 1. Stage all relevant changes with:
19
+ <tool_call>{"name":"run_command","arguments":{"command":"git add -A"}}</tool_call>
20
+ 2. Create a commit with a clear, concise message describing WHAT changed and WHY
21
+ 3. Use conventional commit format: `type(scope): description` (e.g. feat, fix, refactor, docs)
22
+ 4. Confirm what was committed
23
+
24
+ $ARGUMENTS
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: Explain what a file or function does
3
+ allowed-tools: read_file
4
+ ---
5
+
6
+ ## Task
7
+
8
+ $ARGUMENTS
9
+
10
+ Read the file and explain clearly:
11
+ 1. What this code does (plain English)
12
+ 2. How it works step by step
13
+ 3. Key functions/classes and their purpose
14
+ 4. Any important patterns or design decisions
15
+
16
+ Use this tool call to read it first:
17
+ <tool_call>{"name":"read_file","arguments":{"path":"$ARGUMENTS"}}</tool_call>
@@ -0,0 +1,34 @@
1
+ ---
2
+ description: Build a new feature with guided development workflow
3
+ argument-hint: describe the feature you want to build
4
+ ---
5
+
6
+ # Feature Development
7
+
8
+ ## Phase 1: Understand
9
+
10
+ Feature request: $ARGUMENTS
11
+
12
+ First, list files in current folder:
13
+ <tool_call>{"name":"list_files","arguments":{"path":"."}}</tool_call>
14
+
15
+ ## Phase 2: Plan
16
+
17
+ Based on existing code, create a plan:
18
+ 1. What files need to be created/modified?
19
+ 2. What is the simplest implementation?
20
+ 3. Any dependencies needed?
21
+
22
+ ## Phase 3: Implement
23
+
24
+ Build the feature step by step:
25
+ - Create new files with structured `write_file` tool calls
26
+ - Read existing files with `read_file` before modifying
27
+ - Run commands with `run_command` to test
28
+
29
+ ## Phase 4: Confirm
30
+
31
+ After implementation, confirm:
32
+ - What was created/changed
33
+ - How to test it
34
+ - Next steps
zai/commands/fix.md ADDED
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: Find and fix errors or bugs in a file
3
+ allowed-tools: read_file, write_file
4
+ ---
5
+
6
+ ## Task
7
+
8
+ $ARGUMENTS
9
+
10
+ 1. Use this tool call to read the file:
11
+ <tool_call>{"name":"read_file","arguments":{"path":"$ARGUMENTS"}}</tool_call>
12
+ 2. Identify all bugs, errors, and issues
13
+ 3. Fix them using `edit_file`, or `write_file` when replacing the complete file
14
+ 4. Explain what was fixed and why
zai/commands/review.md ADDED
@@ -0,0 +1,22 @@
1
+ ---
2
+ description: Review code for bugs, security issues, and improvements
3
+ ---
4
+
5
+ ## Context
6
+
7
+ - Current changes: !`git diff HEAD`
8
+ - Files changed: !`git diff --name-only HEAD`
9
+
10
+ ## Task
11
+
12
+ Review the code changes above and provide:
13
+
14
+ 1. **Bugs** — any logic errors, null pointer issues, off-by-one errors
15
+ 2. **Security** — SQL injection, XSS, insecure operations, hardcoded secrets
16
+ 3. **Performance** — slow loops, unnecessary DB calls, memory leaks
17
+ 4. **Code quality** — duplication, naming, complexity, missing error handling
18
+ 5. **Suggestions** — concrete improvements with example code
19
+
20
+ Be specific — mention exact line numbers or function names where possible.
21
+
22
+ $ARGUMENTS
zai/config.py ADDED
@@ -0,0 +1,307 @@
1
+ import os
2
+ from pathlib import Path
3
+ from dotenv import load_dotenv
4
+ from .core.storage import atomic_write_json, atomic_write_text, read_json, file_lock
5
+
6
+ ZAI_DIR = Path.home() / ".zai"
7
+ ENV_FILE = ZAI_DIR / ".env"
8
+ MEMORY_FILE = ZAI_DIR / "memory.json"
9
+ CONFIG_FILE = ZAI_DIR / "config.json"
10
+
11
+ ZAI_DIR.mkdir(exist_ok=True)
12
+
13
+ load_dotenv(ENV_FILE)
14
+
15
+ DEFAULT_CONFIG = {
16
+ "config_version": 3,
17
+ "default_model": "gemini",
18
+ "fallback_order": ["gemini", "groq", "cerebras", "openrouter", "qwen", "claude", "gpt4o", "ollama"],
19
+ "model_overrides": {},
20
+ "models": {},
21
+ "model_settings": {},
22
+ "show_token_count": True,
23
+ "auto_fallback": True,
24
+ }
25
+
26
+ MODELS = {
27
+ "gemini": {
28
+ "name": "Gemini 3.5 Flash",
29
+ "provider": "gemini",
30
+ "model_id": "gemini-3.5-flash",
31
+ "context_window": 1000000,
32
+ "free": True,
33
+ },
34
+ "groq": {
35
+ "name": "GPT-OSS 120B (Groq)",
36
+ "provider": "groq",
37
+ "model_id": "openai/gpt-oss-120b",
38
+ "context_window": 128000,
39
+ "free": True,
40
+ },
41
+ "cerebras": {
42
+ "name": "Llama 3.3 70B (Cerebras)",
43
+ "provider": "cerebras",
44
+ "model_id": "llama-3.3-70b",
45
+ "context_window": 128000,
46
+ "free": True,
47
+ },
48
+ "openrouter": {
49
+ "name": "Llama 3.3 70B (OpenRouter)",
50
+ "provider": "openrouter",
51
+ "model_id": "meta-llama/llama-3.3-70b-instruct:free",
52
+ "context_window": 128000,
53
+ "free": True,
54
+ },
55
+ "qwen": {
56
+ "name": "Qwen Turbo",
57
+ "provider": "qwen",
58
+ "model_id": "qwen-turbo",
59
+ "context_window": 1000000,
60
+ "free": False,
61
+ },
62
+ "claude": {
63
+ "name": "Claude Sonnet",
64
+ "provider": "anthropic",
65
+ "model_id": "claude-sonnet-4-6",
66
+ "context_window": 200000,
67
+ "free": False,
68
+ },
69
+ "gpt4o": {
70
+ "name": "GPT-4o Mini",
71
+ "provider": "openai",
72
+ "model_id": "gpt-4o-mini",
73
+ "context_window": 128000,
74
+ "free": False,
75
+ },
76
+ "ollama": {
77
+ "name": "Ollama (local)",
78
+ "provider": "ollama",
79
+ "model_id": "llama3.2",
80
+ "context_window": 128000,
81
+ "free": True,
82
+ },
83
+ }
84
+
85
+ MODEL_PROVIDERS = {
86
+ data["provider"] for data in MODELS.values()
87
+ }
88
+
89
+
90
+ def _valid_custom_model(name: str, data: object) -> bool:
91
+ return (
92
+ isinstance(name, str)
93
+ and bool(name.strip())
94
+ and isinstance(data, dict)
95
+ and data.get("provider") in MODEL_PROVIDERS
96
+ and isinstance(data.get("model_id"), str)
97
+ and bool(data["model_id"].strip())
98
+ and isinstance(data.get("context_window"), int)
99
+ and data["context_window"] >= 1024
100
+ and isinstance(data.get("timeout", 60), (int, float))
101
+ and 1 <= data.get("timeout", 60) <= 600
102
+ and isinstance(data.get("retries", 2), int)
103
+ and 0 <= data.get("retries", 2) <= 10
104
+ )
105
+
106
+
107
+ def get_models(config: dict | None = None) -> dict[str, dict]:
108
+ """Return built-in and user-defined model aliases."""
109
+ if config is None:
110
+ config = load_config()
111
+ models = {name: dict(data) for name, data in MODELS.items()}
112
+ for name, data in config.get("models", {}).items():
113
+ if _valid_custom_model(name, data):
114
+ models[name] = {
115
+ "name": data.get("name") or name,
116
+ "provider": data["provider"],
117
+ "model_id": data["model_id"].strip(),
118
+ "context_window": data["context_window"],
119
+ "free": bool(data.get("free", False)),
120
+ "timeout": data.get("timeout", 60),
121
+ "retries": data.get("retries", 2),
122
+ }
123
+ return models
124
+
125
+
126
+ def validate_config(config: dict) -> list[str]:
127
+ """Return human-readable configuration problems without changing the input."""
128
+ issues = []
129
+ available_models = get_models(config)
130
+ default_model = config.get("default_model")
131
+ if default_model not in available_models:
132
+ issues.append(f"Unknown default model: {default_model!r}")
133
+
134
+ fallback_order = config.get("fallback_order")
135
+ if not isinstance(fallback_order, list):
136
+ issues.append("fallback_order must be a list")
137
+ else:
138
+ unknown = [name for name in fallback_order if name not in available_models]
139
+ if unknown:
140
+ issues.append(f"Unknown fallback models: {', '.join(map(str, unknown))}")
141
+
142
+ overrides = config.get("model_overrides", {})
143
+ if not isinstance(overrides, dict):
144
+ issues.append("model_overrides must be an object")
145
+ else:
146
+ for name, model_id in overrides.items():
147
+ if name not in available_models:
148
+ issues.append(f"Unknown model override: {name}")
149
+ elif not isinstance(model_id, str) or not model_id.strip():
150
+ issues.append(f"Invalid model ID override for {name}")
151
+ custom_models = config.get("models", {})
152
+ if not isinstance(custom_models, dict):
153
+ issues.append("models must be an object")
154
+ else:
155
+ for name, data in custom_models.items():
156
+ if name in MODELS:
157
+ issues.append(f"Custom model cannot replace built-in model: {name}")
158
+ elif not _valid_custom_model(name, data):
159
+ issues.append(f"Invalid custom model: {name}")
160
+ model_settings = config.get("model_settings", {})
161
+ if not isinstance(model_settings, dict):
162
+ issues.append("model_settings must be an object")
163
+ else:
164
+ for name, settings in model_settings.items():
165
+ if name not in available_models or not isinstance(settings, dict):
166
+ issues.append(f"Invalid model settings: {name}")
167
+ continue
168
+ timeout = settings.get("timeout", 60)
169
+ retries = settings.get("retries", 2)
170
+ if (
171
+ not isinstance(timeout, (int, float))
172
+ or not 1 <= timeout <= 600
173
+ or not isinstance(retries, int)
174
+ or not 0 <= retries <= 10
175
+ ):
176
+ issues.append(f"Invalid model settings: {name}")
177
+ return issues
178
+
179
+
180
+ def _normalize_config(config: dict) -> dict:
181
+ normalized = {**DEFAULT_CONFIG, **config}
182
+ custom_models = normalized.get("models")
183
+ if not isinstance(custom_models, dict):
184
+ custom_models = {}
185
+ normalized["models"] = {
186
+ name.strip(): {
187
+ "name": data.get("name") or name.strip(),
188
+ "provider": data["provider"],
189
+ "model_id": data["model_id"].strip(),
190
+ "context_window": data["context_window"],
191
+ "free": bool(data.get("free", False)),
192
+ "timeout": data.get("timeout", 60),
193
+ "retries": data.get("retries", 2),
194
+ }
195
+ for name, data in custom_models.items()
196
+ if name not in MODELS and _valid_custom_model(name, data)
197
+ }
198
+ available_models = get_models(normalized)
199
+ model_settings = normalized.get("model_settings")
200
+ if not isinstance(model_settings, dict):
201
+ model_settings = {}
202
+ normalized["model_settings"] = {
203
+ name: {
204
+ "timeout": settings.get("timeout", 60),
205
+ "retries": settings.get("retries", 2),
206
+ }
207
+ for name, settings in model_settings.items()
208
+ if (
209
+ name in available_models
210
+ and isinstance(settings, dict)
211
+ and isinstance(settings.get("timeout", 60), (int, float))
212
+ and 1 <= settings.get("timeout", 60) <= 600
213
+ and isinstance(settings.get("retries", 2), int)
214
+ and 0 <= settings.get("retries", 2) <= 10
215
+ )
216
+ }
217
+ if normalized["default_model"] not in available_models:
218
+ normalized["default_model"] = DEFAULT_CONFIG["default_model"]
219
+
220
+ fallback = normalized.get("fallback_order")
221
+ if not isinstance(fallback, list):
222
+ fallback = DEFAULT_CONFIG["fallback_order"]
223
+ normalized["fallback_order"] = [
224
+ name for name in fallback if name in available_models
225
+ ]
226
+
227
+ overrides = normalized.get("model_overrides")
228
+ if not isinstance(overrides, dict):
229
+ overrides = {}
230
+ normalized["model_overrides"] = {
231
+ name: model_id.strip()
232
+ for name, model_id in overrides.items()
233
+ if name in available_models and isinstance(model_id, str) and model_id.strip()
234
+ }
235
+ normalized["config_version"] = DEFAULT_CONFIG["config_version"]
236
+ return normalized
237
+
238
+
239
+ def load_config() -> dict:
240
+ saved = read_json(CONFIG_FILE, {}, expected_type=dict)
241
+ return _normalize_config(saved)
242
+
243
+
244
+ def save_config(config: dict):
245
+ atomic_write_json(CONFIG_FILE, _normalize_config(config))
246
+
247
+
248
+ def get_model_config(name: str, config: dict | None = None) -> dict:
249
+ """Return effective model metadata, including user/environment overrides."""
250
+ config = config or load_config()
251
+ models = get_models(config)
252
+ if name not in models:
253
+ raise KeyError(f"Unknown model: {name}")
254
+ effective = dict(models[name])
255
+ effective.setdefault("timeout", 60)
256
+ effective.setdefault("retries", 2)
257
+ effective.update(config.get("model_settings", {}).get(name, {}))
258
+ configured = config.get("model_overrides", {}).get(name)
259
+ environment = os.getenv(f"ZAI_{name.upper()}_MODEL")
260
+ effective["model_id"] = environment or configured or effective["model_id"]
261
+ return effective
262
+
263
+
264
+ def get_api_key(provider: str) -> str | None:
265
+ key_map = {
266
+ "gemini": "GEMINI_API_KEY",
267
+ "groq": "GROQ_API_KEY",
268
+ "cerebras": "CEREBRAS_API_KEY",
269
+ "openrouter": "OPENROUTER_API_KEY",
270
+ "qwen": "QWEN_API_KEY",
271
+ "anthropic": "ANTHROPIC_API_KEY",
272
+ "openai": "OPENAI_API_KEY",
273
+ }
274
+ return os.getenv(key_map.get(provider, ""))
275
+
276
+
277
+ def save_api_key(provider: str, key: str):
278
+ key_map = {
279
+ "gemini": "GEMINI_API_KEY",
280
+ "groq": "GROQ_API_KEY",
281
+ "cerebras": "CEREBRAS_API_KEY",
282
+ "openrouter": "OPENROUTER_API_KEY",
283
+ "qwen": "QWEN_API_KEY",
284
+ "anthropic": "ANTHROPIC_API_KEY",
285
+ "openai": "OPENAI_API_KEY",
286
+ }
287
+ env_key = key_map.get(provider)
288
+ if not env_key:
289
+ return
290
+ with file_lock(ENV_FILE):
291
+ lines = []
292
+ if ENV_FILE.exists():
293
+ lines = ENV_FILE.read_text(encoding="utf-8").splitlines()
294
+ updated = False
295
+ for i, line in enumerate(lines):
296
+ if line.startswith(f"{env_key}="):
297
+ lines[i] = f"{env_key}={key}"
298
+ updated = True
299
+ break
300
+ if not updated:
301
+ lines.append(f"{env_key}={key}")
302
+ atomic_write_text(
303
+ ENV_FILE,
304
+ "\n".join(lines) + "\n",
305
+ lock=False,
306
+ )
307
+ os.environ[env_key] = key
zai/core/__init__.py ADDED
File without changes