lightnow-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.
- lightnow_cli/__init__.py +3 -0
- lightnow_cli/authenticated_http.py +134 -0
- lightnow_cli/client.py +250 -0
- lightnow_cli/commands/__init__.py +1 -0
- lightnow_cli/commands/auth.py +500 -0
- lightnow_cli/commands/integrations.py +658 -0
- lightnow_cli/commands/publish.py +111 -0
- lightnow_cli/commands/query.py +268 -0
- lightnow_cli/commands/runner.py +399 -0
- lightnow_cli/commands/validate.py +78 -0
- lightnow_cli/config.py +152 -0
- lightnow_cli/main.py +54 -0
- lightnow_cli/tls.py +8 -0
- lightnow_cli/validation.py +238 -0
- lightnow_cli-1.0.0.dist-info/METADATA +143 -0
- lightnow_cli-1.0.0.dist-info/RECORD +19 -0
- lightnow_cli-1.0.0.dist-info/WHEEL +4 -0
- lightnow_cli-1.0.0.dist-info/entry_points.txt +2 -0
- lightnow_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Local MCP runner commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from typing_extensions import Annotated
|
|
16
|
+
|
|
17
|
+
from ..authenticated_http import (
|
|
18
|
+
authentication_error_from_response,
|
|
19
|
+
request_with_refresh,
|
|
20
|
+
)
|
|
21
|
+
from ..config import config_manager
|
|
22
|
+
from .auth import (
|
|
23
|
+
ACCESS_TOKEN_EXPIRED_MESSAGE,
|
|
24
|
+
AccessTokenExpired,
|
|
25
|
+
AuthError,
|
|
26
|
+
require_access_token,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
console = Console(stderr=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(
|
|
33
|
+
server: Annotated[
|
|
34
|
+
str,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"--server",
|
|
37
|
+
help="MCP server alias from the selected LightNow runtime profile.",
|
|
38
|
+
),
|
|
39
|
+
],
|
|
40
|
+
profile: Annotated[
|
|
41
|
+
str,
|
|
42
|
+
typer.Option("--profile", help="LightNow runtime profile."),
|
|
43
|
+
] = "default",
|
|
44
|
+
tenant: Annotated[
|
|
45
|
+
Optional[str],
|
|
46
|
+
typer.Option("--tenant", help="Tenant id or slug, sent as X-Tenant."),
|
|
47
|
+
] = None,
|
|
48
|
+
api_url: Annotated[
|
|
49
|
+
Optional[str],
|
|
50
|
+
typer.Option("--api-url", help="Registry API base URL."),
|
|
51
|
+
] = None,
|
|
52
|
+
transport: Annotated[
|
|
53
|
+
str,
|
|
54
|
+
typer.Option("--transport", help="Requested runtime transport."),
|
|
55
|
+
] = "stdio",
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Run one MCP server through LightNow and inject secrets into the child process."""
|
|
58
|
+
if transport != "stdio":
|
|
59
|
+
console.print("[bold red]Runner failed:[/bold red] only stdio is supported.")
|
|
60
|
+
raise typer.Exit(2)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
token = require_access_token()
|
|
64
|
+
registry_api_url = api_url or config_manager.load_config().registry_api_url
|
|
65
|
+
if not registry_api_url:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"Registry API URL required. Configure the CLI or pass --api-url."
|
|
68
|
+
)
|
|
69
|
+
selected = resolve_profile_server(
|
|
70
|
+
api_url=registry_api_url,
|
|
71
|
+
token=token,
|
|
72
|
+
tenant=tenant,
|
|
73
|
+
profile=profile,
|
|
74
|
+
server=server,
|
|
75
|
+
)
|
|
76
|
+
context = fetch_runtime_context(
|
|
77
|
+
api_url=registry_api_url,
|
|
78
|
+
token=token,
|
|
79
|
+
tenant=tenant,
|
|
80
|
+
profile=profile,
|
|
81
|
+
server_name=str(selected["server_name"]),
|
|
82
|
+
version=str(selected["version"]),
|
|
83
|
+
transport=transport,
|
|
84
|
+
)
|
|
85
|
+
assert_runtime_context_ready(context, server=server, profile=profile)
|
|
86
|
+
command, args, env, cwd = launch_config_from_context(context)
|
|
87
|
+
except AccessTokenExpired:
|
|
88
|
+
console.print(f"[red]{ACCESS_TOKEN_EXPIRED_MESSAGE}[/red]")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
except (AuthError, ValueError) as exc:
|
|
91
|
+
console.print(f"[bold red]Runner failed:[/bold red] {redact(str(exc))}")
|
|
92
|
+
raise typer.Exit(1) from exc
|
|
93
|
+
|
|
94
|
+
process_env = os.environ.copy()
|
|
95
|
+
process_env.update(env)
|
|
96
|
+
try:
|
|
97
|
+
completed = subprocess.run(
|
|
98
|
+
[command, *args],
|
|
99
|
+
cwd=str(Path(cwd).expanduser()) if cwd else None,
|
|
100
|
+
env=process_env,
|
|
101
|
+
stdin=sys.stdin.buffer,
|
|
102
|
+
stdout=sys.stdout.buffer,
|
|
103
|
+
stderr=sys.stderr.buffer,
|
|
104
|
+
check=False,
|
|
105
|
+
)
|
|
106
|
+
except FileNotFoundError as exc:
|
|
107
|
+
console.print(
|
|
108
|
+
f"[bold red]Runner failed:[/bold red] command not found: {command}"
|
|
109
|
+
)
|
|
110
|
+
raise typer.Exit(127) from exc
|
|
111
|
+
|
|
112
|
+
if completed.returncode != 0:
|
|
113
|
+
console.print(
|
|
114
|
+
runner_failure_summary(
|
|
115
|
+
server=server,
|
|
116
|
+
profile=profile,
|
|
117
|
+
command=command,
|
|
118
|
+
args=args,
|
|
119
|
+
env=env,
|
|
120
|
+
cwd=cwd,
|
|
121
|
+
exit_code=completed.returncode,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
raise typer.Exit(completed.returncode)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_profile_server(
|
|
129
|
+
*,
|
|
130
|
+
api_url: str,
|
|
131
|
+
token: str,
|
|
132
|
+
tenant: Optional[str],
|
|
133
|
+
profile: str,
|
|
134
|
+
server: str,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""Find a registry-linked profile server by alias or registry name."""
|
|
137
|
+
payload = fetch_profile_servers(
|
|
138
|
+
api_url=api_url,
|
|
139
|
+
token=token,
|
|
140
|
+
tenant=tenant,
|
|
141
|
+
profile=profile,
|
|
142
|
+
)
|
|
143
|
+
servers = payload.get("servers") if isinstance(payload, dict) else None
|
|
144
|
+
if not isinstance(servers, list):
|
|
145
|
+
raise ValueError("Registry API response did not include profile servers.")
|
|
146
|
+
|
|
147
|
+
matches = [
|
|
148
|
+
item
|
|
149
|
+
for item in servers
|
|
150
|
+
if isinstance(item, dict)
|
|
151
|
+
and (item.get("alias") == server or item.get("server_name") == server)
|
|
152
|
+
]
|
|
153
|
+
if len(matches) != 1:
|
|
154
|
+
raise ValueError(f"Runtime profile does not contain server '{server}'.")
|
|
155
|
+
|
|
156
|
+
selected = matches[0]
|
|
157
|
+
if selected.get("status") != "linked":
|
|
158
|
+
missing = missing_inputs_from_context(selected)
|
|
159
|
+
if missing:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
format_missing_inputs_message(
|
|
162
|
+
missing,
|
|
163
|
+
server=server,
|
|
164
|
+
profile=profile,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
status = selected.get("status")
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Server '{server}' is not ready for the local runner: {status}"
|
|
170
|
+
)
|
|
171
|
+
if not isinstance(selected.get("server_name"), str) or not isinstance(
|
|
172
|
+
selected.get("version"), str
|
|
173
|
+
):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"Server '{server}' must be linked to a registry version before runner sync."
|
|
176
|
+
)
|
|
177
|
+
return selected
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def assert_runtime_context_ready(
|
|
181
|
+
context: dict[str, Any],
|
|
182
|
+
*,
|
|
183
|
+
server: str,
|
|
184
|
+
profile: str,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Fail before starting a child process when LightNow knows inputs are missing."""
|
|
187
|
+
missing = missing_inputs_from_context(context)
|
|
188
|
+
if missing:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
format_missing_inputs_message(missing, server=server, profile=profile)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def missing_inputs_from_context(context: dict[str, Any]) -> list[dict[str, Any]]:
|
|
195
|
+
"""Normalize missing_inputs from Registry API payloads."""
|
|
196
|
+
raw_missing = context.get("missing_inputs")
|
|
197
|
+
if not isinstance(raw_missing, list):
|
|
198
|
+
return []
|
|
199
|
+
missing: list[dict[str, Any]] = []
|
|
200
|
+
for item in raw_missing:
|
|
201
|
+
if isinstance(item, dict):
|
|
202
|
+
missing.append(item)
|
|
203
|
+
elif isinstance(item, str) and item:
|
|
204
|
+
missing.append({"name": item})
|
|
205
|
+
return missing
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def format_missing_inputs_message(
|
|
209
|
+
missing: list[dict[str, Any]],
|
|
210
|
+
*,
|
|
211
|
+
server: str,
|
|
212
|
+
profile: str,
|
|
213
|
+
) -> str:
|
|
214
|
+
"""Render a clear readiness error for missing runtime inputs."""
|
|
215
|
+
lines = [
|
|
216
|
+
f"Runtime profile '{profile}' is missing required configuration for '{server}'.",
|
|
217
|
+
"Missing inputs:",
|
|
218
|
+
]
|
|
219
|
+
for item in missing:
|
|
220
|
+
lines.append(f" - {format_missing_input(item)}")
|
|
221
|
+
lines.extend(
|
|
222
|
+
[
|
|
223
|
+
"Open LightNow Integrations, select this server, set the missing values,",
|
|
224
|
+
"save the profile, and run the command again.",
|
|
225
|
+
]
|
|
226
|
+
)
|
|
227
|
+
return "\n".join(lines)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def format_missing_input(item: dict[str, Any]) -> str:
|
|
231
|
+
"""Render one missing input without exposing values."""
|
|
232
|
+
name = item.get("name")
|
|
233
|
+
scope = item.get("scope")
|
|
234
|
+
is_secret = item.get("is_secret")
|
|
235
|
+
description = item.get("description")
|
|
236
|
+
|
|
237
|
+
label = str(name) if isinstance(name, str) and name else "unnamed input"
|
|
238
|
+
details: list[str] = []
|
|
239
|
+
if isinstance(scope, str) and scope:
|
|
240
|
+
details.append(scope.replace("_", " "))
|
|
241
|
+
if is_secret is True:
|
|
242
|
+
details.append("secret")
|
|
243
|
+
suffix = f" ({', '.join(details)})" if details else ""
|
|
244
|
+
help_text = (
|
|
245
|
+
f" - {description}" if isinstance(description, str) and description else ""
|
|
246
|
+
)
|
|
247
|
+
return f"{label}{suffix}{help_text}"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def runner_failure_summary(
|
|
251
|
+
*,
|
|
252
|
+
server: str,
|
|
253
|
+
profile: str,
|
|
254
|
+
command: str,
|
|
255
|
+
args: list[str],
|
|
256
|
+
env: dict[str, str],
|
|
257
|
+
cwd: Optional[str],
|
|
258
|
+
exit_code: int,
|
|
259
|
+
) -> str:
|
|
260
|
+
"""Explain which LightNow-resolved process failed, without printing secrets."""
|
|
261
|
+
rendered_command = " ".join([command, *args])
|
|
262
|
+
env_names = ", ".join(sorted(env.keys())) if env else "none"
|
|
263
|
+
cwd_line = f"\n working directory: {cwd}" if cwd else ""
|
|
264
|
+
return (
|
|
265
|
+
"\n[bold red]Runner failed:[/bold red] MCP server process exited with "
|
|
266
|
+
f"code {exit_code}.\n"
|
|
267
|
+
f" profile: {profile}\n"
|
|
268
|
+
f" server: {server}\n"
|
|
269
|
+
f" command: {rendered_command}\n"
|
|
270
|
+
f" environment values supplied by LightNow: {env_names}"
|
|
271
|
+
f"{cwd_line}\n"
|
|
272
|
+
"Check this server's configuration in LightNow Integrations and run again.\n"
|
|
273
|
+
f"Use 'lightnow info {server}' to inspect the server's expected inputs."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def fetch_profile_servers(
|
|
278
|
+
*,
|
|
279
|
+
api_url: str,
|
|
280
|
+
token: str,
|
|
281
|
+
tenant: Optional[str],
|
|
282
|
+
profile: str,
|
|
283
|
+
) -> dict[str, Any]:
|
|
284
|
+
"""Fetch servers configured in a LightNow runtime profile."""
|
|
285
|
+
url = f"{api_url.rstrip('/')}/integrations/profiles/{profile}/servers"
|
|
286
|
+
try:
|
|
287
|
+
response = request_with_refresh(
|
|
288
|
+
"GET",
|
|
289
|
+
url,
|
|
290
|
+
token=token,
|
|
291
|
+
tenant=tenant,
|
|
292
|
+
headers={"Accept": "application/json"},
|
|
293
|
+
timeout=30.0,
|
|
294
|
+
)
|
|
295
|
+
except httpx.RequestError as exc:
|
|
296
|
+
raise ValueError(f"Network error: {exc}") from exc
|
|
297
|
+
|
|
298
|
+
return json_response_or_error(response)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def fetch_runtime_context(
|
|
302
|
+
*,
|
|
303
|
+
api_url: str,
|
|
304
|
+
token: str,
|
|
305
|
+
tenant: Optional[str],
|
|
306
|
+
profile: str,
|
|
307
|
+
server_name: str,
|
|
308
|
+
version: str,
|
|
309
|
+
transport: str,
|
|
310
|
+
) -> dict[str, Any]:
|
|
311
|
+
"""Fetch a secret-bearing runtime context for the local runner."""
|
|
312
|
+
encoded_server = quote(server_name, safe="")
|
|
313
|
+
encoded_version = quote(version, safe="")
|
|
314
|
+
url = f"{api_url.rstrip('/')}/servers/{encoded_server}/versions/{encoded_version}/context"
|
|
315
|
+
try:
|
|
316
|
+
response = request_with_refresh(
|
|
317
|
+
"GET",
|
|
318
|
+
url,
|
|
319
|
+
token=token,
|
|
320
|
+
tenant=tenant,
|
|
321
|
+
headers={"Accept": "application/json"},
|
|
322
|
+
params={
|
|
323
|
+
"profile": profile,
|
|
324
|
+
"include": "secrets",
|
|
325
|
+
"transport": transport,
|
|
326
|
+
"consumer": "local-runner",
|
|
327
|
+
},
|
|
328
|
+
timeout=30.0,
|
|
329
|
+
)
|
|
330
|
+
except httpx.RequestError as exc:
|
|
331
|
+
raise ValueError(f"Network error: {exc}") from exc
|
|
332
|
+
|
|
333
|
+
return json_response_or_error(response)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def launch_config_from_context(
|
|
337
|
+
context: dict[str, Any],
|
|
338
|
+
) -> tuple[str, list[str], dict[str, str], Optional[str]]:
|
|
339
|
+
"""Extract stdio launch details from a runtime context."""
|
|
340
|
+
probe_request = context.get("probe_request")
|
|
341
|
+
if not isinstance(probe_request, dict):
|
|
342
|
+
raise ValueError("Runtime context did not include a probe request.")
|
|
343
|
+
if probe_request.get("transport") != "stdio":
|
|
344
|
+
raise ValueError("Runtime context did not resolve to a stdio server.")
|
|
345
|
+
stdio = probe_request.get("stdio")
|
|
346
|
+
if not isinstance(stdio, dict):
|
|
347
|
+
raise ValueError("Runtime context did not include stdio launch details.")
|
|
348
|
+
|
|
349
|
+
command = stdio.get("cmd")
|
|
350
|
+
if not isinstance(command, str) or command == "":
|
|
351
|
+
raise ValueError("Runtime context did not include a launch command.")
|
|
352
|
+
|
|
353
|
+
args = stdio.get("args", [])
|
|
354
|
+
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
|
|
355
|
+
raise ValueError("Runtime context included invalid launch arguments.")
|
|
356
|
+
|
|
357
|
+
raw_env = stdio.get("env", {})
|
|
358
|
+
if raw_env is None:
|
|
359
|
+
raw_env = {}
|
|
360
|
+
if not isinstance(raw_env, dict):
|
|
361
|
+
raise ValueError("Runtime context included invalid environment variables.")
|
|
362
|
+
env = {str(key): str(value) for key, value in raw_env.items()}
|
|
363
|
+
|
|
364
|
+
cwd = stdio.get("cwd") if "cwd" in stdio else stdio.get("working_directory")
|
|
365
|
+
if cwd is not None and not isinstance(cwd, str):
|
|
366
|
+
raise ValueError("Runtime context included an invalid working directory.")
|
|
367
|
+
|
|
368
|
+
return command, list(args), env, cwd
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def json_response_or_error(response: httpx.Response) -> dict[str, Any]:
|
|
372
|
+
"""Return JSON response or raise a redacted explicit error."""
|
|
373
|
+
if response.status_code == 401:
|
|
374
|
+
raise authentication_error_from_response(response)
|
|
375
|
+
if response.status_code >= 400:
|
|
376
|
+
raise ValueError(f"HTTP {response.status_code}: {redact(response.text)}")
|
|
377
|
+
|
|
378
|
+
payload = response.json()
|
|
379
|
+
if not isinstance(payload, dict):
|
|
380
|
+
raise ValueError("Registry API response was not a JSON object.")
|
|
381
|
+
return payload
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def redact(value: str) -> str:
|
|
385
|
+
"""Redact secret-like values from error output."""
|
|
386
|
+
redacted = value
|
|
387
|
+
for marker in ("TOKEN", "SECRET", "PASSWORD", "PWD", "KEY"):
|
|
388
|
+
lines = []
|
|
389
|
+
for line in redacted.splitlines():
|
|
390
|
+
if marker in line.upper() and "${" not in line:
|
|
391
|
+
if "=" in line:
|
|
392
|
+
key, _ = line.split("=", 1)
|
|
393
|
+
line = key + '= "[REDACTED]"'
|
|
394
|
+
elif ":" in line:
|
|
395
|
+
key, _ = line.split(":", 1)
|
|
396
|
+
line = key + ': "[REDACTED]"'
|
|
397
|
+
lines.append(line)
|
|
398
|
+
redacted = "\n".join(lines)
|
|
399
|
+
return redacted
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Validate command for local artifact validation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from typing_extensions import Annotated
|
|
9
|
+
|
|
10
|
+
from ..validation import validate_artifacts
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate(
|
|
16
|
+
server: Annotated[
|
|
17
|
+
Optional[Path], typer.Option("--server", help="Path to server.json file")
|
|
18
|
+
] = None,
|
|
19
|
+
docs: Annotated[
|
|
20
|
+
Optional[Path], typer.Option("--docs", help="Path to docs.md file")
|
|
21
|
+
] = None,
|
|
22
|
+
spec: Annotated[
|
|
23
|
+
Optional[Path],
|
|
24
|
+
typer.Option("--spec", help="Path to OpenAPI specification file"),
|
|
25
|
+
] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Validate MCP server artifacts locally."""
|
|
28
|
+
if not any([server, docs, spec]):
|
|
29
|
+
console.print(
|
|
30
|
+
"[red]Error: At least one file must be specified for validation[/red]"
|
|
31
|
+
)
|
|
32
|
+
console.print("Use --help to see available options.")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
console.print("[bold blue]Validating MCP server artifacts...[/bold blue]")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
results = validate_artifacts(server_file=server, docs_file=docs, spec_file=spec)
|
|
39
|
+
|
|
40
|
+
if results["valid"]:
|
|
41
|
+
console.print("\n[bold green]✓ All artifacts are valid![/bold green]")
|
|
42
|
+
|
|
43
|
+
if results["warnings"]:
|
|
44
|
+
console.print(
|
|
45
|
+
f"\n[yellow]Note: {len(results['warnings'])} warning(s) found:[/yellow]"
|
|
46
|
+
)
|
|
47
|
+
for warning in results["warnings"]:
|
|
48
|
+
console.print(f" [yellow]•[/yellow] {warning}")
|
|
49
|
+
|
|
50
|
+
# Show summary of validated files
|
|
51
|
+
validated_files = []
|
|
52
|
+
if server and "server" in results["validated"]:
|
|
53
|
+
validated_files.append("server.json")
|
|
54
|
+
if docs and "docs" in results["validated"]:
|
|
55
|
+
validated_files.append("docs.md")
|
|
56
|
+
if spec and "spec" in results["validated"]:
|
|
57
|
+
validated_files.append("OpenAPI specification")
|
|
58
|
+
|
|
59
|
+
if validated_files:
|
|
60
|
+
console.print(f"\nValidated files: {', '.join(validated_files)}")
|
|
61
|
+
|
|
62
|
+
else:
|
|
63
|
+
console.print(
|
|
64
|
+
f"\n[bold red]Validation failed with {len(results['errors'])} error(s):[/bold red]"
|
|
65
|
+
)
|
|
66
|
+
for error in results["errors"]:
|
|
67
|
+
console.print(f" [red]•[/red] {error}")
|
|
68
|
+
|
|
69
|
+
if results["warnings"]:
|
|
70
|
+
console.print("\n[yellow]Additional warnings:[/yellow]")
|
|
71
|
+
for warning in results["warnings"]:
|
|
72
|
+
console.print(f" [yellow]•[/yellow] {warning}")
|
|
73
|
+
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
except Exception as e:
|
|
77
|
+
console.print(f"[bold red]Validation error:[/bold red] {e}")
|
|
78
|
+
raise typer.Exit(1)
|
lightnow_cli/config.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Configuration and token management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
DEFAULT_ISSUER = "https://auth.lightnow.ai/realms/lightnow"
|
|
13
|
+
DEFAULT_CLIENT_ID = "lightnow-cli"
|
|
14
|
+
DEFAULT_REGISTRY_API_URL = "https://registry-api.lightnow.ai/v0.1"
|
|
15
|
+
LOCAL_ISSUER = "https://auth.lightnow.local/realms/lightnow-local"
|
|
16
|
+
LOCAL_REGISTRY_API_URL = "https://registry-api.lightnow.local/v0.1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Config(BaseModel):
|
|
20
|
+
"""CLI configuration model."""
|
|
21
|
+
|
|
22
|
+
access_token: Optional[str] = None
|
|
23
|
+
refresh_token: Optional[str] = None
|
|
24
|
+
issuer: Optional[str] = Field(default=DEFAULT_ISSUER)
|
|
25
|
+
client_id: Optional[str] = Field(default=DEFAULT_CLIENT_ID)
|
|
26
|
+
registry_api_url: Optional[str] = Field(default=DEFAULT_REGISTRY_API_URL)
|
|
27
|
+
user_info: Optional[Dict[str, Any]] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigManager:
|
|
31
|
+
"""Manages CLI configuration and token storage."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.config_dir = Path.home() / ".lightnow"
|
|
35
|
+
self.config_file = self.config_dir / "config.json"
|
|
36
|
+
self._config: Optional[Config] = None
|
|
37
|
+
|
|
38
|
+
def _ensure_config_dir(self) -> None:
|
|
39
|
+
"""Ensure config directory exists."""
|
|
40
|
+
self.config_dir.mkdir(mode=0o700, exist_ok=True)
|
|
41
|
+
self.config_dir.chmod(0o700)
|
|
42
|
+
|
|
43
|
+
def load_config(self) -> Config:
|
|
44
|
+
"""Load configuration from file."""
|
|
45
|
+
if self._config is not None:
|
|
46
|
+
return self._config
|
|
47
|
+
|
|
48
|
+
# Start with default config
|
|
49
|
+
config_data = {}
|
|
50
|
+
|
|
51
|
+
# Load from file if exists
|
|
52
|
+
if self.config_file.exists():
|
|
53
|
+
try:
|
|
54
|
+
with open(self.config_file, "r") as f:
|
|
55
|
+
config_data = json.load(f)
|
|
56
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
57
|
+
typer.echo(f"Warning: Failed to load config file: {e}", err=True)
|
|
58
|
+
|
|
59
|
+
self._config = Config(**config_data)
|
|
60
|
+
return self._config
|
|
61
|
+
|
|
62
|
+
def save_config(self, config: Config) -> None:
|
|
63
|
+
"""Save configuration to file."""
|
|
64
|
+
self._ensure_config_dir()
|
|
65
|
+
|
|
66
|
+
config_data = config.model_dump()
|
|
67
|
+
serialized = json.dumps(config_data, indent=2)
|
|
68
|
+
fd: Optional[int] = None
|
|
69
|
+
tmp_path: Optional[Path] = None
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
fd, raw_tmp_path = tempfile.mkstemp(
|
|
73
|
+
prefix=f".{self.config_file.name}.",
|
|
74
|
+
suffix=".tmp",
|
|
75
|
+
dir=self.config_dir,
|
|
76
|
+
text=True,
|
|
77
|
+
)
|
|
78
|
+
tmp_path = Path(raw_tmp_path)
|
|
79
|
+
os.fchmod(fd, 0o600)
|
|
80
|
+
with os.fdopen(fd, "w") as f:
|
|
81
|
+
fd = None
|
|
82
|
+
f.write(serialized)
|
|
83
|
+
f.write("\n")
|
|
84
|
+
f.flush()
|
|
85
|
+
os.fsync(f.fileno())
|
|
86
|
+
os.replace(tmp_path, self.config_file)
|
|
87
|
+
self.config_file.chmod(0o600)
|
|
88
|
+
self._fsync_config_dir()
|
|
89
|
+
self._config = config
|
|
90
|
+
except IOError as e:
|
|
91
|
+
typer.echo(f"Error: Failed to save config: {e}", err=True)
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
finally:
|
|
94
|
+
if fd is not None:
|
|
95
|
+
os.close(fd)
|
|
96
|
+
if tmp_path is not None and tmp_path.exists():
|
|
97
|
+
tmp_path.unlink()
|
|
98
|
+
|
|
99
|
+
def _fsync_config_dir(self) -> None:
|
|
100
|
+
"""Best-effort fsync for the config directory after atomic replacement."""
|
|
101
|
+
try:
|
|
102
|
+
dir_fd = os.open(self.config_dir, os.O_RDONLY)
|
|
103
|
+
except OSError:
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
os.fsync(dir_fd)
|
|
107
|
+
finally:
|
|
108
|
+
os.close(dir_fd)
|
|
109
|
+
|
|
110
|
+
def get_token(self) -> Optional[str]:
|
|
111
|
+
"""Get access token from config or environment."""
|
|
112
|
+
config = self.load_config()
|
|
113
|
+
return config.access_token
|
|
114
|
+
|
|
115
|
+
def set_token(
|
|
116
|
+
self,
|
|
117
|
+
token: str,
|
|
118
|
+
refresh_token: Optional[str] = None,
|
|
119
|
+
user_info: Optional[Dict[str, Any]] = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Set access token, refresh token and optionally user info."""
|
|
122
|
+
config = self.load_config()
|
|
123
|
+
config.access_token = token
|
|
124
|
+
if refresh_token is not None:
|
|
125
|
+
config.refresh_token = refresh_token
|
|
126
|
+
config.user_info = user_info
|
|
127
|
+
self.save_config(config)
|
|
128
|
+
|
|
129
|
+
def clear_token(self) -> None:
|
|
130
|
+
"""Clear stored access token."""
|
|
131
|
+
config = self.load_config()
|
|
132
|
+
config.access_token = None
|
|
133
|
+
config.refresh_token = None
|
|
134
|
+
config.user_info = None
|
|
135
|
+
self.save_config(config)
|
|
136
|
+
|
|
137
|
+
def set_auth_config(
|
|
138
|
+
self,
|
|
139
|
+
issuer: str,
|
|
140
|
+
client_id: str,
|
|
141
|
+
registry_api_url: Optional[str] = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Set authentication configuration."""
|
|
144
|
+
config = self.load_config()
|
|
145
|
+
config.issuer = issuer
|
|
146
|
+
config.client_id = client_id
|
|
147
|
+
config.registry_api_url = registry_api_url or DEFAULT_REGISTRY_API_URL
|
|
148
|
+
self.save_config(config)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Global config manager instance
|
|
152
|
+
config_manager = ConfigManager()
|
lightnow_cli/main.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Main CLI application entry point."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .commands import auth, integrations, publish, query, runner, validate
|
|
8
|
+
from .tls import configure_tls_trust_store
|
|
9
|
+
|
|
10
|
+
configure_tls_trust_store()
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="lightnow",
|
|
14
|
+
help="LightNow CLI - publish MCP servers and sync integration profiles.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app.command("login")(auth.login)
|
|
19
|
+
app.command("logout")(auth.logout)
|
|
20
|
+
app.command("status")(auth.status)
|
|
21
|
+
app.command("whoami")(auth.whoami)
|
|
22
|
+
app.command("publish")(publish.publish)
|
|
23
|
+
app.command("search")(query.search_servers)
|
|
24
|
+
app.command("favorites")(query.favorite_servers)
|
|
25
|
+
app.command("info")(query.server_info)
|
|
26
|
+
app.command("validate")(validate.validate)
|
|
27
|
+
app.command("sync")(integrations.sync)
|
|
28
|
+
app.command("run")(runner.run)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def version_callback(value: bool) -> None:
|
|
32
|
+
"""Print version and exit."""
|
|
33
|
+
if value:
|
|
34
|
+
typer.echo(f"LightNow CLI {__version__}")
|
|
35
|
+
raise typer.Exit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.callback()
|
|
39
|
+
def main(
|
|
40
|
+
version: Annotated[
|
|
41
|
+
bool, typer.Option("--version", callback=version_callback, help="Show version")
|
|
42
|
+
] = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""LightNow CLI."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cli() -> None:
|
|
49
|
+
"""Run the LightNow command-line application."""
|
|
50
|
+
app()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
cli()
|