sernixa-cli 0.4.2__tar.gz → 0.4.3__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 (27) hide show
  1. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/PKG-INFO +2 -2
  2. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/pyproject.toml +2 -2
  3. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/__init__.py +1 -1
  4. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/config.py +37 -0
  5. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/main.py +99 -1
  6. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli.egg-info/PKG-INFO +2 -2
  7. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli.egg-info/requires.txt +1 -1
  8. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/tests/test_cli.py +90 -1
  9. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/README.md +0 -0
  10. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/__main__.py +0 -0
  11. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/adapters.py +0 -0
  12. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/discovery.py +0 -0
  13. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/formatting.py +0 -0
  14. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/hooks.py +0 -0
  15. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/lifecycle.py +0 -0
  16. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/local_agent_targets.py +0 -0
  17. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/mcp_server.py +0 -0
  18. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli/native_launcher.py +0 -0
  19. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli.egg-info/SOURCES.txt +0 -0
  20. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli.egg-info/dependency_links.txt +0 -0
  21. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli.egg-info/entry_points.txt +0 -0
  22. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/sernixa_cli.egg-info/top_level.txt +0 -0
  23. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/setup.cfg +0 -0
  24. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/tests/test_discovery.py +0 -0
  25. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/tests/test_local_agent_targets.py +0 -0
  26. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/tests/test_mcp_server.py +0 -0
  27. {sernixa_cli-0.4.2 → sernixa_cli-0.4.3}/tests/test_native_launcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sernixa-cli
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Official Sernixa CLI for policy-governed local AI agent actions
5
5
  Author: Sernixa Team
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
  Requires-Dist: platformdirs>=4.0.0
23
23
  Requires-Dist: prompt-toolkit>=3.0.48
24
24
  Requires-Dist: rich>=13.8.0
25
- Requires-Dist: sernixa>=0.4.1
25
+ Requires-Dist: sernixa>=0.4.2
26
26
  Requires-Dist: sernixa-agent>=1.6.1
27
27
  Requires-Dist: typer>=0.12.0
28
28
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sernixa-cli"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = "Official Sernixa CLI for policy-governed local AI agent actions"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "platformdirs>=4.0.0",
22
22
  "prompt-toolkit>=3.0.48",
23
23
  "rich>=13.8.0",
24
- "sernixa>=0.4.1",
24
+ "sernixa>=0.4.2",
25
25
  "sernixa-agent>=1.6.1",
26
26
  "typer>=0.12.0",
27
27
  ]
@@ -1,3 +1,3 @@
1
1
  """Official Sernixa command-line client."""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.4.3"
@@ -6,6 +6,7 @@ import unicodedata
6
6
  from dataclasses import asdict, dataclass
7
7
  from pathlib import Path
8
8
  from typing import Literal
9
+ from uuid import uuid4
9
10
 
10
11
  from platformdirs import user_config_path
11
12
 
@@ -51,6 +52,42 @@ def plan_session_path() -> Path:
51
52
  return user_config_path("sernixa", appauthor=False) / "plan-session.json"
52
53
 
53
54
 
55
+ def installation_id_path() -> Path:
56
+ override = os.getenv("SERNIXA_INSTALLATION_FILE", "").strip()
57
+ if override:
58
+ return Path(override).expanduser()
59
+ return user_config_path("sernixa", appauthor=False) / "installation.json"
60
+
61
+
62
+ def load_or_create_installation_id() -> str:
63
+ """Return a stable local ID that the API immediately hashes with the org ID."""
64
+ path = installation_id_path()
65
+ if path.exists():
66
+ try:
67
+ payload = json.loads(path.read_text(encoding="utf-8"))
68
+ except (OSError, json.JSONDecodeError) as exc:
69
+ raise ConfigError(f"Installation identity file is unreadable: {path}") from exc
70
+ installation_id = (
71
+ str(payload.get("installation_id") or "") if isinstance(payload, dict) else ""
72
+ )
73
+ if 16 <= len(installation_id) <= 128:
74
+ return installation_id
75
+
76
+ installation_id = uuid4().hex
77
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
78
+ temporary = path.with_suffix(path.suffix + ".tmp")
79
+ temporary.write_text(
80
+ json.dumps({"installation_id": installation_id}, separators=(",", ":")) + "\n",
81
+ encoding="utf-8",
82
+ )
83
+ if os.name == "posix":
84
+ temporary.chmod(0o600)
85
+ temporary.replace(path)
86
+ if os.name == "posix":
87
+ path.chmod(0o600)
88
+ return installation_id
89
+
90
+
54
91
  def save_plan_session(evaluation: dict[str, object]) -> Path:
