sernixa-cli 0.4.0__tar.gz → 0.4.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 (27) hide show
  1. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/PKG-INFO +42 -10
  2. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/README.md +39 -8
  3. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/pyproject.toml +3 -2
  4. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/__init__.py +1 -1
  5. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/config.py +86 -6
  6. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/discovery.py +1 -1
  7. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/hooks.py +8 -3
  8. sernixa_cli-0.4.2/sernixa_cli/lifecycle.py +167 -0
  9. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/local_agent_targets.py +298 -47
  10. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/main.py +264 -34
  11. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/mcp_server.py +5 -1
  12. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/native_launcher.py +7 -0
  13. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/PKG-INFO +42 -10
  14. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/SOURCES.txt +1 -0
  15. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/requires.txt +2 -1
  16. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_cli.py +308 -5
  17. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_local_agent_targets.py +30 -34
  18. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_native_launcher.py +64 -4
  19. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/__main__.py +0 -0
  20. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/adapters.py +0 -0
  21. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/formatting.py +0 -0
  22. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/dependency_links.txt +0 -0
  23. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/entry_points.txt +0 -0
  24. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/top_level.txt +0 -0
  25. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/setup.cfg +0 -0
  26. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_discovery.py +0 -0
  27. {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_mcp_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sernixa-cli
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Official Sernixa CLI for policy-governed local AI agent actions
5
5
  Author: Sernixa Team
6
6
  License-Expression: MIT
@@ -20,8 +20,9 @@ Classifier: Topic :: Security
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  Requires-Dist: platformdirs>=4.0.0
23
+ Requires-Dist: prompt-toolkit>=3.0.48
23
24
  Requires-Dist: rich>=13.8.0
24
- Requires-Dist: sernixa>=0.4.0
25
+ Requires-Dist: sernixa>=0.4.1
25
26
  Requires-Dist: sernixa-agent>=1.6.1
26
27
  Requires-Dist: typer>=0.12.0
27
28
 
@@ -31,14 +32,44 @@ The official terminal and local-agent integration for Sernixa governance. The CL
31
32
 
32
33
  ## Install and set up
33
34
 
35
+ macOS and Linux:
36
+
34
37
  ```bash
35
- pip install --upgrade sernixa-cli
38
+ python3 -m pip install --upgrade sernixa-cli
39
+ sernixa auth login
40
+ sernixa setup
41
+ sernixa doctor --strict
42
+ ```
43
+
44
+ Windows PowerShell:
45
+
46
+ ```powershell
47
+ py -m pip install --upgrade sernixa-cli
36
48
  sernixa auth login
37
49
  sernixa setup
38
50
  sernixa doctor --strict
39
51
  ```
40
52
 
41
- Then run `sernixa` by itself to open the persistent governance console:
53
+ `sernixa auth login` opens the Sernixa sign-in page, displays a short confirmation
54
+ code, and stores a revocable CLI session after approval. The session inherits the
55
+ signed-in user's organization, role, plan, and feature entitlements. Check the
56
+ active identity without exposing its credential:
57
+
58
+ ```bash
59
+ sernixa auth status
60
+ ```
61
+
62
+ Organization API keys remain available for CI and other non-interactive
63
+ automation. Send the key over stdin so it is not saved in command history:
64
+
65
+ ```powershell
66
+ Get-Clipboard | sernixa auth login --api-key-stdin
67
+ ```
68
+
69
+ The key is never passed as a command-line argument or printed.
70
+
71
+ Then run `sernixa` by itself to open the persistent governance console. Type
72
+ `@` to open the selectable command palette, use the arrow keys, and press Enter:
42
73
 
43
74
  ```text
44
75
  sernixa › @status
@@ -49,7 +80,7 @@ sernixa › @approvals
49
80
  sernixa › @exit
50
81
  ```
51
82
 
52
- The console is project-aware, remains open across commands, supports tab completion and private command history, and dispatches only Sernixa commands. `@` is the primary command-palette prefix; slash-prefixed forms remain compatible. Every normal command such as `validate request.json` also works inside it. Bare `sernixa` remains concise when stdout or stdin is not a terminal, so scripts and CI never hang; `sernixa shell` is the explicit interactive entry point.
83
+ The console is project-aware, remains open across commands, supports tab completion and private local history, and dispatches only Sernixa commands. The palette shows only actions available on the authenticated plan; direct commands are still checked by the backend. `@` is the primary command-palette prefix and slash-prefixed forms remain compatible. Every normal command such as `validate request.json` also works inside it. Bare `sernixa` remains concise when stdout or stdin is not a terminal, so scripts and CI never hang; `sernixa shell` is the explicit interactive entry point.
53
84
 
54
85
  `sernixa setup` configures Claude Code, Claude Desktop, Codex, and Cursor, preserves unrelated entries, backs up changed files, registers the bounded `sernixa://` dashboard launcher, and reports host restart or hook-trust steps. Preview without changing files:
55
86
 
@@ -66,7 +97,7 @@ source .venv-cli/bin/activate
66
97
  pip install -e packages/sernixa -e packages/sernixa-agent -e apps/cli
67
98
  ```
68
99
 
69
- The CLI defaults to `https://api.sernixa.com`. Stored credentials use the platform user-config directory and mode `0600` on Unix. `SERNIXA_API_KEY` and `SERNIXA_BASE_URL` override the stored configuration for non-interactive use.
100
+ The CLI defaults to `https://api.sernixa.com`. Stored credentials use the platform user-config directory and mode `0600` on Unix. `SERNIXA_ACCESS_TOKEN`, `SERNIXA_API_KEY`, and `SERNIXA_BASE_URL` override the stored configuration for non-interactive use.
70
101
 
71
102
  ## One-command Auto Discover
72
103
 
@@ -86,9 +117,9 @@ Discovery candidates are observations for review. Enrollment is inventory. An ac
86
117
 
87
118
  Supported global targets:
88
119
 
89
- - `claude`: Claude Code `PreToolUse` exec-form hook in `~/.claude/settings.json`.
120
+ - `claude`: Claude Code enforcement and lifecycle hooks in `~/.claude/settings.json`, plus the Sernixa MCP server in `~/.claude.json`.
90
121
  - `claude-desktop`: real stdio MCP server in the OS-specific Claude Desktop configuration.
91
- - `codex`: current `PreToolUse` JSON or array-of-tables TOML hook in `~/.codex`. Hooks are effective by default unless `[features].hooks = false`; users still review/trust new non-managed hooks in Codex.
122
+ - `codex`: enforcement and lifecycle hooks plus the Sernixa MCP server in `~/.codex`. Hooks are effective by default unless `[features].hooks = false`; users still review/trust new non-managed hooks in Codex.
92
123
  - `cursor`: global MCP entry in `~/.cursor/mcp.json`, shared by Cursor IDE and CLI.
93
124
 
94
125
  Install or repair an individual integration:
@@ -103,11 +134,11 @@ sernixa hook repair codex
103
134
  sernixa hook status
104
135
  ```
105
136
 
106
- Claude Code and Codex hooks call `sernixa hook run <provider>` before tool execution. `allow` continues through normal host permissions and `deny` blocks. Claude Code maps `review` to its supported human prompt. Current Codex `PreToolUse` does not support an `ask` result, so Sernixa maps `review` to a fail-closed deny with an explicit human-review reason instead of returning a hook shape that Codex would ignore. An API/configuration failure exits with code `2`, which both hosts treat as blocking; there is no local allow fallback.
137
+ Claude Code and Codex hooks call `sernixa hook run <provider>` before tool execution and record privacy-bounded session, tool, subagent, and compaction lifecycle evidence. `allow` continues through normal host permissions and `deny` blocks. Claude Code maps `review` to its supported human prompt. Current Codex `PreToolUse` does not support an `ask` result, so Sernixa maps `review` to a fail-closed deny with an explicit human-review reason instead of returning a hook shape that Codex would ignore. A policy-evaluation API or configuration failure exits with code `2`, which both hosts treat as blocking; there is no local allow fallback. Observational lifecycle delivery is silent and non-blocking because a telemetry outage after an action cannot safely reverse that action.
107
138
 
108
139
  Hook handlers allow 35 seconds for the live policy request; the CLI request budget is 30 seconds. This prevents slow semantic evaluations from being killed by a shorter host-hook timeout.
109
140
 
110
- Claude Desktop and Cursor run `sernixa mcp serve`. The stdio MCP server exposes three non-mutating tools:
141
+ Claude Code, Claude Desktop, Codex, and Cursor run `sernixa mcp serve`. The stdio MCP server exposes three non-mutating tools:
111
142
 
112
143
  - `sernixa_policy_check`: evaluate one proposed action.
113
144
  - `sernixa_plan_check`: evaluate a declared multi-step plan.
@@ -158,6 +189,7 @@ These commands call the authenticated organization endpoints and inherit backend
158
189
  ## Stable configuration overrides
159
190
 
160
191
  - `SERNIXA_CONFIG_FILE`
192
+ - `SERNIXA_ACCESS_TOKEN`
161
193
  - `SERNIXA_CLI_TIMEOUT_SECONDS`
162
194
  - `SERNIXA_PLAN_SESSION_FILE`
163
195
  - `SERNIXA_HISTORY_FILE`
@@ -4,14 +4,44 @@ The official terminal and local-agent integration for Sernixa governance. The CL
4
4
 
5
5
  ## Install and set up
6
6
 
7
+ macOS and Linux:
8
+
7
9
  ```bash
8
- pip install --upgrade sernixa-cli
10
+ python3 -m pip install --upgrade sernixa-cli
11
+ sernixa auth login
12
+ sernixa setup
13
+ sernixa doctor --strict
14
+ ```
15
+
16
+ Windows PowerShell:
17
+
18
+ ```powershell
19
+ py -m pip install --upgrade sernixa-cli
9
20
  sernixa auth login
10
21
  sernixa setup
11
22
  sernixa doctor --strict
12
23
  ```
13
24
 
14
- Then run `sernixa` by itself to open the persistent governance console:
25
+ `sernixa auth login` opens the Sernixa sign-in page, displays a short confirmation
26
+ code, and stores a revocable CLI session after approval. The session inherits the
27
+ signed-in user's organization, role, plan, and feature entitlements. Check the
28
+ active identity without exposing its credential:
29
+
30
+ ```bash
31
+ sernixa auth status
32
+ ```
33
+
34
+ Organization API keys remain available for CI and other non-interactive
35
+ automation. Send the key over stdin so it is not saved in command history:
36
+
37
+ ```powershell
38
+ Get-Clipboard | sernixa auth login --api-key-stdin
39
+ ```
40
+
41
+ The key is never passed as a command-line argument or printed.
42
+
43
+ Then run `sernixa` by itself to open the persistent governance console. Type
44
+ `@` to open the selectable command palette, use the arrow keys, and press Enter:
15
45
 
16
46
  ```text
17
47
  sernixa › @status
@@ -22,7 +52,7 @@ sernixa › @approvals
22
52
  sernixa › @exit
23
53
  ```
24
54
 
25
- The console is project-aware, remains open across commands, supports tab completion and private command history, and dispatches only Sernixa commands. `@` is the primary command-palette prefix; slash-prefixed forms remain compatible. Every normal command such as `validate request.json` also works inside it. Bare `sernixa` remains concise when stdout or stdin is not a terminal, so scripts and CI never hang; `sernixa shell` is the explicit interactive entry point.
55
+ The console is project-aware, remains open across commands, supports tab completion and private local history, and dispatches only Sernixa commands. The palette shows only actions available on the authenticated plan; direct commands are still checked by the backend. `@` is the primary command-palette prefix and slash-prefixed forms remain compatible. Every normal command such as `validate request.json` also works inside it. Bare `sernixa` remains concise when stdout or stdin is not a terminal, so scripts and CI never hang; `sernixa shell` is the explicit interactive entry point.
26
56
 
27
57
  `sernixa setup` configures Claude Code, Claude Desktop, Codex, and Cursor, preserves unrelated entries, backs up changed files, registers the bounded `sernixa://` dashboard launcher, and reports host restart or hook-trust steps. Preview without changing files:
28
58
 
@@ -39,7 +69,7 @@ source .venv-cli/bin/activate
39
69
  pip install -e packages/sernixa -e packages/sernixa-agent -e apps/cli
40
70
  ```
41
71
 
42
- The CLI defaults to `https://api.sernixa.com`. Stored credentials use the platform user-config directory and mode `0600` on Unix. `SERNIXA_API_KEY` and `SERNIXA_BASE_URL` override the stored configuration for non-interactive use.
72
+ The CLI defaults to `https://api.sernixa.com`. Stored credentials use the platform user-config directory and mode `0600` on Unix. `SERNIXA_ACCESS_TOKEN`, `SERNIXA_API_KEY`, and `SERNIXA_BASE_URL` override the stored configuration for non-interactive use.
43
73
 
44
74
  ## One-command Auto Discover
45
75
 
@@ -59,9 +89,9 @@ Discovery candidates are observations for review. Enrollment is inventory. An ac
59
89
 
60
90
  Supported global targets:
61
91
 
62
- - `claude`: Claude Code `PreToolUse` exec-form hook in `~/.claude/settings.json`.
92
+ - `claude`: Claude Code enforcement and lifecycle hooks in `~/.claude/settings.json`, plus the Sernixa MCP server in `~/.claude.json`.
63
93
  - `claude-desktop`: real stdio MCP server in the OS-specific Claude Desktop configuration.
64
- - `codex`: current `PreToolUse` JSON or array-of-tables TOML hook in `~/.codex`. Hooks are effective by default unless `[features].hooks = false`; users still review/trust new non-managed hooks in Codex.
94
+ - `codex`: enforcement and lifecycle hooks plus the Sernixa MCP server in `~/.codex`. Hooks are effective by default unless `[features].hooks = false`; users still review/trust new non-managed hooks in Codex.
65
95
  - `cursor`: global MCP entry in `~/.cursor/mcp.json`, shared by Cursor IDE and CLI.
66
96
 
67
97
  Install or repair an individual integration:
@@ -76,11 +106,11 @@ sernixa hook repair codex
76
106
  sernixa hook status
77
107
  ```
78
108
 
79
- Claude Code and Codex hooks call `sernixa hook run <provider>` before tool execution. `allow` continues through normal host permissions and `deny` blocks. Claude Code maps `review` to its supported human prompt. Current Codex `PreToolUse` does not support an `ask` result, so Sernixa maps `review` to a fail-closed deny with an explicit human-review reason instead of returning a hook shape that Codex would ignore. An API/configuration failure exits with code `2`, which both hosts treat as blocking; there is no local allow fallback.
109
+ Claude Code and Codex hooks call `sernixa hook run <provider>` before tool execution and record privacy-bounded session, tool, subagent, and compaction lifecycle evidence. `allow` continues through normal host permissions and `deny` blocks. Claude Code maps `review` to its supported human prompt. Current Codex `PreToolUse` does not support an `ask` result, so Sernixa maps `review` to a fail-closed deny with an explicit human-review reason instead of returning a hook shape that Codex would ignore. A policy-evaluation API or configuration failure exits with code `2`, which both hosts treat as blocking; there is no local allow fallback. Observational lifecycle delivery is silent and non-blocking because a telemetry outage after an action cannot safely reverse that action.
80
110
 
81
111
  Hook handlers allow 35 seconds for the live policy request; the CLI request budget is 30 seconds. This prevents slow semantic evaluations from being killed by a shorter host-hook timeout.
82
112
 
83
- Claude Desktop and Cursor run `sernixa mcp serve`. The stdio MCP server exposes three non-mutating tools:
113
+ Claude Code, Claude Desktop, Codex, and Cursor run `sernixa mcp serve`. The stdio MCP server exposes three non-mutating tools:
84
114
 
85
115
  - `sernixa_policy_check`: evaluate one proposed action.
86
116
  - `sernixa_plan_check`: evaluate a declared multi-step plan.
@@ -131,6 +161,7 @@ These commands call the authenticated organization endpoints and inherit backend
131
161
  ## Stable configuration overrides
132
162
 
133
163
  - `SERNIXA_CONFIG_FILE`
164
+ - `SERNIXA_ACCESS_TOKEN`
134
165
  - `SERNIXA_CLI_TIMEOUT_SECONDS`
135
166
  - `SERNIXA_PLAN_SESSION_FILE`
136
167
  - `SERNIXA_HISTORY_FILE`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sernixa-cli"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Official Sernixa CLI for policy-governed local AI agent actions"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -19,8 +19,9 @@ classifiers = [
19
19
  ]
20
20
  dependencies = [
21
21
  "platformdirs>=4.0.0",
22
+ "prompt-toolkit>=3.0.48",
22
23
  "rich>=13.8.0",
23
- "sernixa>=0.4.0",
24
+ "sernixa>=0.4.1",
24
25
  "sernixa-agent>=1.6.1",
25
26
  "typer>=0.12.0",
26
27
  ]
@@ -1,3 +1,3 @@
1
1
  """Official Sernixa command-line client."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.2"
@@ -2,12 +2,22 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import unicodedata
5
6
  from dataclasses import asdict, dataclass
6
7
  from pathlib import Path
8
+ from typing import Literal
7
9
 
8
10
  from platformdirs import user_config_path
9
11
 
10
12
  DEFAULT_BASE_URL = "https://api.sernixa.com"
13
+ API_KEY_PREFIX = "srnx_live_"
14
+
15
+ _PASTE_QUOTE_PAIRS = {
16
+ '"': '"',
17
+ "'": "'",
18
+ "“": "”",
19
+ "‘": "’",
20
+ }
11
21
 
12
22
 
13
23
  class ConfigError(RuntimeError):
@@ -16,8 +26,15 @@ class ConfigError(RuntimeError):
16
26
 
17
27
  @dataclass(frozen=True)
18
28
  class CliConfig:
19
- api_key: str
29
+ api_key: str = ""
20
30
  base_url: str = DEFAULT_BASE_URL
31
+ access_token: str = ""
32
+ auth_type: Literal["none", "api_key", "user_session"] = "none"
33
+ expires_at: str | None = None
34
+
35
+ @property
36
+ def credential(self) -> str:
37
+ return self.access_token or self.api_key
21
38
 
22
39
 
23
40
  def config_path() -> Path:
@@ -81,11 +98,60 @@ def normalize_base_url(value: str) -> str:
81
98
  return normalized
82
99
 
83
100
 
101
+ def _strip_paste_boundaries(value: str) -> str:
102
+ """Remove only whitespace and invisible/control characters at paste edges."""
103
+ start = 0
104
+ end = len(value)
105
+ while start < end:
106
+ character = value[start]
107
+ if not character.isspace() and unicodedata.category(character) not in {
108
+ "Cc",
109
+ "Cf",
110
+ }:
111
+ break
112
+ start += 1
113
+ while end > start:
114
+ character = value[end - 1]
115
+ if not character.isspace() and unicodedata.category(character) not in {
116
+ "Cc",
117
+ "Cf",
118
+ }:
119
+ break
120
+ end -= 1
121
+ return value[start:end]
122
+
123
+
124
+ def normalize_api_key(value: str) -> str:
125
+ """Normalize harmless clipboard wrappers without changing the secret itself."""
126
+ normalized = _strip_paste_boundaries(value)
127
+ closing_quote = _PASTE_QUOTE_PAIRS.get(normalized[:1])
128
+ if closing_quote and normalized.endswith(closing_quote) and len(normalized) >= 2:
129
+ normalized = _strip_paste_boundaries(normalized[1:-1])
130
+ if not normalized:
131
+ raise ConfigError(
132
+ "No API key text was received. In Windows Terminal, paste with right-click "
133
+ "or Ctrl+Shift+V, or run `Get-Clipboard | sernixa auth login --api-key-stdin`."
134
+ )
135
+ if not normalized.startswith(API_KEY_PREFIX):
136
+ raise ConfigError(
137
+ "Invalid Sernixa API key. Copy the complete one-time key from Team Settings; "
138
+ f"it must start with {API_KEY_PREFIX}."
139
+ )
140
+ return normalized
141
+
142
+
84
143
  def save_config(config: CliConfig) -> Path:
85
144
  path = config_path()
86
145
  path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
87
146
  temporary = path.with_suffix(".tmp")
88
- temporary.write_text(json.dumps(asdict(config), indent=2) + "\n", encoding="utf-8")
147
+ payload = asdict(config)
148
+ if not payload["access_token"]:
149
+ payload.pop("access_token")
150
+ if payload["expires_at"] is None:
151
+ payload.pop("expires_at")
152
+ if payload["auth_type"] == "none":
153
+ payload.pop("auth_type")
154
+ temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
89
155
  if os.name == "posix":
90
156
  temporary.chmod(0o600)
91
157
  temporary.replace(path)
@@ -98,6 +164,7 @@ def load_config(
98
164
  *, require_key: bool = True, base_url_override: str | None = None
99
165
  ) -> CliConfig:
100
166
  env_key = os.getenv("SERNIXA_API_KEY", "").strip()
167
+ env_access_token = os.getenv("SERNIXA_ACCESS_TOKEN", "").strip()
101
168
  env_base_url = os.getenv("SERNIXA_BASE_URL", "").strip()
102
169
  stored: dict[str, object] = {}
103
170
  path = config_path()
@@ -110,15 +177,28 @@ def load_config(
110
177
  raise ConfigError(f"Configuration file must contain a JSON object: {path}")
111
178
  stored = parsed
112
179
 
113
- api_key = env_key or str(stored.get("api_key") or "").strip()
180
+ raw_api_key = env_key or str(stored.get("api_key") or "")
181
+ api_key = normalize_api_key(raw_api_key) if raw_api_key else ""
182
+ access_token = env_access_token or str(stored.get("access_token") or "").strip()
114
183
  base_url = normalize_base_url(
115
184
  base_url_override
116
185
  or env_base_url
117
186
  or str(stored.get("base_url") or DEFAULT_BASE_URL)
118
187
  )
119
- if require_key and not api_key:
120
- raise ConfigError("No API key configured. Run `sernixa auth login` first.")
121
- return CliConfig(api_key=api_key, base_url=base_url)
188
+ if require_key and not (access_token or api_key):
189
+ raise ConfigError(
190
+ "No Sernixa credential configured. Run `sernixa auth login` first."
191
+ )
192
+ auth_type: Literal["none", "api_key", "user_session"] = (
193
+ "user_session" if access_token else "api_key" if api_key else "none"
194
+ )
195
+ return CliConfig(
196
+ api_key=api_key,
197
+ access_token=access_token,
198
+ base_url=base_url,
199
+ auth_type=auth_type,
200
+ expires_at=str(stored.get("expires_at")) if stored.get("expires_at") else None,
201
+ )
122
202
 
123
203
 
124
204
  def remove_config() -> bool:
@@ -55,7 +55,7 @@ def _ensure_agent_ready(
55
55
  agent_config_path: Path,
56
56
  ) -> tuple[str, dict[str, Any], bool, dict[str, str]]:
57
57
  executable = _agent_executable()
58
- agent_env = {"SERNIXA_DISCOVERY_TOKEN": config.api_key}
58
+ agent_env = {"SERNIXA_DISCOVERY_TOKEN": config.credential}
59
59
  initialized = False
60
60
  if not agent_config_path.exists():
61
61
  _run_agent(
@@ -28,10 +28,15 @@ class HookInstallResult:
28
28
  status: str
29
29
  path: Path
30
30
  created: bool
31
- backup_path: Path | None = None
31
+ backup_paths: tuple[Path, ...] = ()
32
32
  error: str | None = None
33
33
  manual_steps: list[str] | None = None
34
34
 
35
+ @property
36
+ def backup_path(self) -> Path | None:
37
+ """Return the first backup for compatibility with older callers."""
38
+ return self.backup_paths[0] if self.backup_paths else None
39
+
35
40
 
36
41
  @dataclass(frozen=True)
37
42
  class HookRepairResult:
@@ -61,7 +66,7 @@ def install_hook(target: str) -> HookInstallResult:
61
66
  try:
62
67
  resolution = resolve_local_agent_target(target)
63
68
  existed_before = resolution.selected_path.exists()
64
- resolution, status, backup_path = install_target(target)
69
+ resolution, status, backup_paths = install_target(target)
65
70
  except HookInstallError as exc:
66
71
  resolution = resolve_local_agent_target(target)
67
72
  return HookInstallResult(
@@ -79,7 +84,7 @@ def install_hook(target: str) -> HookInstallResult:
79
84
  status=status,
80
85
  path=resolution.selected_path,
81
86
  created=not existed_before,
82
- backup_path=backup_path,
87
+ backup_paths=backup_paths,
83
88
  )
84
89
 
85
90
 
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import UTC, datetime
6
+ from typing import Any, Literal
7
+
8
+
9
+ LifecycleProvider = Literal["claude", "codex"]
10
+ LIFECYCLE_EVENTS = {
11
+ "SessionStart",
12
+ "UserPromptSubmit",
13
+ "PostToolUse",
14
+ "PostToolUseFailure",
15
+ "SubagentStart",
16
+ "SubagentStop",
17
+ "PreCompact",
18
+ "PostCompact",
19
+ "Stop",
20
+ "SessionEnd",
21
+ }
22
+
23
+
24
+ class LifecycleEventError(ValueError):
25
+ pass
26
+
27
+
28
+ def parse_hook_event(content: str) -> dict[str, Any]:
29
+ stripped = content.strip()
30
+ if not stripped:
31
+ raise LifecycleEventError("Hook payload is empty.")
32
+ try:
33
+ parsed = json.loads(stripped)
34
+ except json.JSONDecodeError:
35
+ objects: list[dict[str, Any]] = []
36
+ for line_number, line in enumerate(content.splitlines(), start=1):
37
+ if not line.strip():
38
+ continue
39
+ try:
40
+ item = json.loads(line)
41
+ except json.JSONDecodeError as exc:
42
+ raise LifecycleEventError(
43
+ f"Invalid hook JSON on line {line_number}."
44
+ ) from exc
45
+ if isinstance(item, dict):
46
+ objects.append(item)
47
+ if not objects:
48
+ raise LifecycleEventError("Hook payload must contain a JSON object.")
49
+ return objects[-1]
50
+ if not isinstance(parsed, dict):
51
+ raise LifecycleEventError("Hook payload must be a JSON object.")
52
+ return parsed
53
+
54
+
55
+ def lifecycle_payload(
56
+ provider: LifecycleProvider,
57
+ event: dict[str, Any],
58
+ *,
59
+ now: datetime | None = None,
60
+ ) -> dict[str, Any]:
61
+ event_name = str(event.get("hook_event_name") or "")
62
+ if event_name not in LIFECYCLE_EVENTS:
63
+ raise LifecycleEventError(
64
+ f"Unsupported lifecycle event: {event_name or 'missing'}."
65
+ )
66
+ session_id = str(event.get("session_id") or "").strip()
67
+ if not session_id:
68
+ raise LifecycleEventError("Lifecycle hook payload is missing session_id.")
69
+
70
+ occurred_at = (now or datetime.now(UTC)).isoformat().replace("+00:00", "Z")
71
+ prompt_digest, prompt_bytes = _digest(event.get("prompt"))
72
+ input_digest, input_bytes = _digest(event.get("tool_input"))
73
+ output_digest, output_bytes = _digest(
74
+ _first_present(event, "tool_response", "tool_output", "output")
75
+ )
76
+ error_digest, error_bytes = _digest(
77
+ _first_present(event, "error", "error_message", "stderr")
78
+ )
79
+ status = _status(event_name)
80
+ payload: dict[str, Any] = {
81
+ "provider": provider,
82
+ "event_name": event_name,
83
+ "occurred_at": occurred_at,
84
+ "session_id": session_id,
85
+ "turn_id": _bounded_string(event.get("turn_id"), 256),
86
+ "tool_use_id": _bounded_string(event.get("tool_use_id"), 256),
87
+ "model": _bounded_string(event.get("model"), 256),
88
+ "tool_name": _bounded_string(event.get("tool_name"), 256),
89
+ "status": status,
90
+ "cwd_digest": _digest(event.get("cwd"))[0],
91
+ "prompt_digest": prompt_digest,
92
+ "prompt_bytes": prompt_bytes,
93
+ "input_digest": input_digest,
94
+ "input_bytes": input_bytes,
95
+ "output_digest": output_digest,
96
+ "output_bytes": output_bytes,
97
+ "error_digest": error_digest,
98
+ "error_bytes": error_bytes,
99
+ "start_source": _allowlisted(
100
+ event.get("source"), {"startup", "resume", "clear", "compact", "fork"}
101
+ ),
102
+ "stop_reason": _allowlisted(
103
+ event.get("reason"),
104
+ {
105
+ "completed",
106
+ "error",
107
+ "cancelled",
108
+ "clear",
109
+ "resume",
110
+ "logout",
111
+ "prompt_input_exit",
112
+ "bypass_permissions_disabled",
113
+ "other",
114
+ },
115
+ ),
116
+ "subagent_id": _bounded_string(
117
+ _first_present(event, "agent_id", "subagent_id"), 256
118
+ ),
119
+ "subagent_type": _bounded_string(
120
+ _first_present(event, "agent_type", "subagent_type"), 256
121
+ ),
122
+ "compact_trigger": _allowlisted(
123
+ _first_present(event, "trigger", "compact_trigger"), {"manual", "auto"}
124
+ ),
125
+ }
126
+ payload = {key: value for key, value in payload.items() if value is not None}
127
+ source = json.dumps(payload, sort_keys=True, separators=(",", ":"))
128
+ payload["source_event_id"] = hashlib.sha256(source.encode("utf-8")).hexdigest()
129
+ return payload
130
+
131
+
132
+ def _status(event_name: str) -> str:
133
+ if event_name in {"SessionStart", "SubagentStart"}:
134
+ return "started"
135
+ if event_name == "PostToolUse":
136
+ return "succeeded"
137
+ if event_name == "PostToolUseFailure":
138
+ return "failed"
139
+ if event_name in {"Stop", "SessionEnd", "SubagentStop"}:
140
+ return "stopped"
141
+ return "observed"
142
+
143
+
144
+ def _digest(value: Any) -> tuple[str | None, int | None]:
145
+ if value is None:
146
+ return None, None
147
+ serialized = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
148
+ encoded = serialized.encode("utf-8")
149
+ return f"sha256:{hashlib.sha256(encoded).hexdigest()}", len(encoded)
150
+
151
+
152
+ def _first_present(event: dict[str, Any], *keys: str) -> Any:
153
+ for key in keys:
154
+ if event.get(key) is not None:
155
+ return event[key]
156
+ return None
157
+
158
+
159
+ def _bounded_string(value: Any, limit: int) -> str | None:
160
+ if value in (None, ""):
161
+ return None
162
+ return str(value)[:limit]
163
+
164
+
165
+ def _allowlisted(value: Any, allowed: set[str]) -> str | None:
166
+ normalized = str(value or "").strip().casefold()
167
+ return normalized if normalized in allowed else None