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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axor-cli
3
- Version: 0.1.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.1.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
- [![CI](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
36
- [![PyPI](https://img.shields.io/pypi/v/axor-cli)](https://pypi.org/project/axor-cli/)
37
- [![Python](https://img.shields.io/pypi/pyversions/axor-cli)](https://pypi.org/project/axor-cli/)
39
+ [![CI](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/axor-cli?cacheSeconds=300)](https://pypi.org/project/axor-cli/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/axor-cli?cacheSeconds=300)](https://pypi.org/project/axor-cli/)
38
42
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
39
43
 
40
44
 
@@ -1,8 +1,8 @@
1
1
  # axor-cli
2
2
 
3
- [![CI](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
4
- [![PyPI](https://img.shields.io/pypi/v/axor-cli)](https://pypi.org/project/axor-cli/)
5
- [![Python](https://img.shields.io/pypi/pyversions/axor-cli)](https://pypi.org/project/axor-cli/)
3
+ [![CI](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/axor-cli?cacheSeconds=300)](https://pypi.org/project/axor-cli/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/axor-cli?cacheSeconds=300)](https://pypi.org/project/axor-cli/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 = prefix
72
- self._running = False
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._running = True
81
- self._thread = threading.Thread(target=self._spin, daemon=True)
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._running = False
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._running:
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
- time.sleep(0.08)
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 — adapter exposes set_text_callback()
74
- if hasattr(executor, "set_text_callback"):
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)