zai-cli 0.2.0__tar.gz → 0.2.2__tar.gz
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_cli-0.2.0 → zai_cli-0.2.2}/CHANGELOG.md +28 -0
- {zai_cli-0.2.0/zai_cli.egg-info → zai_cli-0.2.2}/PKG-INFO +18 -2
- {zai_cli-0.2.0 → zai_cli-0.2.2}/README.md +17 -1
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_auth.py +42 -1
- zai_cli-0.2.2/tests/test_file_mentions.py +30 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_input.py +16 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_interactive.py +26 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_settings_cli.py +20 -0
- zai_cli-0.2.2/zai/__init__.py +1 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/interactive.py +16 -14
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/settings.py +49 -10
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/auth.py +89 -13
- zai_cli-0.2.2/zai/core/file_mentions.py +99 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/main.py +3 -1
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/ui/input.py +14 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2/zai_cli.egg-info}/PKG-INFO +18 -2
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/SOURCES.txt +2 -0
- zai_cli-0.2.0/zai/__init__.py +0 -1
- {zai_cli-0.2.0 → zai_cli-0.2.2}/LICENSE +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/MANIFEST.in +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/pyproject.toml +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/scripts/release_preflight.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/setup.cfg +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_agent.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_browser.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_code_runner.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_config_main.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_core.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_errors.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_hooks_skills_session.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_integrations_cli.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_mcp.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_plugins.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_process.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_project_memory.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_project_rules.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_providers.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_release_preflight.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_search.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_security.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_storage.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_streaming.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_tokens.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_tool_schema.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_tools.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_undo.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_utilities.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_vision.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_watch.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_workflows.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/__main__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/common.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/integrations.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/setup_wizard.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/status.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/utilities.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/workflows.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/commit.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/explain.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/feature.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/fix.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/review.md +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/config.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/agent.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/cancellation.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/commands.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/compaction.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/context.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/errors.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/fallback.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/hooks.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/memory.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/process.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/project_memory.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/project_rules.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/repomap.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/runtime.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/security.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/session.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/storage.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/streaming.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/tokens.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/tool_schema.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/undo.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/watch.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/mcp/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/mcp/client.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/mcp/manager.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/plugins/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/plugins/base.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/plugins/loader.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/anthropic.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/base.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/cerebras.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/gemini.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/groq.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/ollama.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/openai.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/openrouter.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/qwen.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/skills/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/skills/registry.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/browser.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/code_runner.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/files.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/git.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/search.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/vision.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/ui/__init__.py +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/dependency_links.txt +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/entry_points.txt +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/requires.txt +0 -0
- {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/top_level.txt +0 -0
|
@@ -5,6 +5,34 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and the project uses [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [0.2.2] - 2026-07-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added structured live-provider validation results with the resolved model,
|
|
13
|
+
token usage, failure detail, and a recommended next action.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Authentication login validation now distinguishes missing or invalid
|
|
18
|
+
credentials, quota limits, model configuration errors, network failures, and
|
|
19
|
+
unexpected provider failures.
|
|
20
|
+
- `zai model test` now reports actionable recovery commands and shows the token
|
|
21
|
+
cost of a successful validation request when providers return usage data.
|
|
22
|
+
|
|
23
|
+
## [0.2.1] - 2026-06-30
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Added `@file` completion in normal interactive prompts.
|
|
28
|
+
- Added safe `@file` mention expansion so prompts like `explain @app.py`
|
|
29
|
+
include bounded referenced file content.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Mention expansion skips missing, hidden, oversized, binary, and
|
|
34
|
+
sensitive-looking files without crashing the prompt.
|
|
35
|
+
|
|
8
36
|
## [0.2.0] - 2026-06-30
|
|
9
37
|
|
|
10
38
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zai-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Your personal AI CLI — free, fast, and smart
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/HumaizaNaz/zai_cli
|
|
@@ -198,6 +198,10 @@ zai auth login openai
|
|
|
198
198
|
zai auth login anthropic
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
+
When prompted, authentication login can send one tiny live request to validate
|
|
202
|
+
the key. The result identifies the resolved model and gives a recovery command
|
|
203
|
+
for invalid credentials, quota limits, model errors, or network failures.
|
|
204
|
+
|
|
201
205
|
Remove a saved API key from `zai`:
|
|
202
206
|
|
|
203
207
|
```bash
|
|
@@ -426,7 +430,7 @@ zai model add <name> Add a custom model alias
|
|
|
426
430
|
zai model remove <name> Remove a custom model alias
|
|
427
431
|
zai model configure <name> Set timeout and retry policy
|
|
428
432
|
zai model info <name> Show effective model configuration
|
|
429
|
-
zai model test <name> Verify credentials and
|
|
433
|
+
zai model test <name> Verify credentials/model and show recovery guidance
|
|
430
434
|
zai auth status Show API-key and external CLI auth status
|
|
431
435
|
zai auth login <provider> Connect a provider API key
|
|
432
436
|
zai auth logout <provider> Remove a provider API key from zai
|
|
@@ -538,6 +542,18 @@ Interactive input also provides:
|
|
|
538
542
|
- `Ctrl+C` to cancel the current input or operation without ending the session;
|
|
539
543
|
- a basic Rich prompt fallback in redirected or unsupported terminals.
|
|
540
544
|
|
|
545
|
+
You can reference files in natural prompts with `@file`:
|
|
546
|
+
|
|
547
|
+
```text
|
|
548
|
+
explain @app.py
|
|
549
|
+
fix @src/main.py
|
|
550
|
+
compare @README.md and @CHANGELOG.md
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
`zai` loads bounded text from valid referenced files and appends it to the
|
|
554
|
+
prompt context. Hidden files, sensitive-looking paths, binary files, and very
|
|
555
|
+
large files are skipped with a note instead of crashing the session.
|
|
556
|
+
|
|
541
557
|
## How much of my project does zai see?
|
|
542
558
|
|
|
543
559
|
`zai` starts from the folder where you run it. It does not load every file in a
|
|
@@ -141,6 +141,10 @@ zai auth login openai
|
|
|
141
141
|
zai auth login anthropic
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
When prompted, authentication login can send one tiny live request to validate
|
|
145
|
+
the key. The result identifies the resolved model and gives a recovery command
|
|
146
|
+
for invalid credentials, quota limits, model errors, or network failures.
|
|
147
|
+
|
|
144
148
|
Remove a saved API key from `zai`:
|
|
145
149
|
|
|
146
150
|
```bash
|
|
@@ -369,7 +373,7 @@ zai model add <name> Add a custom model alias
|
|
|
369
373
|
zai model remove <name> Remove a custom model alias
|
|
370
374
|
zai model configure <name> Set timeout and retry policy
|
|
371
375
|
zai model info <name> Show effective model configuration
|
|
372
|
-
zai model test <name> Verify credentials and
|
|
376
|
+
zai model test <name> Verify credentials/model and show recovery guidance
|
|
373
377
|
zai auth status Show API-key and external CLI auth status
|
|
374
378
|
zai auth login <provider> Connect a provider API key
|
|
375
379
|
zai auth logout <provider> Remove a provider API key from zai
|
|
@@ -481,6 +485,18 @@ Interactive input also provides:
|
|
|
481
485
|
- `Ctrl+C` to cancel the current input or operation without ending the session;
|
|
482
486
|
- a basic Rich prompt fallback in redirected or unsupported terminals.
|
|
483
487
|
|
|
488
|
+
You can reference files in natural prompts with `@file`:
|
|
489
|
+
|
|
490
|
+
```text
|
|
491
|
+
explain @app.py
|
|
492
|
+
fix @src/main.py
|
|
493
|
+
compare @README.md and @CHANGELOG.md
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`zai` loads bounded text from valid referenced files and appends it to the
|
|
497
|
+
prompt context. Hidden files, sensitive-looking paths, binary files, and very
|
|
498
|
+
large files are skipped with a note instead of crashing the session.
|
|
499
|
+
|
|
484
500
|
## How much of my project does zai see?
|
|
485
501
|
|
|
486
502
|
`zai` starts from the folder where you run it. It does not load every file in a
|
|
@@ -14,6 +14,7 @@ from zai.core.auth import (
|
|
|
14
14
|
mask_secret,
|
|
15
15
|
save_provider_key,
|
|
16
16
|
validate_provider,
|
|
17
|
+
validate_provider_details,
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
|
|
@@ -66,18 +67,58 @@ def test_external_login_command_for_codex_and_claude():
|
|
|
66
67
|
def test_validate_provider_normalizes_results():
|
|
67
68
|
provider = MagicMock()
|
|
68
69
|
provider.is_available.return_value = True
|
|
69
|
-
provider.chat.return_value = MagicMock()
|
|
70
|
+
provider.chat.return_value = MagicMock(model="provider/model", tokens_used=7)
|
|
70
71
|
with patch("zai.core.auth.get_provider", return_value=provider):
|
|
71
72
|
assert validate_provider("groq") == "OK"
|
|
73
|
+
details = validate_provider_details("groq")
|
|
74
|
+
assert details.status == "OK"
|
|
75
|
+
assert details.model == "provider/model"
|
|
76
|
+
assert details.tokens_used == 7
|
|
77
|
+
assert "zai model set groq" in details.next_action
|
|
72
78
|
|
|
73
79
|
provider.chat.side_effect = RuntimeError("unauthorized api key")
|
|
74
80
|
with patch("zai.core.auth.get_provider", return_value=provider):
|
|
75
81
|
assert validate_provider("groq") == "invalid"
|
|
82
|
+
details = validate_provider_details("groq")
|
|
83
|
+
assert details.status == "invalid"
|
|
84
|
+
assert "zai auth login groq" in details.next_action
|
|
76
85
|
|
|
77
86
|
provider.chat.side_effect = RuntimeError("rate limit")
|
|
78
87
|
with patch("zai.core.auth.get_provider", return_value=provider):
|
|
79
88
|
assert validate_provider("groq") == "quota/rate limit"
|
|
80
89
|
|
|
90
|
+
provider.chat.side_effect = RuntimeError("unknown model does not exist")
|
|
91
|
+
with patch("zai.core.auth.get_provider", return_value=provider):
|
|
92
|
+
details = validate_provider_details("groq")
|
|
93
|
+
assert details.status == "model error"
|
|
94
|
+
assert "zai model info groq" in details.next_action
|
|
95
|
+
|
|
96
|
+
provider.chat.side_effect = RuntimeError("unexpected provider failure")
|
|
97
|
+
with patch("zai.core.auth.get_provider", return_value=provider):
|
|
98
|
+
details = validate_provider_details("groq")
|
|
99
|
+
assert details.status == "provider error"
|
|
100
|
+
assert "zai auth doctor" in details.next_action
|
|
101
|
+
|
|
102
|
+
provider.chat.side_effect = RuntimeError("api_key=super-secret-provider-token")
|
|
103
|
+
with patch("zai.core.auth.get_provider", return_value=provider):
|
|
104
|
+
details = validate_provider_details("groq")
|
|
105
|
+
assert "super-secret-provider-token" not in details.detail
|
|
106
|
+
assert "[REDACTED_SECRET]" in details.detail
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_validate_provider_reports_unknown_and_unavailable():
|
|
110
|
+
unknown = validate_provider_details("unknown")
|
|
111
|
+
assert unknown.status == "missing"
|
|
112
|
+
assert unknown.detail == "Unknown provider."
|
|
113
|
+
|
|
114
|
+
provider = MagicMock()
|
|
115
|
+
provider.is_available.return_value = False
|
|
116
|
+
with patch("zai.core.auth.get_provider", return_value=provider):
|
|
117
|
+
unavailable = validate_provider_details("groq")
|
|
118
|
+
|
|
119
|
+
assert unavailable.status == "missing"
|
|
120
|
+
assert "zai auth login groq" in unavailable.next_action
|
|
121
|
+
|
|
81
122
|
|
|
82
123
|
def test_top_level_auth_status_does_not_print_secret(monkeypatch):
|
|
83
124
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-live-secret")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from zai.core.file_mentions import expand_file_mentions, extract_file_mentions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_extract_file_mentions_keeps_order_and_uniqueness():
|
|
5
|
+
text = "explain @app.py then compare @README.md and @app.py."
|
|
6
|
+
|
|
7
|
+
assert extract_file_mentions(text) == ["app.py", "README.md"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_expand_file_mentions_loads_bounded_file_context(tmp_path):
|
|
11
|
+
(tmp_path / "app.py").write_text("print('hi')\n", encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
expanded = expand_file_mentions("explain @app.py", tmp_path)
|
|
14
|
+
|
|
15
|
+
assert "explain @app.py" in expanded
|
|
16
|
+
assert "Referenced @file context" in expanded
|
|
17
|
+
assert "--- @app.py ---" in expanded
|
|
18
|
+
assert "print('hi')" in expanded
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_expand_file_mentions_skips_hidden_and_sensitive_files(tmp_path):
|
|
22
|
+
(tmp_path / ".env").write_text("API_KEY=secret\n", encoding="utf-8")
|
|
23
|
+
(tmp_path / "token.txt").write_text("secret\n", encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
expanded = expand_file_mentions("read @.env and @token.txt", tmp_path)
|
|
26
|
+
|
|
27
|
+
assert "API_KEY=secret" not in expanded
|
|
28
|
+
assert "Mention notes" in expanded
|
|
29
|
+
assert "Skipped @.env" in expanded
|
|
30
|
+
assert "Skipped @token.txt" in expanded
|
|
@@ -97,6 +97,22 @@ def test_path_completion(tmp_path):
|
|
|
97
97
|
assert not any(".secret" in candidate[0] for candidate in candidates)
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def test_file_mention_completion_in_natural_prompt(tmp_path):
|
|
101
|
+
(tmp_path / "src").mkdir()
|
|
102
|
+
(tmp_path / "sample.py").write_text("")
|
|
103
|
+
(tmp_path / ".secret").write_text("")
|
|
104
|
+
|
|
105
|
+
candidates = completion_candidates(
|
|
106
|
+
"explain @s",
|
|
107
|
+
str(tmp_path),
|
|
108
|
+
{"fix"},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
assert ("@src/", "@src/", "file mention") in candidates
|
|
112
|
+
assert ("@sample.py", "@sample.py", "file mention") in candidates
|
|
113
|
+
assert not any(".secret" in candidate[0] for candidate in candidates)
|
|
114
|
+
|
|
115
|
+
|
|
100
116
|
def test_prompt_falls_back_when_prompt_toolkit_missing(tmp_path):
|
|
101
117
|
real_import = __import__
|
|
102
118
|
|
|
@@ -305,6 +305,32 @@ def test_interactive_pins_project_rules_before_agent_call(tmp_path, monkeypatch)
|
|
|
305
305
|
)
|
|
306
306
|
|
|
307
307
|
|
|
308
|
+
def test_interactive_expands_file_mentions_before_agent_call(tmp_path, monkeypatch):
|
|
309
|
+
monkeypatch.chdir(tmp_path)
|
|
310
|
+
(tmp_path / "app.py").write_text("print('hello')\n")
|
|
311
|
+
with patch("zai.cli.interactive.has_available_provider", return_value=True):
|
|
312
|
+
with patch("zai.cli.interactive.fire_hook", return_value=True):
|
|
313
|
+
with patch("zai.cli.interactive.plugin_loader.load_all", return_value={}):
|
|
314
|
+
with patch("zai.cli.interactive.plugin_loader.get_errors", return_value={}):
|
|
315
|
+
with patch("zai.cli.interactive._connect_mcp_servers"):
|
|
316
|
+
with patch(
|
|
317
|
+
"zai.cli.interactive.InteractiveInput.prompt",
|
|
318
|
+
side_effect=["explain @app.py", EOFError()],
|
|
319
|
+
):
|
|
320
|
+
with (
|
|
321
|
+
patch(
|
|
322
|
+
"zai.cli.interactive.run_agent",
|
|
323
|
+
return_value="done",
|
|
324
|
+
) as run_agent,
|
|
325
|
+
patch("zai.cli.interactive.Confirm.ask", return_value=False),
|
|
326
|
+
):
|
|
327
|
+
run_interactive("groq")
|
|
328
|
+
|
|
329
|
+
prompt = run_agent.call_args.args[0]
|
|
330
|
+
assert "Referenced @file context" in prompt
|
|
331
|
+
assert "print('hello')" in prompt
|
|
332
|
+
|
|
333
|
+
|
|
308
334
|
def test_interactive_manual_compact_summarizes_history(tmp_path, monkeypatch, capsys):
|
|
309
335
|
monkeypatch.chdir(tmp_path)
|
|
310
336
|
prompts = [
|
|
@@ -159,6 +159,7 @@ def test_context_command_is_honest_about_unloaded_files(tmp_path, monkeypatch):
|
|
|
159
159
|
def test_rules_command_shows_loaded_project_rules(tmp_path, monkeypatch):
|
|
160
160
|
app = settings_app()
|
|
161
161
|
monkeypatch.chdir(tmp_path)
|
|
162
|
+
(tmp_path / ".git").mkdir()
|
|
162
163
|
(tmp_path / ".zai").mkdir()
|
|
163
164
|
(tmp_path / ".zai" / "rules.md").write_text("Never commit secrets.\n")
|
|
164
165
|
|
|
@@ -271,6 +272,25 @@ def test_model_test_passes_and_reports_unavailable():
|
|
|
271
272
|
assert "Model unavailable" in unavailable.output
|
|
272
273
|
|
|
273
274
|
|
|
275
|
+
def test_model_test_reports_actionable_auth_failure():
|
|
276
|
+
from zai.core.errors import AuthenticationError
|
|
277
|
+
|
|
278
|
+
app = settings_app()
|
|
279
|
+
runner = CliRunner()
|
|
280
|
+
provider = MagicMock()
|
|
281
|
+
provider.name = "groq"
|
|
282
|
+
provider.is_available.return_value = True
|
|
283
|
+
provider.chat.side_effect = AuthenticationError("groq", "bad key")
|
|
284
|
+
|
|
285
|
+
with patch("zai.core.fallback.get_provider", return_value=provider):
|
|
286
|
+
result = runner.invoke(app, ["model", "test", "groq"])
|
|
287
|
+
|
|
288
|
+
assert result.exit_code == 0
|
|
289
|
+
assert "invalid credentials" in result.output
|
|
290
|
+
assert "zai auth login groq" in result.output
|
|
291
|
+
assert "bad key" in result.output
|
|
292
|
+
|
|
293
|
+
|
|
274
294
|
def test_model_configure_updates_builtin_runtime_settings():
|
|
275
295
|
app = settings_app()
|
|
276
296
|
config = {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -15,13 +15,14 @@ from ..core.auth import (
|
|
|
15
15
|
get_auth_provider,
|
|
16
16
|
run_external_login,
|
|
17
17
|
save_provider_key,
|
|
18
|
-
|
|
18
|
+
validate_provider_details,
|
|
19
19
|
)
|
|
20
20
|
from ..core.agent import plan_agent, run_agent, undo_last
|
|
21
21
|
from ..core.commands import get_command_prompt, list_commands, load_commands
|
|
22
22
|
from ..core.compaction import manual_compact_messages
|
|
23
23
|
from ..core.hooks import fire as fire_hook
|
|
24
24
|
from ..core.fallback import has_available_provider
|
|
25
|
+
from ..core.file_mentions import expand_file_mentions
|
|
25
26
|
from ..core.context import ContextManager
|
|
26
27
|
from ..core.memory import add_project, get_last_session, save_session
|
|
27
28
|
from ..core.project_memory import (
|
|
@@ -282,14 +283,13 @@ def _run_auth_login(provider: str, *, device: bool = False) -> None:
|
|
|
282
283
|
f"Test {info.display_name} now? This sends one tiny request.",
|
|
283
284
|
default=False,
|
|
284
285
|
):
|
|
285
|
-
|
|
286
|
-
style = "green" if status == "OK" else "yellow"
|
|
287
|
-
console.print(f"[{style}]Validation: {status}[/{style}]")
|
|
288
|
-
if
|
|
289
|
-
console.print(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
)
|
|
286
|
+
validation = validate_provider_details(info.key)
|
|
287
|
+
style = "green" if validation.status == "OK" else "yellow"
|
|
288
|
+
console.print(f"[{style}]Validation: {validation.status}[/{style}]")
|
|
289
|
+
if validation.model:
|
|
290
|
+
console.print(f"[dim]Model: {validation.model}[/dim]")
|
|
291
|
+
if validation.next_action:
|
|
292
|
+
console.print(f"[dim]{validation.next_action.replace('zai ', '/')}[/dim]")
|
|
293
293
|
command = external_login_command(info.key, device=device)
|
|
294
294
|
if command:
|
|
295
295
|
command_text = " ".join(command)
|
|
@@ -748,12 +748,13 @@ def run_interactive(model: str = None) -> None:
|
|
|
748
748
|
console.print("[red]Usage: /plan <what you want to build>[/red]")
|
|
749
749
|
else:
|
|
750
750
|
console.print("[dim]Planning...[/dim]")
|
|
751
|
-
|
|
751
|
+
expanded_task = expand_file_mentions(task, cwd)
|
|
752
|
+
plan = plan_agent(expanded_task, history, cwd, preferred)
|
|
752
753
|
if plan and Prompt.ask("[cyan]Proceed?[/cyan]", default="no").lower() in ("yes", "y"):
|
|
753
754
|
console.print("[dim]Executing...[/dim]")
|
|
754
|
-
result = run_agent(
|
|
755
|
+
result = run_agent(expanded_task, history, cwd, preferred)
|
|
755
756
|
if result:
|
|
756
|
-
remember_turn(
|
|
757
|
+
remember_turn(expanded_task, result)
|
|
757
758
|
save_session(task=task[:80], model=preferred)
|
|
758
759
|
maybe_offer_project_memory(task)
|
|
759
760
|
elif stripped == "/test":
|
|
@@ -955,9 +956,10 @@ def run_interactive(model: str = None) -> None:
|
|
|
955
956
|
console.print("[dim]Type /commands to see available commands[/dim]")
|
|
956
957
|
else:
|
|
957
958
|
fire_hook("UserPromptSubmit", {"message": stripped[:200]})
|
|
958
|
-
|
|
959
|
+
expanded = expand_file_mentions(stripped, cwd)
|
|
960
|
+
result = run_agent(expanded, history, cwd, preferred)
|
|
959
961
|
if result:
|
|
960
|
-
remember_turn(
|
|
962
|
+
remember_turn(expanded, result)
|
|
961
963
|
save_session(task=stripped[:80], model=preferred)
|
|
962
964
|
maybe_offer_project_memory(stripped)
|
|
963
965
|
except KeyboardInterrupt:
|
|
@@ -14,8 +14,17 @@ from ..core.auth import (
|
|
|
14
14
|
get_auth_provider,
|
|
15
15
|
save_provider_key,
|
|
16
16
|
run_external_login,
|
|
17
|
-
|
|
17
|
+
validate_provider_details,
|
|
18
18
|
)
|
|
19
|
+
from ..core.errors import (
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
ModelNotFoundError,
|
|
22
|
+
NetworkError,
|
|
23
|
+
NoAPIKeyError,
|
|
24
|
+
QuotaError,
|
|
25
|
+
classify_provider_error,
|
|
26
|
+
)
|
|
27
|
+
from ..core.tokens import redact_secrets
|
|
19
28
|
from ..config import (
|
|
20
29
|
ENV_FILE,
|
|
21
30
|
MEMORY_FILE,
|
|
@@ -171,14 +180,13 @@ def auth(
|
|
|
171
180
|
f"Test {info.display_name} now? This sends one tiny request.",
|
|
172
181
|
default=False,
|
|
173
182
|
):
|
|
174
|
-
|
|
175
|
-
style = "green" if status == "OK" else "yellow"
|
|
176
|
-
console.print(f"[{style}]Validation: {status}[/{style}]")
|
|
177
|
-
if
|
|
178
|
-
console.print(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
)
|
|
183
|
+
validation = validate_provider_details(info.key)
|
|
184
|
+
style = "green" if validation.status == "OK" else "yellow"
|
|
185
|
+
console.print(f"[{style}]Validation: {validation.status}[/{style}]")
|
|
186
|
+
if validation.model:
|
|
187
|
+
console.print(f"[dim]Model: {validation.model}[/dim]")
|
|
188
|
+
if validation.next_action:
|
|
189
|
+
console.print(f"[dim]{validation.next_action}[/dim]")
|
|
182
190
|
command = external_login_command(info.key, device=device)
|
|
183
191
|
if command:
|
|
184
192
|
command_text = " ".join(command)
|
|
@@ -356,13 +364,44 @@ def model(
|
|
|
356
364
|
response = selected.chat([
|
|
357
365
|
Message(role="user", content="Reply with exactly: ok")
|
|
358
366
|
])
|
|
359
|
-
except
|
|
367
|
+
except NoAPIKeyError as error:
|
|
360
368
|
console.print(f"[red]Model test failed:[/red] {error}")
|
|
369
|
+
console.print(f"[dim]Add a key with: zai auth login {selected.name}[/dim]")
|
|
370
|
+
return
|
|
371
|
+
except AuthenticationError as error:
|
|
372
|
+
console.print(f"[red]Model test failed:[/red] invalid credentials for {selected.name}")
|
|
373
|
+
console.print(f"[dim]Update the key with: zai auth login {selected.name}[/dim]")
|
|
374
|
+
console.print(f"[dim]{redact_secrets(error.detail)}[/dim]")
|
|
375
|
+
return
|
|
376
|
+
except QuotaError as error:
|
|
377
|
+
console.print(f"[yellow]Model test hit quota or rate limit:[/yellow] {selected.name}")
|
|
378
|
+
console.print("[dim]Wait, check quota, or switch models with: zai model set <name>[/dim]")
|
|
379
|
+
console.print(f"[dim]{redact_secrets(error.detail)}[/dim]")
|
|
380
|
+
return
|
|
381
|
+
except ModelNotFoundError as error:
|
|
382
|
+
console.print(f"[red]Model test failed:[/red] model not found")
|
|
383
|
+
console.print(f"[dim]Configured model id: {selected.model_id}[/dim]")
|
|
384
|
+
console.print(f"[dim]Change it with: zai model configure {name} or zai model add <name>[/dim]")
|
|
385
|
+
console.print(f"[dim]{redact_secrets(error.detail)}[/dim]")
|
|
386
|
+
return
|
|
387
|
+
except NetworkError as error:
|
|
388
|
+
console.print(f"[red]Model test failed:[/red] network error")
|
|
389
|
+
console.print("[dim]Check internet connection, local service, or provider status.[/dim]")
|
|
390
|
+
console.print(f"[dim]{redact_secrets(str(error))}[/dim]")
|
|
391
|
+
return
|
|
392
|
+
except Exception as error:
|
|
393
|
+
normalized = classify_provider_error(selected.name, error)
|
|
394
|
+
console.print(
|
|
395
|
+
f"[red]Model test failed:[/red] {redact_secrets(str(normalized))}"
|
|
396
|
+
)
|
|
397
|
+
console.print("[dim]Run `zai auth doctor` for provider setup guidance.[/dim]")
|
|
361
398
|
return
|
|
362
399
|
console.print(
|
|
363
400
|
f"[green]Model test passed:[/green] {name} "
|
|
364
401
|
f"([cyan]{response.model}[/cyan])"
|
|
365
402
|
)
|
|
403
|
+
if response.tokens_used:
|
|
404
|
+
console.print(f"[dim]Tiny validation request used {response.tokens_used} tokens.[/dim]")
|
|
366
405
|
else:
|
|
367
406
|
console.print(
|
|
368
407
|
"[red]Usage: zai model list | zai model set <name> | "
|
|
@@ -10,8 +10,18 @@ from typing import Iterable
|
|
|
10
10
|
|
|
11
11
|
from ..config import ENV_FILE, get_api_key, save_api_key
|
|
12
12
|
from ..providers.base import Message
|
|
13
|
+
from .errors import (
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
ModelNotFoundError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
NoAPIKeyError,
|
|
18
|
+
ProviderTimeoutError,
|
|
19
|
+
QuotaError,
|
|
20
|
+
classify_provider_error,
|
|
21
|
+
)
|
|
13
22
|
from .fallback import get_provider
|
|
14
23
|
from .storage import atomic_write_text, file_lock
|
|
24
|
+
from .tokens import redact_secrets
|
|
15
25
|
|
|
16
26
|
|
|
17
27
|
@dataclass(frozen=True)
|
|
@@ -28,6 +38,16 @@ class AuthProvider:
|
|
|
28
38
|
notes: str = ""
|
|
29
39
|
|
|
30
40
|
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ProviderValidation:
|
|
43
|
+
provider: str
|
|
44
|
+
status: str
|
|
45
|
+
model: str = ""
|
|
46
|
+
detail: str = ""
|
|
47
|
+
next_action: str = ""
|
|
48
|
+
tokens_used: int = 0
|
|
49
|
+
|
|
50
|
+
|
|
31
51
|
PROVIDERS: dict[str, AuthProvider] = {
|
|
32
52
|
"gemini": AuthProvider(
|
|
33
53
|
key="gemini",
|
|
@@ -233,22 +253,78 @@ def run_external_login(provider: str, *, device: bool = False) -> subprocess.Com
|
|
|
233
253
|
return subprocess.run(command, text=True)
|
|
234
254
|
|
|
235
255
|
|
|
236
|
-
def
|
|
256
|
+
def _validation_next_action(provider: str, status: str) -> str:
|
|
257
|
+
if status == "OK":
|
|
258
|
+
return f"Use it with: zai model set {MODEL_BY_PROVIDER.get(provider, provider)}"
|
|
259
|
+
if status == "missing":
|
|
260
|
+
info = get_auth_provider(provider)
|
|
261
|
+
if info and info.key_env_var:
|
|
262
|
+
return f"Add a key with: zai auth login {info.key}"
|
|
263
|
+
if provider == "ollama":
|
|
264
|
+
return "Start Ollama locally, then run: zai model test ollama"
|
|
265
|
+
return "Check provider setup."
|
|
266
|
+
if status == "invalid":
|
|
267
|
+
return f"Update the key with: zai auth login {provider}"
|
|
268
|
+
if status == "quota/rate limit":
|
|
269
|
+
return "Wait, check provider quota, or switch models with: zai model set <name>"
|
|
270
|
+
if status == "network error":
|
|
271
|
+
return "Check internet connection, local service, or provider status."
|
|
272
|
+
if status == "model error":
|
|
273
|
+
alias = MODEL_BY_PROVIDER.get(provider, provider)
|
|
274
|
+
return f"Check the model id with: zai model info {alias}"
|
|
275
|
+
return "Run: zai auth doctor"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def validate_provider_details(provider: str) -> ProviderValidation:
|
|
237
279
|
alias = MODEL_BY_PROVIDER.get(provider)
|
|
238
280
|
if not alias:
|
|
239
|
-
return
|
|
281
|
+
return ProviderValidation(
|
|
282
|
+
provider=provider,
|
|
283
|
+
status="missing",
|
|
284
|
+
detail="Unknown provider.",
|
|
285
|
+
next_action=_validation_next_action(provider, "missing"),
|
|
286
|
+
)
|
|
240
287
|
selected = get_provider(alias)
|
|
241
288
|
if selected is None or not selected.is_available():
|
|
242
|
-
return
|
|
289
|
+
return ProviderValidation(
|
|
290
|
+
provider=provider,
|
|
291
|
+
status="missing",
|
|
292
|
+
model=alias,
|
|
293
|
+
detail="Provider is not configured or local service is unavailable.",
|
|
294
|
+
next_action=_validation_next_action(provider, "missing"),
|
|
295
|
+
)
|
|
243
296
|
try:
|
|
244
|
-
selected.chat([Message(role="user", content="Reply with exactly: ok")])
|
|
297
|
+
response = selected.chat([Message(role="user", content="Reply with exactly: ok")])
|
|
245
298
|
except Exception as error:
|
|
246
|
-
|
|
247
|
-
if
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
299
|
+
normalized = classify_provider_error(provider, error)
|
|
300
|
+
if isinstance(normalized, NoAPIKeyError):
|
|
301
|
+
status = "missing"
|
|
302
|
+
elif isinstance(normalized, AuthenticationError):
|
|
303
|
+
status = "invalid"
|
|
304
|
+
elif isinstance(normalized, QuotaError):
|
|
305
|
+
status = "quota/rate limit"
|
|
306
|
+
elif isinstance(normalized, ModelNotFoundError):
|
|
307
|
+
status = "model error"
|
|
308
|
+
elif isinstance(normalized, (NetworkError, ProviderTimeoutError)):
|
|
309
|
+
status = "network error"
|
|
310
|
+
else:
|
|
311
|
+
status = "provider error"
|
|
312
|
+
return ProviderValidation(
|
|
313
|
+
provider=provider,
|
|
314
|
+
status=status,
|
|
315
|
+
model=getattr(selected, "model_id", alias),
|
|
316
|
+
detail=redact_secrets(str(normalized)),
|
|
317
|
+
next_action=_validation_next_action(provider, status),
|
|
318
|
+
)
|
|
319
|
+
return ProviderValidation(
|
|
320
|
+
provider=provider,
|
|
321
|
+
status="OK",
|
|
322
|
+
model=response.model or getattr(selected, "model_id", alias),
|
|
323
|
+
detail="Tiny live request completed.",
|
|
324
|
+
next_action=_validation_next_action(provider, "OK"),
|
|
325
|
+
tokens_used=response.tokens_used,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def validate_provider(provider: str) -> str:
|
|
330
|
+
return validate_provider_details(provider).status
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Safe @file mention expansion for user prompts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .security import resolve_project_path
|
|
8
|
+
|
|
9
|
+
MENTION_RE = re.compile(r"(?<![\w/\\.-])@([^\s]+)")
|
|
10
|
+
MAX_MENTION_FILE_BYTES = 48 * 1024
|
|
11
|
+
MAX_MENTION_CONTENT_CHARS = 16 * 1024
|
|
12
|
+
SENSITIVE_PARTS = {
|
|
13
|
+
".env",
|
|
14
|
+
"secret",
|
|
15
|
+
"secrets",
|
|
16
|
+
"token",
|
|
17
|
+
"tokens",
|
|
18
|
+
"password",
|
|
19
|
+
"passwords",
|
|
20
|
+
"credential",
|
|
21
|
+
"credentials",
|
|
22
|
+
"private_key",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_file_mentions(text: str) -> list[str]:
|
|
27
|
+
"""Return unique @file mentions in first-seen order."""
|
|
28
|
+
result: list[str] = []
|
|
29
|
+
seen: set[str] = set()
|
|
30
|
+
for match in MENTION_RE.finditer(text):
|
|
31
|
+
value = match.group(1).rstrip(".,;:)]}")
|
|
32
|
+
if value and value not in seen:
|
|
33
|
+
seen.add(value)
|
|
34
|
+
result.append(value)
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _looks_sensitive(path_text: str) -> bool:
|
|
39
|
+
parts = [part.lower() for part in Path(path_text.replace("\\", "/")).parts]
|
|
40
|
+
return any(
|
|
41
|
+
part.startswith(".")
|
|
42
|
+
or part in SENSITIVE_PARTS
|
|
43
|
+
or any(marker in part for marker in SENSITIVE_PARTS if marker != ".env")
|
|
44
|
+
for part in parts
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_mentioned_file(cwd: str | Path, mention: str) -> tuple[str | None, str | None]:
|
|
49
|
+
if _looks_sensitive(mention):
|
|
50
|
+
return None, f"Skipped @{mention}: hidden or sensitive-looking path."
|
|
51
|
+
try:
|
|
52
|
+
path = resolve_project_path(str(cwd), mention)
|
|
53
|
+
except Exception as error:
|
|
54
|
+
return None, f"Skipped @{mention}: {error}"
|
|
55
|
+
try:
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
return None, f"Skipped @{mention}: not a file."
|
|
58
|
+
if path.stat().st_size > MAX_MENTION_FILE_BYTES:
|
|
59
|
+
return None, f"Skipped @{mention}: file is too large."
|
|
60
|
+
content = path.read_text(encoding="utf-8")
|
|
61
|
+
except UnicodeDecodeError:
|
|
62
|
+
return None, f"Skipped @{mention}: not a UTF-8 text file."
|
|
63
|
+
except OSError as error:
|
|
64
|
+
return None, f"Skipped @{mention}: {error}"
|
|
65
|
+
if len(content) > MAX_MENTION_CONTENT_CHARS:
|
|
66
|
+
content = content[:MAX_MENTION_CONTENT_CHARS] + "\n\n[... file mention truncated ...]"
|
|
67
|
+
relative = path.relative_to(Path(cwd).resolve()).as_posix()
|
|
68
|
+
return f"--- @{relative} ---\n{content}", None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def expand_file_mentions(text: str, cwd: str | Path = ".") -> str:
|
|
72
|
+
"""Append bounded referenced file content to a prompt."""
|
|
73
|
+
mentions = extract_file_mentions(text)
|
|
74
|
+
if not mentions:
|
|
75
|
+
return text
|
|
76
|
+
|
|
77
|
+
loaded: list[str] = []
|
|
78
|
+
notes: list[str] = []
|
|
79
|
+
for mention in mentions:
|
|
80
|
+
content, note = _read_mentioned_file(cwd, mention)
|
|
81
|
+
if content:
|
|
82
|
+
loaded.append(content)
|
|
83
|
+
if note:
|
|
84
|
+
notes.append(note)
|
|
85
|
+
|
|
86
|
+
if not loaded and not notes:
|
|
87
|
+
return text
|
|
88
|
+
|
|
89
|
+
sections = [
|
|
90
|
+
text,
|
|
91
|
+
"",
|
|
92
|
+
"Referenced @file context:",
|
|
93
|
+
]
|
|
94
|
+
if loaded:
|
|
95
|
+
sections.extend(loaded)
|
|
96
|
+
if notes:
|
|
97
|
+
sections.append("Mention notes:")
|
|
98
|
+
sections.extend(f"- {note}" for note in notes)
|
|
99
|
+
return "\n".join(sections)
|
|
@@ -16,6 +16,7 @@ from .core.fallback import (
|
|
|
16
16
|
stream_with_fallback,
|
|
17
17
|
)
|
|
18
18
|
from .core.context import ContextManager
|
|
19
|
+
from .core.file_mentions import expand_file_mentions
|
|
19
20
|
from .core.memory import save_session, get_last_session
|
|
20
21
|
from .core.project_rules import load_project_rules, project_rules_prompt
|
|
21
22
|
from .core.errors import show_error, AllModelsFailedError
|
|
@@ -145,7 +146,8 @@ def _chat(message: str, model: str = None) -> str:
|
|
|
145
146
|
context.add("user", project_rules_prompt(rules), pinned=True)
|
|
146
147
|
_RULES_LOADED_FOR = cwd
|
|
147
148
|
|
|
148
|
-
|
|
149
|
+
expanded_message = expand_file_mentions(message, cwd)
|
|
150
|
+
context.add("user", expanded_message)
|
|
149
151
|
|
|
150
152
|
if config.get("auto_compact", True) and context.is_near_limit(
|
|
151
153
|
float(config.get("compact_trigger_threshold", 0.85))
|
|
@@ -52,6 +52,13 @@ def completion_candidates(
|
|
|
52
52
|
) -> list[tuple[str, str, str]]:
|
|
53
53
|
"""Return (insert_text, display, metadata) completion candidates."""
|
|
54
54
|
before = text.lstrip()
|
|
55
|
+
mention_fragment = _mention_fragment(before)
|
|
56
|
+
if mention_fragment is not None:
|
|
57
|
+
return [
|
|
58
|
+
(f"@{insert}", f"@{display}", "file mention")
|
|
59
|
+
for insert, display in _path_candidates(mention_fragment, cwd)
|
|
60
|
+
]
|
|
61
|
+
|
|
55
62
|
if not before.startswith("/"):
|
|
56
63
|
return []
|
|
57
64
|
|
|
@@ -136,6 +143,13 @@ def completion_candidates(
|
|
|
136
143
|
return []
|
|
137
144
|
|
|
138
145
|
|
|
146
|
+
def _mention_fragment(text: str) -> str | None:
|
|
147
|
+
token = text.split()[-1] if text.split() else text
|
|
148
|
+
if token.startswith("@"):
|
|
149
|
+
return token[1:]
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
139
153
|
class InteractiveInput:
|
|
140
154
|
"""Reusable input session. Enter submits; Alt+Enter/Ctrl+J inserts newline."""
|
|
141
155
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zai-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Your personal AI CLI — free, fast, and smart
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/HumaizaNaz/zai_cli
|
|
@@ -198,6 +198,10 @@ zai auth login openai
|
|
|
198
198
|
zai auth login anthropic
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
+
When prompted, authentication login can send one tiny live request to validate
|
|
202
|
+
the key. The result identifies the resolved model and gives a recovery command
|
|
203
|
+
for invalid credentials, quota limits, model errors, or network failures.
|
|
204
|
+
|
|
201
205
|
Remove a saved API key from `zai`:
|
|
202
206
|
|
|
203
207
|
```bash
|
|
@@ -426,7 +430,7 @@ zai model add <name> Add a custom model alias
|
|
|
426
430
|
zai model remove <name> Remove a custom model alias
|
|
427
431
|
zai model configure <name> Set timeout and retry policy
|
|
428
432
|
zai model info <name> Show effective model configuration
|
|
429
|
-
zai model test <name> Verify credentials and
|
|
433
|
+
zai model test <name> Verify credentials/model and show recovery guidance
|
|
430
434
|
zai auth status Show API-key and external CLI auth status
|
|
431
435
|
zai auth login <provider> Connect a provider API key
|
|
432
436
|
zai auth logout <provider> Remove a provider API key from zai
|
|
@@ -538,6 +542,18 @@ Interactive input also provides:
|
|
|
538
542
|
- `Ctrl+C` to cancel the current input or operation without ending the session;
|
|
539
543
|
- a basic Rich prompt fallback in redirected or unsupported terminals.
|
|
540
544
|
|
|
545
|
+
You can reference files in natural prompts with `@file`:
|
|
546
|
+
|
|
547
|
+
```text
|
|
548
|
+
explain @app.py
|
|
549
|
+
fix @src/main.py
|
|
550
|
+
compare @README.md and @CHANGELOG.md
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
`zai` loads bounded text from valid referenced files and appends it to the
|
|
554
|
+
prompt context. Hidden files, sensitive-looking paths, binary files, and very
|
|
555
|
+
large files are skipped with a note instead of crashing the session.
|
|
556
|
+
|
|
541
557
|
## How much of my project does zai see?
|
|
542
558
|
|
|
543
559
|
`zai` starts from the folder where you run it. It does not load every file in a
|
|
@@ -11,6 +11,7 @@ tests/test_code_runner.py
|
|
|
11
11
|
tests/test_config_main.py
|
|
12
12
|
tests/test_core.py
|
|
13
13
|
tests/test_errors.py
|
|
14
|
+
tests/test_file_mentions.py
|
|
14
15
|
tests/test_hooks_skills_session.py
|
|
15
16
|
tests/test_input.py
|
|
16
17
|
tests/test_integrations_cli.py
|
|
@@ -62,6 +63,7 @@ zai/core/compaction.py
|
|
|
62
63
|
zai/core/context.py
|
|
63
64
|
zai/core/errors.py
|
|
64
65
|
zai/core/fallback.py
|
|
66
|
+
zai/core/file_mentions.py
|
|
65
67
|
zai/core/hooks.py
|
|
66
68
|
zai/core/memory.py
|
|
67
69
|
zai/core/process.py
|
zai_cli-0.2.0/zai/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|