axor-cli 0.1.0__tar.gz → 0.2.0__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.
- {axor_cli-0.1.0 → axor_cli-0.2.0}/PKG-INFO +9 -5
- {axor_cli-0.1.0 → axor_cli-0.2.0}/README.md +3 -3
- axor_cli-0.2.0/axor_cli/_version.py +1 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/axor_cli/adapters.py +4 -0
- axor_cli-0.2.0/axor_cli/auth.py +335 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/axor_cli/display.py +10 -7
- {axor_cli-0.1.0 → axor_cli-0.2.0}/axor_cli/main.py +16 -1
- {axor_cli-0.1.0 → axor_cli-0.2.0}/axor_cli/streaming.py +4 -3
- axor_cli-0.2.0/axor_cli/telemetry.py +151 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/pyproject.toml +10 -7
- axor_cli-0.2.0/tests/unit/test_adapters.py +39 -0
- axor_cli-0.2.0/tests/unit/test_auth.py +129 -0
- axor_cli-0.2.0/tests/unit/test_display.py +59 -0
- axor_cli-0.2.0/tests/unit/test_smoke.py +19 -0
- axor_cli-0.2.0/tests/unit/test_telemetry_bridge.py +172 -0
- axor_cli-0.1.0/axor_cli/_version.py +0 -1
- axor_cli-0.1.0/axor_cli/auth.py +0 -189
- axor_cli-0.1.0/tests/unit/test_smoke.py +0 -13
- {axor_cli-0.1.0 → axor_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/.gitignore +0 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/CHANGELOG.md +0 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/CONTRIBUTING.md +0 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/LICENSE +0 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/axor_cli/__init__.py +0 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/tests/__init__.py +0 -0
- {axor_cli-0.1.0 → axor_cli-0.2.0}/tests/conftest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axor-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: CLI for axor-core governance kernel — governed agent sessions in your terminal
|
|
5
5
|
Project-URL: Bug Tracker, https://github.com/Bucha11/axor-cli/issues
|
|
6
6
|
Project-URL: Changelog, https://github.com/Bucha11/axor-cli/releases
|
|
@@ -17,24 +17,28 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries
|
|
18
18
|
Classifier: Topic :: Terminals
|
|
19
19
|
Requires-Python: >=3.11
|
|
20
|
-
Requires-Dist: axor-core>=0.
|
|
20
|
+
Requires-Dist: axor-core>=0.3.0
|
|
21
21
|
Provides-Extra: all
|
|
22
22
|
Requires-Dist: axor-claude>=0.1.0; extra == 'all'
|
|
23
23
|
Requires-Dist: axor-openai>=0.1.0; extra == 'all'
|
|
24
|
+
Requires-Dist: axor-telemetry>=0.1.0; extra == 'all'
|
|
24
25
|
Provides-Extra: claude
|
|
25
26
|
Requires-Dist: axor-claude>=0.1.0; extra == 'claude'
|
|
26
27
|
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: axor-telemetry>=0.1.0; extra == 'dev'
|
|
27
29
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
30
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
31
|
Provides-Extra: openai
|
|
30
32
|
Requires-Dist: axor-openai>=0.1.0; extra == 'openai'
|
|
33
|
+
Provides-Extra: telemetry
|
|
34
|
+
Requires-Dist: axor-telemetry>=0.1.0; extra == 'telemetry'
|
|
31
35
|
Description-Content-Type: text/markdown
|
|
32
36
|
|
|
33
37
|
# axor-cli
|
|
34
38
|
|
|
35
|
-
[](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
|
|
36
|
-
[](https://pypi.org/project/axor-cli/)
|
|
37
|
-
[](https://pypi.org/project/axor-cli/)
|
|
39
|
+
[](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
|
|
40
|
+
[](https://pypi.org/project/axor-cli/)
|
|
41
|
+
[](https://pypi.org/project/axor-cli/)
|
|
38
42
|
[](LICENSE)
|
|
39
43
|
|
|
40
44
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# axor-cli
|
|
2
2
|
|
|
3
|
-
[](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
|
|
4
|
-
[](https://pypi.org/project/axor-cli/)
|
|
5
|
-
[](https://pypi.org/project/axor-cli/)
|
|
3
|
+
[](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/axor-cli/)
|
|
5
|
+
[](https://pypi.org/project/axor-cli/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -60,6 +60,7 @@ def build_session(
|
|
|
60
60
|
system_prompt: str | None = None,
|
|
61
61
|
load_skills: bool = True,
|
|
62
62
|
load_plugins: bool = True,
|
|
63
|
+
telemetry: Any | None = None,
|
|
63
64
|
) -> GovernedSession:
|
|
64
65
|
"""
|
|
65
66
|
Import the adapter package and build a GovernedSession.
|
|
@@ -94,6 +95,9 @@ def build_session(
|
|
|
94
95
|
if soft_token_limit is not None:
|
|
95
96
|
kwargs["soft_token_limit"] = soft_token_limit
|
|
96
97
|
|
|
98
|
+
if telemetry is not None:
|
|
99
|
+
kwargs["telemetry"] = telemetry
|
|
100
|
+
|
|
97
101
|
# model and system_prompt are passed to the executor inside make_session
|
|
98
102
|
# adapters should accept **session_kwargs and forward to their executor
|
|
99
103
|
if model:
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
API key management for axor-cli.
|
|
5
|
+
|
|
6
|
+
Priority order (highest to lowest):
|
|
7
|
+
1. --api-key CLI flag (one-off, never saved)
|
|
8
|
+
2. ADAPTER_API_KEY env var (e.g. ANTHROPIC_API_KEY)
|
|
9
|
+
3. ~/.axor/config.toml (persistent, 0600 permissions)
|
|
10
|
+
4. None -> prompt via /auth
|
|
11
|
+
|
|
12
|
+
~/.axor/config.toml format:
|
|
13
|
+
[claude]
|
|
14
|
+
api_key = "sk-ant-..."
|
|
15
|
+
|
|
16
|
+
[openai]
|
|
17
|
+
api_key = "sk-..."
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import getpass
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import tomllib # Python 3.11+
|
|
30
|
+
except ImportError:
|
|
31
|
+
try:
|
|
32
|
+
import tomli as tomllib # fallback
|
|
33
|
+
except ImportError:
|
|
34
|
+
tomllib = None # type: ignore
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Configuration paths
|
|
39
|
+
CONFIG_DIR = Path.home() / ".axor"
|
|
40
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
41
|
+
|
|
42
|
+
# File permissions for config (owner read/write only)
|
|
43
|
+
CONFIG_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR # 0600
|
|
44
|
+
|
|
45
|
+
# Environment variable names per adapter
|
|
46
|
+
_ENV_VARS: dict[str, str] = {
|
|
47
|
+
"claude": "ANTHROPIC_API_KEY",
|
|
48
|
+
"openai": "OPENAI_API_KEY",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ============================================================================
|
|
53
|
+
# Public API
|
|
54
|
+
# ============================================================================
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_api_key(adapter: str, flag_key: str | None = None) -> str | None:
|
|
58
|
+
"""
|
|
59
|
+
Resolve API key using priority chain.
|
|
60
|
+
|
|
61
|
+
Priority order:
|
|
62
|
+
1. CLI flag (flag_key parameter)
|
|
63
|
+
2. Environment variable (ADAPTER_API_KEY)
|
|
64
|
+
3. Config file (~/.axor/config.toml)
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
adapter: Name of the adapter (e.g., "claude", "openai")
|
|
68
|
+
flag_key: Optional API key from CLI flag (highest priority)
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
API key string or None if not found
|
|
72
|
+
"""
|
|
73
|
+
# 1. CLI flag has highest priority
|
|
74
|
+
if flag_key:
|
|
75
|
+
return flag_key
|
|
76
|
+
|
|
77
|
+
# 2. Check environment variable
|
|
78
|
+
key = _get_key_from_env(adapter)
|
|
79
|
+
if key:
|
|
80
|
+
return key
|
|
81
|
+
|
|
82
|
+
# 3. Check config file
|
|
83
|
+
key = load_from_config(adapter)
|
|
84
|
+
if key:
|
|
85
|
+
# Set in environment so adapter executables can pick it up
|
|
86
|
+
_set_key_in_env(adapter, key)
|
|
87
|
+
return key
|
|
88
|
+
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_from_config(adapter: str) -> str | None:
|
|
93
|
+
"""
|
|
94
|
+
Load API key from ~/.axor/config.toml.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
adapter: Name of the adapter section in config
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
API key string or None if not found
|
|
101
|
+
"""
|
|
102
|
+
if not CONFIG_FILE.exists():
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
if tomllib is None:
|
|
106
|
+
logger.warning("TOML support unavailable (install tomli for Python <3.11)")
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
config = _read_config_file()
|
|
111
|
+
return config.get(adapter, {}).get("api_key")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning("Failed to read %s: %s", CONFIG_FILE, e)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def save_to_config(adapter: str, api_key: str) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Save API key to ~/.axor/config.toml with 0600 permissions.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
adapter: Name of the adapter section
|
|
123
|
+
api_key: API key to save
|
|
124
|
+
"""
|
|
125
|
+
existing = _load_existing_config()
|
|
126
|
+
|
|
127
|
+
if adapter not in existing:
|
|
128
|
+
existing[adapter] = {}
|
|
129
|
+
existing[adapter]["api_key"] = api_key
|
|
130
|
+
|
|
131
|
+
_write_config(existing)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def clear_from_config(adapter: str) -> bool:
|
|
135
|
+
"""
|
|
136
|
+
Remove adapter key from config.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
adapter: Name of the adapter section to remove
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if key existed and was removed, False otherwise
|
|
143
|
+
"""
|
|
144
|
+
if not CONFIG_FILE.exists():
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
if tomllib is None:
|
|
148
|
+
logger.warning("TOML support unavailable — cannot clear config")
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
existing = _read_config_file()
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.warning("Failed to read config: %s", e)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
if adapter not in existing:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
del existing[adapter]
|
|
161
|
+
_write_config(existing)
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def prompt_and_save(adapter: str) -> str | None:
|
|
166
|
+
"""
|
|
167
|
+
Interactively prompt for API key and optionally save to config.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
adapter: Name of the adapter
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The entered API key or None if user cancelled
|
|
174
|
+
"""
|
|
175
|
+
env_var_name = _get_env_var_name(adapter)
|
|
176
|
+
|
|
177
|
+
_print_prompt_header(adapter, env_var_name)
|
|
178
|
+
|
|
179
|
+
key = _prompt_for_key(adapter)
|
|
180
|
+
if not key:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
_offer_to_save_key(adapter, key)
|
|
184
|
+
_set_key_in_env(adapter, key)
|
|
185
|
+
|
|
186
|
+
return key
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ============================================================================
|
|
190
|
+
# Private helpers - Environment variable handling
|
|
191
|
+
# ============================================================================
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _get_env_var_name(adapter: str) -> str:
|
|
195
|
+
"""Get the environment variable name for an adapter."""
|
|
196
|
+
return _ENV_VARS.get(adapter, f"{adapter.upper()}_API_KEY")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _get_key_from_env(adapter: str) -> str | None:
|
|
200
|
+
"""Get API key from environment variable."""
|
|
201
|
+
env_var = _ENV_VARS.get(adapter)
|
|
202
|
+
if env_var:
|
|
203
|
+
return os.environ.get(env_var)
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _set_key_in_env(adapter: str, key: str) -> None:
|
|
208
|
+
"""Set API key in environment variable."""
|
|
209
|
+
env_var = _ENV_VARS.get(adapter)
|
|
210
|
+
if env_var:
|
|
211
|
+
os.environ[env_var] = key
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ============================================================================
|
|
215
|
+
# Private helpers - Config file I/O
|
|
216
|
+
# ============================================================================
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _read_config_file() -> dict[str, Any]:
|
|
220
|
+
"""Read and parse the config file."""
|
|
221
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
222
|
+
return tomllib.load(f) # type: ignore
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _load_existing_config() -> dict[str, Any]:
|
|
226
|
+
"""Load existing config or return empty dict if not available."""
|
|
227
|
+
if not CONFIG_FILE.exists() or tomllib is None:
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
return _read_config_file()
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.warning("Failed to read existing config: %s", e)
|
|
234
|
+
return {}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _escape_toml_value(val: str) -> str:
|
|
238
|
+
"""Escape a string for TOML double-quoted value."""
|
|
239
|
+
return val.replace("\\", "\\\\").replace('"', '\\"')
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _serialize_config_to_toml(data: dict[str, Any]) -> str:
|
|
243
|
+
"""Serialize config dict to TOML format."""
|
|
244
|
+
lines: list[str] = []
|
|
245
|
+
for section, values in data.items():
|
|
246
|
+
lines.append(f"[{section}]")
|
|
247
|
+
for key, val in values.items():
|
|
248
|
+
escaped_val = _escape_toml_value(str(val))
|
|
249
|
+
lines.append(f'{key} = "{escaped_val}"')
|
|
250
|
+
lines.append("")
|
|
251
|
+
return "\n".join(lines)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _write_config(data: dict[str, Any]) -> None:
|
|
255
|
+
"""
|
|
256
|
+
Write config dict to file atomically with 0600 permissions.
|
|
257
|
+
|
|
258
|
+
Uses atomic write pattern: write to temp file with restricted permissions,
|
|
259
|
+
then replace the original file.
|
|
260
|
+
"""
|
|
261
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
|
|
263
|
+
toml_content = _serialize_config_to_toml(data)
|
|
264
|
+
|
|
265
|
+
# Atomic write: create temp with restricted perms, then replace
|
|
266
|
+
fd, tmp_path = tempfile.mkstemp(dir=CONFIG_DIR, prefix=".axor_cfg_")
|
|
267
|
+
try:
|
|
268
|
+
# Set permissions before writing content
|
|
269
|
+
os.fchmod(fd, CONFIG_FILE_MODE)
|
|
270
|
+
with os.fdopen(fd, "w") as f:
|
|
271
|
+
f.write(toml_content)
|
|
272
|
+
os.replace(tmp_path, CONFIG_FILE)
|
|
273
|
+
except Exception:
|
|
274
|
+
# Clean up temp file on error
|
|
275
|
+
if os.path.exists(tmp_path):
|
|
276
|
+
os.unlink(tmp_path)
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ============================================================================
|
|
281
|
+
# Private helpers - Interactive prompts
|
|
282
|
+
# ============================================================================
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _print_prompt_header(adapter: str, env_var_name: str) -> None:
|
|
286
|
+
"""Print header for interactive prompt."""
|
|
287
|
+
print(f"\n No API key found for '{adapter}'.")
|
|
288
|
+
print(f" (checked: --api-key flag, {env_var_name} env var, {CONFIG_FILE})\n")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _prompt_for_key(adapter: str) -> str | None:
|
|
292
|
+
"""
|
|
293
|
+
Prompt user for API key.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Entered key or None if cancelled/empty
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
key = getpass.getpass(
|
|
300
|
+
f" {adapter.capitalize()} API key (hidden): "
|
|
301
|
+
).strip()
|
|
302
|
+
except (KeyboardInterrupt, EOFError):
|
|
303
|
+
print()
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
if not key:
|
|
307
|
+
print(" No key entered.")
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
return key
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _should_save_key() -> bool:
|
|
314
|
+
"""Ask user if they want to save the key."""
|
|
315
|
+
try:
|
|
316
|
+
response = input(
|
|
317
|
+
" Save to ~/.axor/config.toml for future sessions? [Y/n]: "
|
|
318
|
+
).strip().lower()
|
|
319
|
+
except (KeyboardInterrupt, EOFError):
|
|
320
|
+
print()
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
return response in ("", "y", "yes")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _offer_to_save_key(adapter: str, key: str) -> None:
|
|
327
|
+
"""Offer to save the key to config and execute the save if accepted."""
|
|
328
|
+
if _should_save_key():
|
|
329
|
+
try:
|
|
330
|
+
save_to_config(adapter, key)
|
|
331
|
+
print(f" Saved to {CONFIG_FILE} (permissions: 600)")
|
|
332
|
+
except Exception as e:
|
|
333
|
+
print(f" Could not save: {e}")
|
|
334
|
+
else:
|
|
335
|
+
print(" Key not saved — valid for this session only.")
|
|
@@ -19,6 +19,8 @@ import threading
|
|
|
19
19
|
# ── Color support ──────────────────────────────────────────────────────────────
|
|
20
20
|
|
|
21
21
|
def _supports_color() -> bool:
|
|
22
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
23
|
+
return False
|
|
22
24
|
if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
|
|
23
25
|
return False
|
|
24
26
|
return os.environ.get("TERM", "") != "dumb"
|
|
@@ -68,8 +70,8 @@ class Spinner:
|
|
|
68
70
|
_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
69
71
|
|
|
70
72
|
def __init__(self, prefix: str = "") -> None:
|
|
71
|
-
self._prefix
|
|
72
|
-
self.
|
|
73
|
+
self._prefix = prefix
|
|
74
|
+
self._stop_event = threading.Event()
|
|
73
75
|
self._thread: threading.Thread | None = None
|
|
74
76
|
|
|
75
77
|
def start(self) -> None:
|
|
@@ -77,25 +79,26 @@ class Spinner:
|
|
|
77
79
|
sys.stdout.write(dim("thinking...\n"))
|
|
78
80
|
sys.stdout.flush()
|
|
79
81
|
return
|
|
80
|
-
self.
|
|
81
|
-
self._thread
|
|
82
|
+
self._stop_event.clear()
|
|
83
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
82
84
|
self._thread.start()
|
|
83
85
|
|
|
84
86
|
def stop(self) -> None:
|
|
85
|
-
self.
|
|
87
|
+
self._stop_event.set()
|
|
86
88
|
if self._thread:
|
|
87
89
|
self._thread.join(timeout=0.5)
|
|
90
|
+
self._thread = None
|
|
88
91
|
if _COLOR:
|
|
89
92
|
sys.stdout.write("\r\033[K") # clear spinner line
|
|
90
93
|
sys.stdout.flush()
|
|
91
94
|
|
|
92
95
|
def _spin(self) -> None:
|
|
93
96
|
i = 0
|
|
94
|
-
while self.
|
|
97
|
+
while not self._stop_event.is_set():
|
|
95
98
|
frame = self._FRAMES[i % len(self._FRAMES)]
|
|
96
99
|
sys.stdout.write(f"\r{dim(self._prefix)}{dim(frame)} ")
|
|
97
100
|
sys.stdout.flush()
|
|
98
|
-
|
|
101
|
+
self._stop_event.wait(timeout=0.08)
|
|
99
102
|
i += 1
|
|
100
103
|
|
|
101
104
|
|
|
@@ -27,7 +27,7 @@ for _candidate in [
|
|
|
27
27
|
sys.path.insert(0, os.path.abspath(_candidate))
|
|
28
28
|
break
|
|
29
29
|
|
|
30
|
-
from axor_cli import display, auth, adapters, streaming
|
|
30
|
+
from axor_cli import display, auth, adapters, streaming, telemetry
|
|
31
31
|
from axor_cli._version import __version__
|
|
32
32
|
|
|
33
33
|
|
|
@@ -38,6 +38,10 @@ Built-in commands:
|
|
|
38
38
|
/auth Set or update API key (saved to ~/.axor/config.toml)
|
|
39
39
|
/auth --clear Remove saved API key
|
|
40
40
|
/auth --show Show where key is loaded from (never shows the key)
|
|
41
|
+
/telemetry Show telemetry status
|
|
42
|
+
/telemetry on Enable local telemetry (adds --remote to also ship)
|
|
43
|
+
/telemetry off Disable telemetry
|
|
44
|
+
/telemetry preview Print the last queued telemetry record
|
|
41
45
|
/cost Token usage for this session
|
|
42
46
|
/policy Last execution policy
|
|
43
47
|
/compact Compress context (reduces token usage)
|
|
@@ -182,6 +186,7 @@ async def repl(session, adapter: str, args: argparse.Namespace) -> None:
|
|
|
182
186
|
soft_token_limit=args.limit,
|
|
183
187
|
load_skills=not args.no_skills,
|
|
184
188
|
load_plugins=not args.no_plugins,
|
|
189
|
+
telemetry=telemetry.build_pipeline(axor_version=__version__),
|
|
185
190
|
)
|
|
186
191
|
session = new_session
|
|
187
192
|
display.print_success("Session ready.")
|
|
@@ -205,6 +210,11 @@ async def repl(session, adapter: str, args: argparse.Namespace) -> None:
|
|
|
205
210
|
f"Restart with: axor {adapter} --model {parts[1]}")
|
|
206
211
|
continue
|
|
207
212
|
|
|
213
|
+
# ── /telemetry (CLI-local, not governance) ─────────────────────────────
|
|
214
|
+
if line.startswith("/telemetry"):
|
|
215
|
+
telemetry.handle_slash(line)
|
|
216
|
+
continue
|
|
217
|
+
|
|
208
218
|
# ── Governed slash commands (forwarded to session) ─────────────────────
|
|
209
219
|
if line.startswith("/"):
|
|
210
220
|
result = await session.run(line)
|
|
@@ -264,6 +274,10 @@ async def async_main() -> int:
|
|
|
264
274
|
display.print_error("No API key. Exiting.")
|
|
265
275
|
return 1
|
|
266
276
|
|
|
277
|
+
# one-time opt-in banner (no-op after first run, suppressed by AXOR_NO_BANNER)
|
|
278
|
+
telemetry.maybe_show_first_run_banner()
|
|
279
|
+
pipeline = telemetry.build_pipeline(axor_version=__version__)
|
|
280
|
+
|
|
267
281
|
# build session
|
|
268
282
|
try:
|
|
269
283
|
session = adapters.build_session(
|
|
@@ -274,6 +288,7 @@ async def async_main() -> int:
|
|
|
274
288
|
soft_token_limit=args.limit,
|
|
275
289
|
load_skills=not args.no_skills,
|
|
276
290
|
load_plugins=not args.no_plugins,
|
|
291
|
+
telemetry=pipeline,
|
|
277
292
|
)
|
|
278
293
|
except Exception as e:
|
|
279
294
|
display.print_error(f"Could not start session: {e}")
|
|
@@ -68,10 +68,11 @@ async def _stream_run(
|
|
|
68
68
|
summary: dict[str, Any],
|
|
69
69
|
) -> None:
|
|
70
70
|
text_received = False
|
|
71
|
-
executor = session._executor
|
|
72
71
|
|
|
73
|
-
# streaming path —
|
|
74
|
-
|
|
72
|
+
# streaming path — session.executor exposes set_text_callback()
|
|
73
|
+
# Use getattr to avoid depending on GovernedSession internals
|
|
74
|
+
executor = getattr(session, "executor", None) or getattr(session, "_executor", None)
|
|
75
|
+
if executor and hasattr(executor, "set_text_callback"):
|
|
75
76
|
def on_text(chunk: str) -> None:
|
|
76
77
|
nonlocal text_received
|
|
77
78
|
if not text_received:
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
axor-cli <-> axor-telemetry bridge.
|
|
3
|
+
|
|
4
|
+
Keeps axor-telemetry as an optional dependency: everything here no-ops when
|
|
5
|
+
the package is not importable. The REPL and startup never see exceptions
|
|
6
|
+
from the telemetry path.
|
|
7
|
+
|
|
8
|
+
Responsibilities:
|
|
9
|
+
- Resolve TelemetryConfig from ~/.axor/config.toml + env.
|
|
10
|
+
- Build a TelemetryPipeline when mode != off.
|
|
11
|
+
- Show a one-time stderr banner if telemetry is off and no marker exists,
|
|
12
|
+
so users discover the opt-in without being prompted mid-session.
|
|
13
|
+
- Run `/telemetry` CLI-side subcommands (status / on / off / preview).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
_MARKER_PATH = Path.home() / ".axor" / ".telemetry_notice_shown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_importable() -> bool:
|
|
26
|
+
try:
|
|
27
|
+
import axor_telemetry # noqa: F401
|
|
28
|
+
return True
|
|
29
|
+
except ImportError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_pipeline(axor_version: str = "") -> Any | None:
|
|
34
|
+
"""
|
|
35
|
+
Return a TelemetryPipeline instance when mode is enabled, else None.
|
|
36
|
+
|
|
37
|
+
Never raises. Returns None on any failure so the CLI can continue
|
|
38
|
+
without telemetry.
|
|
39
|
+
"""
|
|
40
|
+
if not _is_importable():
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
from axor_telemetry import TelemetryConfig, build_pipeline as _build
|
|
44
|
+
cfg = TelemetryConfig.load()
|
|
45
|
+
if not cfg.enabled:
|
|
46
|
+
return None
|
|
47
|
+
return _build(config=cfg, axor_version=axor_version)
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def current_mode() -> str:
|
|
53
|
+
"""Resolved mode string ('off' | 'local' | 'remote' | 'unknown')."""
|
|
54
|
+
if not _is_importable():
|
|
55
|
+
return "unknown"
|
|
56
|
+
try:
|
|
57
|
+
from axor_telemetry import TelemetryConfig
|
|
58
|
+
return TelemetryConfig.load().mode.value
|
|
59
|
+
except Exception:
|
|
60
|
+
return "unknown"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def maybe_show_first_run_banner(stream=sys.stderr) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Print a one-line opt-in banner on the first run of the CLI where
|
|
66
|
+
telemetry is off. Controlled by:
|
|
67
|
+
- marker file at ~/.axor/.telemetry_notice_shown (created after shown)
|
|
68
|
+
- AXOR_NO_BANNER=1 env var to suppress unconditionally
|
|
69
|
+
- telemetry mode — only shown when OFF
|
|
70
|
+
|
|
71
|
+
Safe to call on every startup — second call is a no-op.
|
|
72
|
+
"""
|
|
73
|
+
if os.environ.get("AXOR_NO_BANNER") == "1":
|
|
74
|
+
return
|
|
75
|
+
if not _is_importable():
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
from axor_telemetry import TelemetryConfig
|
|
79
|
+
cfg = TelemetryConfig.load()
|
|
80
|
+
except Exception:
|
|
81
|
+
return
|
|
82
|
+
if cfg.enabled:
|
|
83
|
+
return
|
|
84
|
+
if _MARKER_PATH.is_file():
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
stream.write(
|
|
88
|
+
"axor: anonymous telemetry is off. "
|
|
89
|
+
"Run `axor telemetry consent` to help tune the classifier. "
|
|
90
|
+
"(shown once; suppress with AXOR_NO_BANNER=1)\n"
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
_MARKER_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
_MARKER_PATH.write_text("shown\n", encoding="utf-8")
|
|
95
|
+
except OSError:
|
|
96
|
+
# Marker is best-effort — missing write permission just means we
|
|
97
|
+
# may show the banner more than once, not a functional problem.
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── /telemetry slash command (CLI-local, not governance) ────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def handle_slash(raw: str, stream=sys.stdout) -> int:
|
|
105
|
+
"""
|
|
106
|
+
Dispatch `/telemetry [on|off|status|preview|consent] ...`.
|
|
107
|
+
|
|
108
|
+
Returns a shell-style return code for testability. Prints messages to
|
|
109
|
+
`stream`. Does not touch the GovernedSession — config writes take effect
|
|
110
|
+
on next CLI start.
|
|
111
|
+
"""
|
|
112
|
+
if not _is_importable():
|
|
113
|
+
stream.write(
|
|
114
|
+
"telemetry is not installed. Install with: pip install axor-telemetry[core]\n"
|
|
115
|
+
)
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
from axor_telemetry import cli as tcli
|
|
119
|
+
|
|
120
|
+
parts = raw.split()
|
|
121
|
+
if len(parts) <= 1:
|
|
122
|
+
return tcli.cmd_status(_ns(), stream=stream)
|
|
123
|
+
|
|
124
|
+
sub = parts[1].lower()
|
|
125
|
+
if sub in ("status",):
|
|
126
|
+
return tcli.cmd_status(_ns(), stream=stream)
|
|
127
|
+
if sub == "preview":
|
|
128
|
+
return tcli.cmd_preview(_ns(), stream=stream)
|
|
129
|
+
if sub == "off":
|
|
130
|
+
return tcli.cmd_off(_ns(), stream=stream)
|
|
131
|
+
if sub == "on":
|
|
132
|
+
ns = _ns(remote="--remote" in parts[2:])
|
|
133
|
+
return tcli.cmd_on(ns, stream=stream)
|
|
134
|
+
if sub == "consent":
|
|
135
|
+
# Interactive consent needs stdin — kept for `python -m axor_telemetry consent`.
|
|
136
|
+
stream.write(
|
|
137
|
+
"interactive consent is available via `python -m axor_telemetry consent`.\n"
|
|
138
|
+
"use `/telemetry on` or `/telemetry on --remote` to opt in without prompts.\n"
|
|
139
|
+
)
|
|
140
|
+
return 0
|
|
141
|
+
stream.write(f"unknown subcommand: {sub}\n")
|
|
142
|
+
stream.write("usage: /telemetry [status|on [--remote]|off|preview|consent]\n")
|
|
143
|
+
return 2
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _ns:
|
|
147
|
+
"""Minimal argparse.Namespace stand-in for direct cmd_* invocation."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, **kwargs):
|
|
150
|
+
for k, v in kwargs.items():
|
|
151
|
+
setattr(self, k, v)
|