55
92
  token = evaluation.get("plan_token")
56
93
  proofs = evaluation.get("step_proofs")
@@ -34,6 +34,7 @@ from .config import (
34
34
  ConfigError,
35
35
  config_path,
36
36
  load_config,
37
+ load_or_create_installation_id,
37
38
  normalize_api_key,
38
39
  normalize_base_url,
39
40
  remove_config,
@@ -181,6 +182,44 @@ def _print_banner() -> None:
181
182
  typer.echo("Govern every agent action.")
182
183
 
183
184
 
185
+ def _platform_family() -> Literal["macos", "windows", "linux", "other"]:
186
+ value = platform.system().casefold()
187
+ if value == "darwin":
188
+ return "macos"
189
+ if value == "windows":
190
+ return "windows"
191
+ if value == "linux":
192
+ return "linux"
193
+ return "other"
194
+
195
+
196
+ def _observed_integrations() -> list[str]:
197
+ integrations: list[str] = []
198
+ for target, status in all_target_statuses().items():
199
+ if not (status.get("wrapper_valid") or status.get("sernixa_installed")):
200
+ continue
201
+ normalized = "claude" if target == "claude-desktop" else target
202
+ if normalized in {"codex", "claude", "cursor", "langchain", "mcp"}:
203
+ integrations.append(normalized)
204
+ return list(dict.fromkeys(integrations))
205
+
206
+
207
+ def _record_cli_installation(client: Client, *, integrations: list[str] | None = None) -> None:
208
+ """Best-effort telemetry must never make sign-in or setup fail."""
209
+ recorder = getattr(client, "record_installation", None)
210
+ if not callable(recorder):
211
+ return
212
+ try:
213
+ recorder(
214
+ installation_id=load_or_create_installation_id(),
215
+ version=__version__,
216
+ platform=_platform_family(),
217
+ integrations=integrations if integrations is not None else _observed_integrations(),
218
+ )
219
+ except (ConfigError, OSError, SernixaError):
220
+ return
221
+
222
+
184
223
  def _interactive_terminal() -> bool:
185
224
  return sys.stdin.isatty() and sys.stdout.isatty()
186
225
 
@@ -363,6 +402,51 @@ def _render_console_help() -> None:
363
402
  typer.echo(" @exit Close the session")
364
403
  typer.echo("Slash-prefixed forms remain supported for compatibility.")
365
404
  typer.echo("Any normal command also works, for example: validate request.json")
405
+ typer.echo("Run PowerShell and Python snippets in the host terminal, not at sernixa ›.")
406
+
407
+
408
+ def _console_input_guidance(raw: str) -> str | None:
409
+ """Explain common host-language pastes instead of reporting a false CLI error."""
410
+ stripped = raw.strip()
411
+ lowered = stripped.casefold()
412
+ if (
413
+ lowered.startswith(("$env:", "setx ", "set ", "export "))
414
+ or ".trim()" in lowered
415
+ ):
416
+ return (
417
+ "That is a host-shell environment command. Type @exit, run it in "
418
+ "PowerShell or your system terminal, then start `sernixa` again."
419
+ )
420
+ python_prefixes = (
421
+ "from ",
422
+ "import ",
423
+ "for ",
424
+ "while ",
425
+ "if ",
426
+ "def ",
427
+ "class ",
428
+ "print(",
429
+ "python ",
430
+ "py ",
431
+ )
432
+ looks_like_python_assignment = (
433
+ "=" in stripped
434
+ and not lowered.startswith(("@", "/", "sernixa "))
435
+ and any(
436
+ token in lowered
437
+ for token in ("client", "decision", "surface", "schema_version")
438
+ )
439
+ )
440
+ if (
441
+ lowered.startswith(python_prefixes)
442
+ or looks_like_python_assignment
443
+ or stripped.startswith(("{", "}", "[", "]", '"', "'"))
444
+ ):
445
+ return (
446
+ "That is Python code, not a Sernixa console command. Type @exit, "
447
+ "start `python` (or run a .py file), then execute the snippet there."
448
+ )
449
+ return None
366
450
 
367
451
 
368
452
  def _resolve_console_command(raw: str) -> tuple[str, list[str]]:
@@ -463,7 +547,10 @@ def _run_interactive_console(
463
547
  elif action == "already_open":
464
548
  typer.echo("The Sernixa console is already open.")
465
549
  elif action in {"invalid", "unknown"}:
466
- typer.echo("Unknown command. Type @help for available actions.")
550
+ typer.echo(
551
+ _console_input_guidance(raw)
552
+ or "Unknown command. Type @help for available actions."
553
+ )
467
554
  elif action == "execute":
468
555
  try:
469
556
  execute(argv)
@@ -840,6 +927,7 @@ def auth_login(
840
927
  expires_at=expires_at,
841
928
  )
842
929
  )
930
+ _record_cli_installation(client)
843
931
  except (ConfigError, SernixaError) as exc:
844
932
  _tool_error(str(exc))
845
933
  raise typer.Exit(2) from exc
@@ -1193,6 +1281,7 @@ def setup(
1193
1281
  "auth": {"configured": False, "valid": False},
1194
1282
  "host_tools": _host_tool_statuses(),
1195
1283
  }
1284
+ client: Client | None = None
1196
1285
  try:
1197
1286
  client, _ = _client()
1198
1287
  identity = client.whoami()
@@ -1256,6 +1345,15 @@ def setup(
1256
1345
  failed = True
1257
1346
  payload["launcher"] = {"status": "failed", "error": str(exc)}
1258
1347
  payload["healthy"] = not failed and bool(payload["auth"]["valid"])
1348
+ if not dry_run and payload["auth"]["valid"] and client is not None:
1349
+ _record_cli_installation(
1350
+ client,
1351
+ integrations=[
1352
+ "claude" if target == "claude-desktop" else target
1353
+ for target, result in payload["targets"].items()
1354
+ if result.get("status") not in {"manual_required", "failed"}
1355
+ ],
1356
+ )
1259
1357
  if json_output:
1260
1358
  typer.echo(json.dumps(payload, sort_keys=True))
1261
1359
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sernixa-cli
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Official Sernixa CLI for policy-governed local AI agent actions
5
5
  Author: Sernixa Team
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
  Requires-Dist: platformdirs>=4.0.0
23
23
  Requires-Dist: prompt-toolkit>=3.0.48
24
24
  Requires-Dist: rich>=13.8.0
25
- Requires-Dist: sernixa>=0.4.1
25
+ Requires-Dist: sernixa>=0.4.2
26
26
  Requires-Dist: sernixa-agent>=1.6.1
27
27
  Requires-Dist: typer>=0.12.0
28
28
 
@@ -1,6 +1,6 @@
1
1
  platformdirs>=4.0.0
2
2
  prompt-toolkit>=3.0.48
3
3
  rich>=13.8.0
4
- sernixa>=0.4.1
4
+ sernixa>=0.4.2
5
5
  sernixa-agent>=1.6.1
6
6
  typer>=0.12.0
@@ -18,7 +18,9 @@ from sernixa_cli.config import (
18
18
  CliConfig,
19
19
  ConfigError,
20
20
  config_path,
21
+ installation_id_path,
21
22
  load_config,
23
+ load_or_create_installation_id,
22
24
  normalize_api_key,
23
25
  save_config,
24
26
  )
@@ -29,6 +31,16 @@ from sernixa_cli.local_agent_targets import hook_snippet
29
31
  runner = CliRunner()
30
32
 
31
33
 
34
+ @pytest.fixture(autouse=True)
35
+ def _isolate_installation_identity(
36
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
37
+ ) -> None:
38
+ monkeypatch.setenv(
39
+ "SERNIXA_INSTALLATION_FILE",
40
+ str(tmp_path / "sernixa-installation.json"),
41
+ )
42
+
43
+
32
44
  def _configure(monkeypatch, tmp_path: Path) -> Path:
33
45
  path = tmp_path / "sernixa-config.json"
34
46
  monkeypatch.setenv("SERNIXA_CONFIG_FILE", str(path))
@@ -51,6 +63,23 @@ def test_config_round_trip_uses_private_permissions(
51
63
  assert path.stat().st_mode & 0o777 == 0o600
52
64
 
53
65
 
66
+ def test_installation_identity_is_stable_and_private(
67
+ monkeypatch: pytest.MonkeyPatch,
68
+ tmp_path: Path,
69
+ ) -> None:
70
+ path = tmp_path / "installation.json"
71
+ monkeypatch.setenv("SERNIXA_INSTALLATION_FILE", str(path))
72
+
73
+ first = load_or_create_installation_id()
74
+ second = load_or_create_installation_id()
75
+
76
+ assert first == second
77
+ assert len(first) == 32
78
+ assert installation_id_path() == path
79
+ if os.name == "posix":
80
+ assert path.stat().st_mode & 0o777 == 0o600
81
+
82
+
54
83
  def test_auth_login_validates_before_saving_and_config_show_hides_secret(
55
84
  monkeypatch,
56
85
  tmp_path: Path,
@@ -65,7 +94,10 @@ def test_auth_login_validates_before_saving_and_config_show_hides_secret(
65
94
  }
66
95
  client.current_plan.return_value = {"plan": {"id": "free", "name": "Free"}}
67
96
 
68
- with patch("sernixa_cli.main.Client", return_value=client):
97
+ with (
98
+ patch("sernixa_cli.main.Client", return_value=client),
99
+ patch("sernixa_cli.main._observed_integrations", return_value=["codex"]),
100
+ ):
69
101
  login = runner.invoke(
70
102
  app,
71
103
  ["auth", "login", "--base-url", "https://api.test", "--api-key-stdin"],
@@ -78,6 +110,11 @@ def test_auth_login_validates_before_saving_and_config_show_hides_secret(
78
110
  assert json.loads(path.read_text(encoding="utf-8"))["api_key"] == (
79
111
  "srnx_live_login_secret"
80
112
  )
113
+ client.record_installation.assert_called_once()
114
+ observation = client.record_installation.call_args.kwargs
115
+ assert observation["version"] == __version__
116
+ assert observation["integrations"] == ["codex"]
117
+ assert len(observation["installation_id"]) == 32
81
118
 
82
119
  shown = runner.invoke(app, ["config", "show"])
83
120
  assert shown.exit_code == 0
@@ -168,6 +205,33 @@ def test_auth_login_accepts_secure_stdin_for_powershell_clipboard(
168
205
  client.whoami.assert_called_once_with()
169
206
 
170
207
 
208
+ def test_auth_login_succeeds_when_telemetry_is_unavailable(
209
+ monkeypatch: pytest.MonkeyPatch,
210
+ tmp_path: Path,
211
+ ) -> None:
212
+ path = tmp_path / "login-config.json"
213
+ monkeypatch.setenv("SERNIXA_CONFIG_FILE", str(path))
214
+ client = MagicMock()
215
+ client.whoami.return_value = {"organization_id": "org_windows"}
216
+ client.current_plan.return_value = {"plan": {"name": "Free"}}
217
+ client.record_installation.side_effect = SernixaApiError("telemetry unavailable")
218
+
219
+ with patch("sernixa_cli.main.Client", return_value=client):
220
+ login = runner.invoke(
221
+ app,
222
+ ["auth", "login", "--api-key-stdin"],
223
+ input="srnx_live_windows_secret\n",
224
+ )
225
+
226
+ assert login.exit_code == 0
227
+ assert "Signed in · org_windows · Free" in login.stdout
228
+ assert "telemetry" not in login.stdout.casefold()
229
+
230
+
231
+ def test_installation_telemetry_is_compatible_with_an_older_sdk() -> None:
232
+ cli_main._record_cli_installation(SimpleNamespace())
233
+
234
+
171
235
  def test_auth_login_stdin_mode_refuses_to_wait_on_an_interactive_terminal(
172
236
  monkeypatch,
173
237
  tmp_path: Path,
@@ -659,6 +723,31 @@ def test_interactive_console_runs_multiple_real_commands_and_exits_cleanly(
659
723
  assert "Session closed." in output
660
724
 
661
725
 
726
+ def test_interactive_console_explains_windows_and_python_pastes(
727
+ monkeypatch, tmp_path: Path, capsys
728
+ ) -> None:
729
+ commands = iter(
730
+ [
731
+ '$env:SERNIXA_BASE_URL = "https://api.sernixa.com"',
732
+ "from sernixa import Client",
733
+ "client = Client()",
734
+ "@exit",
735
+ ]
736
+ )
737
+ monkeypatch.setenv("SERNIXA_HISTORY_FILE", str(tmp_path / "history"))
738
+
739
+ cli_main._run_interactive_console(
740
+ read_command=lambda _: next(commands),
741
+ execute=lambda argv: 0,
742
+ )
743
+
744
+ output = capsys.readouterr().out
745
+ assert "run it in PowerShell or your system terminal" in output
746
+ assert "That is Python code" in output
747
+ assert "Unknown command" not in output
748
+ assert "Session closed." in output
749
+
750
+
662
751
  def test_console_history_is_private_and_console_commands_are_discoverable(
663
752
  monkeypatch, tmp_path: Path
664
753
  ) -> None:
File without changes
File without changes