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.
Files changed (117) hide show
  1. {zai_cli-0.2.0 → zai_cli-0.2.2}/CHANGELOG.md +28 -0
  2. {zai_cli-0.2.0/zai_cli.egg-info → zai_cli-0.2.2}/PKG-INFO +18 -2
  3. {zai_cli-0.2.0 → zai_cli-0.2.2}/README.md +17 -1
  4. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_auth.py +42 -1
  5. zai_cli-0.2.2/tests/test_file_mentions.py +30 -0
  6. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_input.py +16 -0
  7. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_interactive.py +26 -0
  8. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_settings_cli.py +20 -0
  9. zai_cli-0.2.2/zai/__init__.py +1 -0
  10. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/interactive.py +16 -14
  11. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/settings.py +49 -10
  12. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/auth.py +89 -13
  13. zai_cli-0.2.2/zai/core/file_mentions.py +99 -0
  14. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/main.py +3 -1
  15. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/ui/input.py +14 -0
  16. {zai_cli-0.2.0 → zai_cli-0.2.2/zai_cli.egg-info}/PKG-INFO +18 -2
  17. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/SOURCES.txt +2 -0
  18. zai_cli-0.2.0/zai/__init__.py +0 -1
  19. {zai_cli-0.2.0 → zai_cli-0.2.2}/LICENSE +0 -0
  20. {zai_cli-0.2.0 → zai_cli-0.2.2}/MANIFEST.in +0 -0
  21. {zai_cli-0.2.0 → zai_cli-0.2.2}/pyproject.toml +0 -0
  22. {zai_cli-0.2.0 → zai_cli-0.2.2}/scripts/release_preflight.py +0 -0
  23. {zai_cli-0.2.0 → zai_cli-0.2.2}/setup.cfg +0 -0
  24. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_agent.py +0 -0
  25. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_browser.py +0 -0
  26. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_code_runner.py +0 -0
  27. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_config_main.py +0 -0
  28. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_core.py +0 -0
  29. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_errors.py +0 -0
  30. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_hooks_skills_session.py +0 -0
  31. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_integrations_cli.py +0 -0
  32. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_mcp.py +0 -0
  33. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_plugins.py +0 -0
  34. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_process.py +0 -0
  35. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_project_memory.py +0 -0
  36. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_project_rules.py +0 -0
  37. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_providers.py +0 -0
  38. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_release_preflight.py +0 -0
  39. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_search.py +0 -0
  40. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_security.py +0 -0
  41. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_storage.py +0 -0
  42. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_streaming.py +0 -0
  43. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_tokens.py +0 -0
  44. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_tool_schema.py +0 -0
  45. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_tools.py +0 -0
  46. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_undo.py +0 -0
  47. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_utilities.py +0 -0
  48. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_vision.py +0 -0
  49. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_watch.py +0 -0
  50. {zai_cli-0.2.0 → zai_cli-0.2.2}/tests/test_workflows.py +0 -0
  51. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/__main__.py +0 -0
  52. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/__init__.py +0 -0
  53. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/common.py +0 -0
  54. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/integrations.py +0 -0
  55. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/setup_wizard.py +0 -0
  56. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/status.py +0 -0
  57. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/utilities.py +0 -0
  58. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/cli/workflows.py +0 -0
  59. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/commit.md +0 -0
  60. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/explain.md +0 -0
  61. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/feature.md +0 -0
  62. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/fix.md +0 -0
  63. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/commands/review.md +0 -0
  64. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/config.py +0 -0
  65. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/__init__.py +0 -0
  66. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/agent.py +0 -0
  67. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/cancellation.py +0 -0
  68. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/commands.py +0 -0
  69. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/compaction.py +0 -0
  70. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/context.py +0 -0
  71. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/errors.py +0 -0
  72. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/fallback.py +0 -0
  73. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/hooks.py +0 -0
  74. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/memory.py +0 -0
  75. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/process.py +0 -0
  76. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/project_memory.py +0 -0
  77. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/project_rules.py +0 -0
  78. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/repomap.py +0 -0
  79. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/runtime.py +0 -0
  80. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/security.py +0 -0
  81. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/session.py +0 -0
  82. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/storage.py +0 -0
  83. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/streaming.py +0 -0
  84. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/tokens.py +0 -0
  85. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/tool_schema.py +0 -0
  86. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/undo.py +0 -0
  87. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/core/watch.py +0 -0
  88. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/mcp/__init__.py +0 -0
  89. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/mcp/client.py +0 -0
  90. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/mcp/manager.py +0 -0
  91. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/plugins/__init__.py +0 -0
  92. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/plugins/base.py +0 -0
  93. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/plugins/loader.py +0 -0
  94. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/__init__.py +0 -0
  95. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/anthropic.py +0 -0
  96. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/base.py +0 -0
  97. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/cerebras.py +0 -0
  98. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/gemini.py +0 -0
  99. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/groq.py +0 -0
  100. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/ollama.py +0 -0
  101. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/openai.py +0 -0
  102. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/openrouter.py +0 -0
  103. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/providers/qwen.py +0 -0
  104. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/skills/__init__.py +0 -0
  105. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/skills/registry.py +0 -0
  106. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/__init__.py +0 -0
  107. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/browser.py +0 -0
  108. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/code_runner.py +0 -0
  109. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/files.py +0 -0
  110. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/git.py +0 -0
  111. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/search.py +0 -0
  112. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/tools/vision.py +0 -0
  113. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai/ui/__init__.py +0 -0
  114. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/dependency_links.txt +0 -0
  115. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/entry_points.txt +0 -0
  116. {zai_cli-0.2.0 → zai_cli-0.2.2}/zai_cli.egg-info/requires.txt +0 -0
  117. {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.0
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 model availability
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 model availability
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
- validate_provider,
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
- status = validate_provider(info.key)
286
- style = "green" if status == "OK" else "yellow"
287
- console.print(f"[{style}]Validation: {status}[/{style}]")
288
- if status != "OK":
289
- console.print(
290
- f"[dim]Update it with `/auth login {info.key}` "
291
- f"or remove it with `/auth logout {info.key}`.[/dim]"
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
- plan = plan_agent(task, history, cwd, preferred)
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(task, history, cwd, preferred)
755
+ result = run_agent(expanded_task, history, cwd, preferred)
755
756
  if result:
756
- remember_turn(task, result)
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
- result = run_agent(stripped, history, cwd, preferred)
959
+ expanded = expand_file_mentions(stripped, cwd)
960
+ result = run_agent(expanded, history, cwd, preferred)
959
961
  if result:
960
- remember_turn(stripped, result)
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
- validate_provider,
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
- status = validate_provider(info.key)
175
- style = "green" if status == "OK" else "yellow"
176
- console.print(f"[{style}]Validation: {status}[/{style}]")
177
- if status != "OK":
178
- console.print(
179
- f"[dim]Update it with `zai auth login {info.key}` "
180
- f"or remove it with `zai auth logout {info.key}`.[/dim]"
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 Exception as error:
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 validate_provider(provider: str) -> str:
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 "missing"
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 "missing"
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
- message = str(error).lower()
247
- if any(marker in message for marker in ("unauthorized", "auth", "api key", "401")):
248
- return "invalid"
249
- if any(marker in message for marker in ("quota", "rate limit", "429")):
250
- return "quota/rate limit"
251
- if any(marker in message for marker in ("timeout", "network", "connection")):
252
- return "network error"
253
- return "network error"
254
- return "OK"
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
- context.add("user", message)
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.0
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 model availability
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
@@ -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