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.
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/PKG-INFO +42 -10
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/README.md +39 -8
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/pyproject.toml +3 -2
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/__init__.py +1 -1
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/config.py +86 -6
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/discovery.py +1 -1
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/hooks.py +8 -3
- sernixa_cli-0.4.2/sernixa_cli/lifecycle.py +167 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/local_agent_targets.py +298 -47
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/main.py +264 -34
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/mcp_server.py +5 -1
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/native_launcher.py +7 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/PKG-INFO +42 -10
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/SOURCES.txt +1 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/requires.txt +2 -1
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_cli.py +308 -5
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_local_agent_targets.py +30 -34
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_native_launcher.py +64 -4
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/__main__.py +0 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/adapters.py +0 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli/formatting.py +0 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/dependency_links.txt +0 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/entry_points.txt +0 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/sernixa_cli.egg-info/top_level.txt +0 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/setup.cfg +0 -0
- {sernixa_cli-0.4.0 → sernixa_cli-0.4.2}/tests/test_discovery.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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`:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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`:
|
|
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.
|
|
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.
|
|
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.
|
|
24
|
+
"sernixa>=0.4.1",
|
|
24
25
|
"sernixa-agent>=1.6.1",
|
|
25
26
|
"typer>=0.12.0",
|
|
26
27
|
]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
121
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|