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 +4 -0
- kyvvu_cli/__main__.py +7 -0
- kyvvu_cli/__version__.py +1 -0
- kyvvu_cli/_build_info.json +19 -0
- kyvvu_cli/agents.py +130 -0
- kyvvu_cli/auth.py +314 -0
- kyvvu_cli/cli_plugins.py +71 -0
- kyvvu_cli/client.py +237 -0
- kyvvu_cli/config.py +71 -0
- kyvvu_cli/doctor.py +1568 -0
- kyvvu_cli/documentation.py +79 -0
- kyvvu_cli/init_cmd.py +155 -0
- kyvvu_cli/main.py +455 -0
- kyvvu_cli/manifests.py +154 -0
- kyvvu_cli/policies.py +66 -0
- kyvvu_cli/project_environment.py +87 -0
- kyvvu_cli/py.typed +0 -0
- kyvvu_cli/serve.py +258 -0
- kyvvu_cli/supported.py +4 -0
- kyvvu_cli/templates/default/AGENTS.md.tmpl +25 -0
- kyvvu_cli/templates/default/README.md.tmpl +55 -0
- kyvvu_cli/templates/default/agent.py.tmpl +202 -0
- kyvvu_cli/templates/default/env.example.tmpl +23 -0
- kyvvu_cli/templates/default/gitignore.tmpl +5 -0
- kyvvu_cli/templates/default/requirements.txt.tmpl +1 -0
- kyvvu_cli/trial_marker.py +222 -0
- kyvvu_cli/try_cmd.py +879 -0
- kyvvu_cli-1.0.0.dist-info/METADATA +50 -0
- kyvvu_cli-1.0.0.dist-info/RECORD +33 -0
- kyvvu_cli-1.0.0.dist-info/WHEEL +5 -0
- kyvvu_cli-1.0.0.dist-info/entry_points.txt +2 -0
- kyvvu_cli-1.0.0.dist-info/licenses/LICENSE +190 -0
- kyvvu_cli-1.0.0.dist-info/top_level.txt +1 -0
kyvvu_cli/__init__.py
ADDED
kyvvu_cli/__main__.py
ADDED
kyvvu_cli/__version__.py
ADDED
|
@@ -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()}")
|
kyvvu_cli/cli_plugins.py
ADDED
|
@@ -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
|