kyvvu-cli 1.0.0__py3-none-any.whl

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.
kyvvu_cli/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Kyvvu B.V.
3
+
4
+ """Kyvvu CLI — command-line interface for the Kyvvu SDK."""
kyvvu_cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Kyvvu B.V.
3
+
4
+ """Allow ``python -m kyvvu`` to invoke the CLI."""
5
+ from kyvvu_cli.main import app
6
+
7
+ app()
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,19 @@
1
+ {
2
+ "commit": "c6dc2dcb8d96d4842ed989267301bfdcb3cfa139",
3
+ "versions": {
4
+ "API_VERSION": "1.0.0",
5
+ "BASELINE_VERSION": "1.0.0",
6
+ "CLAUDE_VERSION": "1.0.0",
7
+ "CLI_VERSION": "1.0.0",
8
+ "CREWAI_VERSION": "1.0.0",
9
+ "ENGINE_VERSION": "1.0.0",
10
+ "LANGCHAIN_VERSION": "1.0.0",
11
+ "MIN_ENGINE_VERSION": "0.13.0",
12
+ "MIN_POLICY_SCHEMA": "1",
13
+ "NEXT_MIN_ENGINE_DATE": "",
14
+ "NEXT_MIN_ENGINE_VERSION": "",
15
+ "SDK_VERSION": "1.0.0",
16
+ "TEMPLATES_VERSION": "1.0.0",
17
+ "WEB_VERSION": "1.0.0"
18
+ }
19
+ }
kyvvu_cli/agents.py ADDED
@@ -0,0 +1,130 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Kyvvu B.V.
3
+
4
+ """Agent inspection command: list-agents, and agent key -> id resolution."""
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from typing import Any, Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from kyvvu_cli.client import api_get, require_cli_pat
15
+ from kyvvu_cli.config import get_api_url
16
+
17
+ console = Console()
18
+
19
+ # The platform's agent ids: ``agent_`` plus hex (``agents/service.py``).
20
+ _AGENT_ID_RE = re.compile(r"agent_[0-9a-f]+")
21
+
22
+
23
+ def _fetch_agents(
24
+ api_key: str,
25
+ api_url: str,
26
+ *,
27
+ environment: Optional[str] = None,
28
+ include_inactive: bool = False,
29
+ ) -> list[Any]:
30
+ """Return the workspace's agents from ``GET /api/v1/agents``.
31
+
32
+ The platform filters out deactivated agents unless ``include_inactive=true``
33
+ is sent, so the filtering is the server's, not ours (#807).
34
+ """
35
+ params: dict[str, str] = {"limit": "1000"}
36
+ if environment:
37
+ params["environment"] = environment
38
+ if include_inactive:
39
+ params["include_inactive"] = "true"
40
+ agents = api_get("/api/v1/agents", params=params, api_key=api_key, api_url=api_url)
41
+ if isinstance(agents, dict) and "items" in agents:
42
+ agents = agents["items"]
43
+ if not isinstance(agents, list):
44
+ agents = [agents] if agents else []
45
+ return agents
46
+
47
+
48
+ def resolve_agent_id(value: str, *, api_key: str, api_url: str) -> str:
49
+ """Return the platform ``agent_id`` for *value*, an agent id or an agent key.
50
+
51
+ ``--agent-id`` used to take only the platform-assigned ``agent_...`` id,
52
+ and a key was answered with ``404 Agent not found`` (#795). A value shaped
53
+ like an id is passed through untouched; anything else is looked up among
54
+ the workspace's active agents by ``agent_key``, in the one list call
55
+ ``list-agents`` makes.
56
+
57
+ Args:
58
+ value: What the user passed to ``--agent-id``.
59
+ api_key: The CLI PAT.
60
+ api_url: The platform URL.
61
+
62
+ Returns:
63
+ The agent's ``agent_id``.
64
+
65
+ Raises:
66
+ typer.Exit: With status 1 when no agent, or more than one, has that key.
67
+ """
68
+ if _AGENT_ID_RE.fullmatch(value):
69
+ return value
70
+ matches = [
71
+ a for a in _fetch_agents(api_key, api_url)
72
+ if a.get("agent_key") == value
73
+ ]
74
+ ids = [str(a.get("agent_id", a.get("id", ""))) for a in matches]
75
+ if len(ids) == 1:
76
+ return ids[0]
77
+ if not ids:
78
+ typer.echo(
79
+ f"No active agent with id or key '{value}' in this workspace. "
80
+ "Run `kyvvu list-agents` to see them.",
81
+ err=True,
82
+ )
83
+ else:
84
+ typer.echo(
85
+ f"More than one agent has the key '{value}' ({', '.join(ids)}). "
86
+ "Pass the agent id instead.",
87
+ err=True,
88
+ )
89
+ raise typer.Exit(1)
90
+
91
+
92
+ def list_agents(
93
+ environment: Optional[str] = None,
94
+ include_inactive: bool = False,
95
+ ) -> None:
96
+ """List registered agents.
97
+
98
+ Args:
99
+ environment: Only agents in this environment.
100
+ include_inactive: Also list deactivated agents. Sent to the platform
101
+ as ``include_inactive=true``; it never returns them otherwise.
102
+ """
103
+ api_key = require_cli_pat()
104
+ api_url = get_api_url()
105
+
106
+ agents = _fetch_agents(
107
+ api_key, api_url, environment=environment, include_inactive=include_inactive
108
+ )
109
+
110
+ if not agents:
111
+ typer.echo("No agents found.")
112
+ return
113
+
114
+ table = Table(show_header=True, header_style="bold")
115
+ table.add_column("AGENT_KEY")
116
+ table.add_column("AGENT_ID")
117
+ table.add_column("NAME")
118
+ table.add_column("ENVIRONMENT")
119
+ table.add_column("ACTIVE")
120
+
121
+ for a in agents:
122
+ table.add_row(
123
+ a.get("agent_key", ""),
124
+ a.get("agent_id", a.get("id", "")),
125
+ a.get("name", ""),
126
+ a.get("environment", ""),
127
+ "\u2713" if a.get("active", True) else "\u2717",
128
+ )
129
+
130
+ console.print(table)
kyvvu_cli/auth.py ADDED
@@ -0,0 +1,314 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Kyvvu B.V.
3
+
4
+ """Authentication commands: auth, logout, whoami."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import socket
10
+ import sys
11
+ import threading
12
+ import time
13
+ import webbrowser
14
+ from typing import Optional
15
+
16
+ import typer
17
+
18
+ import httpx
19
+
20
+ from kyvvu_cli.client import api_delete, api_get, api_post, require_cli_pat
21
+ from kyvvu_cli.config import (
22
+ clear_cli_auth,
23
+ get_api_url,
24
+ get_cli_auth,
25
+ save_cli_auth,
26
+ )
27
+
28
+
29
+ def _print_waiting_message(email: str) -> None:
30
+ """Explain the ONE remaining reason an account cannot act yet.
31
+
32
+ This used to say an administrator at your organisation had to approve
33
+ you, which was true while an email domain founded or joined an
34
+ organisation and everyone after the first person queued. Nothing arrives
35
+ unapproved now; the registration cap is what is left, it is a property of
36
+ the PLATFORM rather than of anybody's organisation, and only Kyvvu staff
37
+ can release it. Telling somebody to ask their administrator would send
38
+ them to a person who cannot help.
39
+ """
40
+ typer.echo(
41
+ f"\n{email} is waiting to be admitted.\n"
42
+ f"Kyvvu is at its registration limit, so new accounts are released by "
43
+ f"Kyvvu rather than by anyone at your organization.\n"
44
+ f"Run `kyvvu whoami` to check."
45
+ )
46
+
47
+
48
+ def _device_label(device_label: Optional[str]) -> str:
49
+ if device_label:
50
+ return device_label
51
+ try:
52
+ hostname = socket.gethostname()
53
+ except Exception:
54
+ hostname = ""
55
+ return hostname or "CLI"
56
+
57
+
58
+ def _open_browser(url: str) -> None:
59
+ # Run off the main thread: webbrowser.open() is not guaranteed to
60
+ # return immediately (some configured browsers, e.g. text-mode ones,
61
+ # run synchronously until the user exits them), and blocking here would
62
+ # delay entering the polling loop below.
63
+ try:
64
+ webbrowser.open(url)
65
+ except Exception: # noqa: S110 - best effort operation must not affect the primary result
66
+ pass
67
+
68
+
69
+ def auth(api_url: Optional[str] = None, device_label: Optional[str] = None) -> None:
70
+ """Log in via the device-code flow: print a code, wait for browser approval."""
71
+ url = api_url or get_api_url()
72
+
73
+ start = api_post(
74
+ "/api/v1/auth/cli/start",
75
+ json={"device_label": _device_label(device_label)},
76
+ api_url=url,
77
+ )
78
+
79
+ device_code = start["device_code"]
80
+ user_code = start["user_code"]
81
+ verification_uri_complete = start["verification_uri_complete"]
82
+ interval = int(start.get("interval", 5))
83
+ expires_in = int(start.get("expires_in", 900))
84
+
85
+ threading.Thread(
86
+ target=_open_browser, args=(verification_uri_complete,), daemon=True
87
+ ).start()
88
+
89
+ typer.echo("Opening your browser to approve this login...")
90
+ typer.echo(f"If it doesn't open, visit this URL manually: {verification_uri_complete}")
91
+ typer.echo(f"(Code: {user_code})")
92
+ typer.echo("")
93
+ typer.echo("Waiting for you to approve...")
94
+ sys.stdout.flush()
95
+
96
+ deadline = time.monotonic() + expires_in
97
+
98
+ while True:
99
+ if time.monotonic() >= deadline:
100
+ typer.echo("This code has expired. Run `kyvvu auth` again.", err=True)
101
+ raise typer.Exit(1)
102
+
103
+ time.sleep(interval)
104
+
105
+ try:
106
+ with httpx.Client(base_url=url, timeout=15.0) as client:
107
+ resp = client.post("/api/v1/auth/cli/token", json={"device_code": device_code})
108
+ except (httpx.ConnectError, httpx.TimeoutException):
109
+ typer.echo(f"Could not reach {url}. Check your network connection.", err=True)
110
+ raise typer.Exit(1)
111
+
112
+ if resp.status_code == 200:
113
+ data = resp.json()
114
+ access_token = data.get("access_token", "")
115
+ break
116
+
117
+ try:
118
+ error = resp.json().get("error")
119
+ except Exception:
120
+ error = None
121
+
122
+ if error == "authorization_pending":
123
+ continue
124
+ elif error == "slow_down":
125
+ # RFC 8628 §3.5: server is throttling us -- back off by 5s and
126
+ # keep polling instead of treating this as a fatal error.
127
+ interval += 5
128
+ continue
129
+ elif error == "access_denied":
130
+ typer.echo("Login was denied.", err=True)
131
+ raise typer.Exit(1)
132
+ elif error == "expired_token":
133
+ typer.echo("This code has expired. Run `kyvvu auth` again.", err=True)
134
+ raise typer.Exit(1)
135
+ else:
136
+ detail = error or resp.text
137
+ typer.echo(f"Login failed: {detail}", err=True)
138
+ raise typer.Exit(1)
139
+
140
+ save_cli_auth(api_key=access_token, api_url=url)
141
+
142
+ me = api_get("/api/v1/auth/me", api_key=access_token, api_url=url)
143
+ email = me.get("email", "unknown")
144
+ # `is_verified` is gone from /auth/me; it meant three things at once and
145
+ # was replaced by the two that survived. `is_capped` is the one that stops
146
+ # somebody signing in.
147
+ is_capped = bool(me.get("is_capped", False))
148
+
149
+ if not is_capped:
150
+ typer.echo(f"\n\u2713 Logged in as {email}")
151
+ else:
152
+ _print_waiting_message(email)
153
+ raise typer.Exit(0)
154
+
155
+
156
+ def logout() -> None:
157
+ """Revoke and clear the stored CLI PAT; other config sections are kept."""
158
+ cli_auth = get_cli_auth()
159
+ api_key = cli_auth.get("api_key")
160
+ api_url = cli_auth.get("api_url") or get_api_url()
161
+
162
+ revoke_failed = False
163
+ if api_key:
164
+ try:
165
+ api_delete("/api/v1/auth/cli/session", api_key=api_key, api_url=api_url)
166
+ except typer.Exit:
167
+ revoke_failed = True
168
+
169
+ clear_cli_auth()
170
+
171
+ if revoke_failed:
172
+ typer.echo(
173
+ "Warning: could not reach the server to revoke your session; "
174
+ "cleared local credentials anyway.",
175
+ err=True,
176
+ )
177
+
178
+ typer.echo("\u2713 Logged out.")
179
+
180
+
181
+ def _policy_source_line() -> str:
182
+ """Describe where an agent built from this shell's env would get policies.
183
+
184
+ ``whoami`` already answers "where do my logs go"; this answers the question
185
+ that actually decides whether an agent enforces anything. It reads the same
186
+ ``KV_*`` environment the SDK reads, so it reports the configuration a
187
+ ``python agent.py`` in this shell would resolve — not the CLI's own
188
+ credentials, which are stored separately.
189
+
190
+ Returns:
191
+ A one-line description, explicit about the case where nothing is
192
+ configured and an agent would therefore enforce nothing.
193
+ """
194
+ from kyvvu_engine import KyvvuSettings
195
+ from kyvvu_engine import PolicySourceKind
196
+
197
+ settings = KyvvuSettings()
198
+ kind = settings.policy_source_kind()
199
+ if kind is PolicySourceKind.api:
200
+ return f"{settings.api_url} (agent_key={settings.agent_key})"
201
+ if kind is PolicySourceKind.disk_cache:
202
+ return f"disk cache only: {settings.policy_cache_path}"
203
+ missing = ", ".join(settings.missing_policy_source_settings())
204
+ return f"\u2717 none — an agent here would enforce nothing (missing {missing})"
205
+
206
+
207
+ def _log_location_line() -> str:
208
+ """Describe where an agent's steps go, without over-claiming.
209
+
210
+ ``whoami`` reads this shell's ``KV_*``; it cannot read the agent's source.
211
+ That matters because ``kyvvu init`` writes ``agent_key`` into ``agent.py``
212
+ and nothing into ``.env``, so the scaffold's own happy path has no
213
+ ``KV_AGENT_KEY`` here -- ``"auto"`` (#357) therefore resolves to ``stdout``
214
+ for this process while the very same project's ``python agent.py`` posts to
215
+ the platform.
216
+
217
+ Printing the bare resolved value reproduced, inverted, exactly the bug the
218
+ old ``os.environ.get("KV_LOG_LOCATION", "stdout")`` line caused: a
219
+ confident answer that is wrong for the documented case. So when the answer
220
+ depends on something only the agent supplies, say so.
221
+
222
+ Returns:
223
+ A one-line description, qualified when this shell cannot decide it.
224
+ """
225
+ from kyvvu_engine import AUTO_LOG_LOCATION, KyvvuSettings
226
+ from kyvvu_engine import PolicySourceKind
227
+
228
+ settings = KyvvuSettings()
229
+ resolved = settings.effective_log_location or "none (logging disabled)"
230
+ if settings.log_location != AUTO_LOG_LOCATION:
231
+ return resolved
232
+ if settings.policy_source_kind() is PolicySourceKind.api:
233
+ return f"{resolved} (auto)"
234
+ # The scaffold passes ``agent_key=`` in code and reads everything else
235
+ # (``KV_API_KEY``, ``KV_API_URL``) from the environment, so only a missing
236
+ # ``KV_AGENT_KEY`` is something it supplies itself (#797). Anything else
237
+ # missing here is missing for the scaffold too, and is named as a
238
+ # condition rather than claimed as supplied.
239
+ still_needed = " and ".join(
240
+ env_var
241
+ for env_var in (
242
+ part.split(" / ")[1] for part in settings.missing_policy_source_settings()
243
+ )
244
+ if env_var != "KV_AGENT_KEY"
245
+ )
246
+ condition = f", with {still_needed} set," if still_needed else ""
247
+ return (
248
+ f"{resolved} (auto — for THIS shell; the kyvvu init scaffold passes "
249
+ f"agent_key in code and{condition} resolves it to "
250
+ f"{_scaffold_api_url(settings.api_url)})"
251
+ )
252
+
253
+
254
+ def _scaffold_api_url(shell_api_url: str) -> str:
255
+ """Name the URL a project ``kyvvu init`` scaffolds here would call.
256
+
257
+ The scaffold reads ``KV_API_URL`` from the environment, and an exported
258
+ value (or one in this directory's ``.env``) wins over anything in a file.
259
+ When nothing sets it, ``kyvvu init`` writes the URL this CLI is logged in
260
+ to into ``.env.example`` (#797), so the scaffold calls *that* rather than
261
+ the production default this shell would resolve. Saying production while
262
+ logged in to dev sent a reader to create a key in the wrong place (#816).
263
+
264
+ Args:
265
+ shell_api_url: The URL this shell's ``KyvvuSettings`` resolved.
266
+
267
+ Returns:
268
+ The URL, followed by where it comes from when that is the scaffold's
269
+ ``.env.example`` rather than this shell.
270
+ """
271
+ from kyvvu_cli.init_cmd import _logged_in_api_url
272
+
273
+ if os.environ.get("KV_API_URL"):
274
+ return shell_api_url
275
+ session_url = _logged_in_api_url()
276
+ if session_url is None:
277
+ return shell_api_url
278
+ return f"{session_url}, the KV_API_URL kyvvu init writes into .env.example"
279
+
280
+
281
+ def whoami() -> None:
282
+ """Print current user info and endpoint configuration."""
283
+ api_key = require_cli_pat()
284
+ cli_auth = get_cli_auth()
285
+ api_url = cli_auth.get("api_url") or get_api_url()
286
+
287
+ me = api_get("/api/v1/auth/me", api_key=api_key, api_url=api_url)
288
+ email = me.get("email", "unknown")
289
+ is_capped = bool(me.get("is_capped", False))
290
+ is_suspended = bool(me.get("is_suspended", False))
291
+ if is_capped:
292
+ status = "\u2717 waiting to be admitted by Kyvvu"
293
+ elif is_suspended:
294
+ status = "\u2717 suspended in this workspace"
295
+ else:
296
+ status = "\u2713 active"
297
+
298
+ log_location = _log_location_line()
299
+ # Incidents follow the same location + format sink model as traces and
300
+ # default to the trace sink when KV_INCIDENT_LOCATION is unset (#225).
301
+ incident_location = os.environ.get("KV_INCIDENT_LOCATION")
302
+ if incident_location:
303
+ incident_display = incident_location
304
+ else:
305
+ # The log line above is already qualified when it needs to be;
306
+ # repeating its whole parenthetical here doubled it (#797).
307
+ incident_display = "(inherits log location)"
308
+
309
+ typer.echo(f"Email: {email}")
310
+ typer.echo(f"API URL: {api_url}")
311
+ typer.echo(f"Status: {status}")
312
+ typer.echo(f"Log location: {log_location}")
313
+ typer.echo(f"Incidents: {incident_display}")
314
+ typer.echo(f"Policy source: {_policy_source_line()}")
@@ -0,0 +1,71 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Kyvvu B.V.
3
+
4
+ """Installed extensions for the ``kyvvu`` command."""
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from functools import lru_cache
9
+ from importlib.metadata import entry_points
10
+ from typing import Callable, TYPE_CHECKING
11
+
12
+ import typer
13
+
14
+ if TYPE_CHECKING:
15
+ from kyvvu_cli.doctor import Check
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CliPlugin:
20
+ """A validated CLI entry point and its optional diagnostics."""
21
+
22
+ name: str
23
+ app: typer.Typer
24
+ status: Callable[[], str] | None
25
+ doctor: Callable[[], Check] | None
26
+
27
+
28
+ def warn(name: str, error: Exception) -> None:
29
+ """Report one failed plugin operation without interrupting the CLI."""
30
+ message = " ".join(str(error).splitlines())
31
+ typer.echo(f"Warning: CLI plugin {name}: {message}", err=True)
32
+
33
+
34
+ @lru_cache(maxsize=1)
35
+ def cli_plugins() -> tuple[CliPlugin, ...]:
36
+ """Load installed plugins once, isolating each distribution's import."""
37
+ plugins = []
38
+ for entry in entry_points(group="kyvvu.cli_plugins"):
39
+ try:
40
+ module = entry.load()
41
+ plugin_app = getattr(module, "app")
42
+ if not isinstance(plugin_app, typer.Typer):
43
+ raise TypeError("app must be a Typer instance")
44
+ status = getattr(module, "status", None)
45
+ doctor = getattr(module, "doctor", None)
46
+ if status is not None and not callable(status):
47
+ raise TypeError("status must be callable")
48
+ if doctor is not None and not callable(doctor):
49
+ raise TypeError("doctor must be callable")
50
+ plugins.append(CliPlugin(entry.name, plugin_app, status, doctor))
51
+ except Exception as exc:
52
+ warn(entry.name, exc)
53
+ return tuple(plugins)
54
+
55
+
56
+ def doctor_checks() -> list[Check]:
57
+ """Run optional plugin checks while preserving the rest of the report."""
58
+ from kyvvu_cli.doctor import Check
59
+
60
+ checks = []
61
+ for plugin in cli_plugins():
62
+ if plugin.doctor is None:
63
+ continue
64
+ try:
65
+ check = plugin.doctor()
66
+ if not isinstance(check, Check):
67
+ raise TypeError("doctor must return a Check")
68
+ checks.append(check)
69
+ except Exception as exc:
70
+ warn(plugin.name, exc)
71
+ return checks