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,658 @@
|
|
|
1
|
+
"""Integration profile commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
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
|
+
from .runner import fetch_profile_servers
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
app = typer.Typer(help="Integration profile commands")
|
|
32
|
+
|
|
33
|
+
BEGIN = "# >>> LightNow managed integrations >>>"
|
|
34
|
+
END = "# <<< LightNow managed integrations <<<"
|
|
35
|
+
JSON_MANIFEST_SUFFIX = ".lightnow-managed.json"
|
|
36
|
+
|
|
37
|
+
CLIENT_DEFAULTS: dict[str, tuple[str, Path]] = {
|
|
38
|
+
"codex": ("toml", Path.home() / ".codex" / "config.toml"),
|
|
39
|
+
"claude-desktop": (
|
|
40
|
+
"json",
|
|
41
|
+
Path.home()
|
|
42
|
+
/ "Library"
|
|
43
|
+
/ "Application Support"
|
|
44
|
+
/ "Claude"
|
|
45
|
+
/ "claude_desktop_config.json",
|
|
46
|
+
),
|
|
47
|
+
"claude-code": ("json", Path.home() / ".claude" / "mcp.json"),
|
|
48
|
+
"cursor": ("json", Path.home() / ".cursor" / "mcp.json"),
|
|
49
|
+
"windsurf": ("json", Path.home() / ".codeium" / "windsurf" / "mcp_config.json"),
|
|
50
|
+
"continue": ("yaml", Path.home() / ".continue" / "config.yaml"),
|
|
51
|
+
"gemini-cli": ("json", Path.home() / ".gemini" / "settings.json"),
|
|
52
|
+
"librechat": ("yaml", Path.cwd() / "librechat.yaml"),
|
|
53
|
+
"vscode": ("json", Path.cwd() / ".vscode" / "mcp.json"),
|
|
54
|
+
"mcp-inspector": ("shell", Path.cwd() / "lightnow-mcp-inspector.sh"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
CLIENTS = sorted(CLIENT_DEFAULTS)
|
|
58
|
+
SECRET_MODES = ["placeholder", "plaintext"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("sync")
|
|
62
|
+
def sync(
|
|
63
|
+
client: Annotated[str, typer.Option("--client", help="Target MCP client")],
|
|
64
|
+
profile: Annotated[
|
|
65
|
+
str, typer.Option("--profile", help="Runtime profile")
|
|
66
|
+
] = "default",
|
|
67
|
+
tenant: Annotated[
|
|
68
|
+
Optional[str],
|
|
69
|
+
typer.Option("--tenant", help="Tenant id or slug, sent as X-Tenant"),
|
|
70
|
+
] = None,
|
|
71
|
+
format_: Annotated[
|
|
72
|
+
Optional[str],
|
|
73
|
+
typer.Option("--format", help="Export format; defaults to the client format"),
|
|
74
|
+
] = None,
|
|
75
|
+
secret_mode: Annotated[
|
|
76
|
+
str,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--secret-mode",
|
|
79
|
+
help="plaintext writes secret values; placeholder writes ${SECRET_NAME} references",
|
|
80
|
+
),
|
|
81
|
+
] = "plaintext",
|
|
82
|
+
config_path: Annotated[
|
|
83
|
+
Optional[Path],
|
|
84
|
+
typer.Option("--config-path", help="Target client config file"),
|
|
85
|
+
] = None,
|
|
86
|
+
api_url: Annotated[
|
|
87
|
+
Optional[str],
|
|
88
|
+
typer.Option("--api-url", help="Registry API base URL"),
|
|
89
|
+
] = None,
|
|
90
|
+
dry_run: Annotated[
|
|
91
|
+
bool,
|
|
92
|
+
typer.Option("--dry-run", help="Print a redacted preview without writing"),
|
|
93
|
+
] = False,
|
|
94
|
+
yes: Annotated[
|
|
95
|
+
bool,
|
|
96
|
+
typer.Option("--yes", help="Skip plaintext write confirmation"),
|
|
97
|
+
] = False,
|
|
98
|
+
runner: Annotated[
|
|
99
|
+
bool,
|
|
100
|
+
typer.Option(
|
|
101
|
+
"--runner",
|
|
102
|
+
help="Write LightNow local-runner wrappers instead of client-side server configs.",
|
|
103
|
+
),
|
|
104
|
+
] = False,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Sync a LightNow integration runtime profile into a local MCP client config."""
|
|
107
|
+
if client not in CLIENT_DEFAULTS:
|
|
108
|
+
raise_bad_argument("Unsupported client", f"Use one of: {', '.join(CLIENTS)}")
|
|
109
|
+
if secret_mode not in SECRET_MODES:
|
|
110
|
+
raise_bad_argument("Unsupported secret mode", "Use placeholder or plaintext.")
|
|
111
|
+
|
|
112
|
+
default_format, default_path = CLIENT_DEFAULTS[client]
|
|
113
|
+
export_format = format_ or default_format
|
|
114
|
+
target = (config_path or default_path).expanduser()
|
|
115
|
+
try:
|
|
116
|
+
bearer_token = require_access_token()
|
|
117
|
+
except AccessTokenExpired:
|
|
118
|
+
console.print(f"[red]{ACCESS_TOKEN_EXPIRED_MESSAGE}[/red]")
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
except AuthError as exc:
|
|
121
|
+
console.print(f"[yellow]{exc}[/yellow]")
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
|
|
124
|
+
registry_api_url = api_url or config_manager.load_config().registry_api_url
|
|
125
|
+
if not registry_api_url:
|
|
126
|
+
raise_bad_argument(
|
|
127
|
+
"Registry API URL required", "Configure the CLI or pass --api-url."
|
|
128
|
+
)
|
|
129
|
+
assert registry_api_url is not None
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
if runner:
|
|
133
|
+
profile_payload = fetch_profile_servers(
|
|
134
|
+
api_url=registry_api_url,
|
|
135
|
+
token=bearer_token,
|
|
136
|
+
tenant=tenant,
|
|
137
|
+
profile=profile,
|
|
138
|
+
)
|
|
139
|
+
generated = build_runner_export(
|
|
140
|
+
profile_payload=profile_payload,
|
|
141
|
+
client=client,
|
|
142
|
+
export_format=export_format,
|
|
143
|
+
profile=profile,
|
|
144
|
+
tenant=tenant,
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
generated = fetch_export(
|
|
148
|
+
api_url=registry_api_url,
|
|
149
|
+
token=bearer_token,
|
|
150
|
+
tenant=tenant,
|
|
151
|
+
profile=profile,
|
|
152
|
+
client=client,
|
|
153
|
+
export_format=export_format,
|
|
154
|
+
secret_mode=secret_mode,
|
|
155
|
+
)
|
|
156
|
+
existing = target.read_text() if target.exists() else ""
|
|
157
|
+
manifest = target.with_name(target.name + JSON_MANIFEST_SUFFIX)
|
|
158
|
+
previous_managed = (
|
|
159
|
+
read_json_manifest(manifest)
|
|
160
|
+
if export_format == "json"
|
|
161
|
+
else {"aliases": [], "input_ids": []}
|
|
162
|
+
)
|
|
163
|
+
patched = patch_config(
|
|
164
|
+
existing,
|
|
165
|
+
generated,
|
|
166
|
+
export_format,
|
|
167
|
+
previous_managed["aliases"],
|
|
168
|
+
previous_managed["input_ids"],
|
|
169
|
+
)
|
|
170
|
+
except AccessTokenExpired:
|
|
171
|
+
console.print(f"[red]{ACCESS_TOKEN_EXPIRED_MESSAGE}[/red]")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
console.print(f"[bold red]Integration sync failed:[/bold red] {exc}")
|
|
175
|
+
raise typer.Exit(1) from exc
|
|
176
|
+
|
|
177
|
+
if dry_run:
|
|
178
|
+
console.print(redact(patched), markup=False)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if secret_mode == "plaintext" and not dry_run and not yes and not runner:
|
|
182
|
+
confirmed = typer.confirm(
|
|
183
|
+
"This writes secret values into the client config on this machine. Continue?",
|
|
184
|
+
default=False,
|
|
185
|
+
)
|
|
186
|
+
if not confirmed:
|
|
187
|
+
console.print("[yellow]Canceled.[/yellow]")
|
|
188
|
+
raise typer.Exit(1)
|
|
189
|
+
|
|
190
|
+
secure_write_text(target, patched, executable=export_format == "shell")
|
|
191
|
+
if export_format == "json":
|
|
192
|
+
write_json_manifest(manifest, extract_json_managed(generated))
|
|
193
|
+
|
|
194
|
+
console.print(f"[green]Synced {client} profile {profile} to {target}[/green]")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def build_runner_export(
|
|
198
|
+
*,
|
|
199
|
+
profile_payload: dict[str, Any],
|
|
200
|
+
client: str,
|
|
201
|
+
export_format: str,
|
|
202
|
+
profile: str,
|
|
203
|
+
tenant: Optional[str],
|
|
204
|
+
) -> str:
|
|
205
|
+
"""Build client config that delegates MCP execution to the LightNow runner."""
|
|
206
|
+
servers = profile_payload.get("servers")
|
|
207
|
+
if not isinstance(servers, list):
|
|
208
|
+
raise ValueError("Registry API response did not include profile servers.")
|
|
209
|
+
|
|
210
|
+
entries: list[dict[str, Any]] = []
|
|
211
|
+
for item in servers:
|
|
212
|
+
if not isinstance(item, dict):
|
|
213
|
+
continue
|
|
214
|
+
alias = item.get("alias")
|
|
215
|
+
version = item.get("version")
|
|
216
|
+
status = item.get("status")
|
|
217
|
+
if not isinstance(alias, str) or alias == "":
|
|
218
|
+
raise ValueError("A profile server is missing its alias.")
|
|
219
|
+
if status != "linked" or not isinstance(version, str) or version == "":
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"Server '{alias}' must be registry-linked before local-runner sync."
|
|
222
|
+
)
|
|
223
|
+
entries.append(item)
|
|
224
|
+
|
|
225
|
+
if entries == []:
|
|
226
|
+
raise ValueError("Runtime profile does not contain runner-compatible servers.")
|
|
227
|
+
|
|
228
|
+
return render_runner_config(entries, client, export_format, profile, tenant)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def render_runner_config(
|
|
232
|
+
servers: list[dict[str, Any]],
|
|
233
|
+
client: str,
|
|
234
|
+
export_format: str,
|
|
235
|
+
profile: str,
|
|
236
|
+
tenant: Optional[str],
|
|
237
|
+
) -> str:
|
|
238
|
+
"""Render local-runner wrapper config for supported clients."""
|
|
239
|
+
if export_format == "toml" and client == "codex":
|
|
240
|
+
return render_runner_toml(servers, profile, tenant)
|
|
241
|
+
|
|
242
|
+
if export_format == "json" and client in {
|
|
243
|
+
"claude-desktop",
|
|
244
|
+
"claude-code",
|
|
245
|
+
"cursor",
|
|
246
|
+
"windsurf",
|
|
247
|
+
"librechat",
|
|
248
|
+
}:
|
|
249
|
+
return render_runner_mcp_servers_json(servers, profile, tenant)
|
|
250
|
+
|
|
251
|
+
if export_format == "json" and client == "gemini-cli":
|
|
252
|
+
return render_runner_mcp_servers_json(servers, profile, tenant)
|
|
253
|
+
|
|
254
|
+
if export_format == "json" and client == "vscode":
|
|
255
|
+
return render_runner_vscode_json(servers, profile, tenant)
|
|
256
|
+
|
|
257
|
+
if export_format == "yaml" and client == "continue":
|
|
258
|
+
return render_runner_continue_yaml(servers, profile, tenant)
|
|
259
|
+
|
|
260
|
+
if export_format == "yaml" and client == "librechat":
|
|
261
|
+
return render_runner_librechat_yaml(servers, profile, tenant)
|
|
262
|
+
|
|
263
|
+
if export_format == "shell" and client == "mcp-inspector":
|
|
264
|
+
return render_runner_inspector_shell(servers, profile, tenant)
|
|
265
|
+
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"The local runner does not support {client} with {export_format}."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def render_runner_toml(
|
|
272
|
+
servers: list[dict[str, Any]], profile: str, tenant: Optional[str]
|
|
273
|
+
) -> str:
|
|
274
|
+
"""Render Codex TOML for local-runner profile servers."""
|
|
275
|
+
lines = [
|
|
276
|
+
"# Generated by LightNow. Secrets stay in LightNow and are injected by `lightnow run`.",
|
|
277
|
+
"",
|
|
278
|
+
]
|
|
279
|
+
for server in servers:
|
|
280
|
+
alias = str(server["alias"])
|
|
281
|
+
lines.append(f"[mcp_servers.{toml_key(alias)}]")
|
|
282
|
+
lines.append('command = "lightnow"')
|
|
283
|
+
lines.append("args = " + json.dumps(runner_args(alias, profile, tenant)))
|
|
284
|
+
lines.append("")
|
|
285
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def render_runner_mcp_servers_json(
|
|
289
|
+
servers: list[dict[str, Any]], profile: str, tenant: Optional[str]
|
|
290
|
+
) -> str:
|
|
291
|
+
"""Render a JSON client config using the mcpServers shape."""
|
|
292
|
+
payload = {
|
|
293
|
+
"mcpServers": {
|
|
294
|
+
str(server["alias"]): {
|
|
295
|
+
"command": "lightnow",
|
|
296
|
+
"args": runner_args(str(server["alias"]), profile, tenant),
|
|
297
|
+
}
|
|
298
|
+
for server in servers
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return json.dumps(payload, indent=2) + "\n"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def render_runner_vscode_json(
|
|
305
|
+
servers: list[dict[str, Any]], profile: str, tenant: Optional[str]
|
|
306
|
+
) -> str:
|
|
307
|
+
"""Render VS Code's MCP server configuration shape."""
|
|
308
|
+
payload = {
|
|
309
|
+
"servers": {
|
|
310
|
+
str(server["alias"]): {
|
|
311
|
+
"type": "stdio",
|
|
312
|
+
"command": "lightnow",
|
|
313
|
+
"args": runner_args(str(server["alias"]), profile, tenant),
|
|
314
|
+
}
|
|
315
|
+
for server in servers
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return json.dumps(payload, indent=2) + "\n"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def render_runner_continue_yaml(
|
|
322
|
+
servers: list[dict[str, Any]], profile: str, tenant: Optional[str]
|
|
323
|
+
) -> str:
|
|
324
|
+
"""Render Continue YAML for local-runner profile servers."""
|
|
325
|
+
lines = [
|
|
326
|
+
"# Generated by LightNow. Secrets stay in LightNow and are injected by `lightnow run`.",
|
|
327
|
+
"mcpServers:",
|
|
328
|
+
]
|
|
329
|
+
for server in servers:
|
|
330
|
+
alias = str(server["alias"])
|
|
331
|
+
lines.append(f" - name: {yaml_string(alias)}")
|
|
332
|
+
lines.append(' command: "lightnow"')
|
|
333
|
+
lines.append(" args:")
|
|
334
|
+
for arg in runner_args(alias, profile, tenant):
|
|
335
|
+
lines.append(f" - {yaml_string(arg)}")
|
|
336
|
+
return "\n".join(lines) + "\n"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def render_runner_librechat_yaml(
|
|
340
|
+
servers: list[dict[str, Any]], profile: str, tenant: Optional[str]
|
|
341
|
+
) -> str:
|
|
342
|
+
"""Render LibreChat YAML for local-runner profile servers."""
|
|
343
|
+
lines = [
|
|
344
|
+
"# Generated by LightNow. Secrets stay in LightNow and are injected by `lightnow run`.",
|
|
345
|
+
"mcpServers:",
|
|
346
|
+
]
|
|
347
|
+
for server in servers:
|
|
348
|
+
alias = str(server["alias"])
|
|
349
|
+
lines.append(f" {yaml_key(alias)}:")
|
|
350
|
+
lines.append(' command: "lightnow"')
|
|
351
|
+
lines.append(" args:")
|
|
352
|
+
for arg in runner_args(alias, profile, tenant):
|
|
353
|
+
lines.append(f" - {yaml_string(arg)}")
|
|
354
|
+
return "\n".join(lines) + "\n"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def render_runner_inspector_shell(
|
|
358
|
+
servers: list[dict[str, Any]], profile: str, tenant: Optional[str]
|
|
359
|
+
) -> str:
|
|
360
|
+
"""Render an MCP Inspector shell wrapper for the first profile server."""
|
|
361
|
+
first_alias = str(servers[0]["alias"])
|
|
362
|
+
quoted = " ".join(
|
|
363
|
+
shell_quote(arg) for arg in runner_args(first_alias, profile, tenant)
|
|
364
|
+
)
|
|
365
|
+
return "#!/usr/bin/env sh\nexec lightnow " + quoted + ' "$@"\n'
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def runner_args(alias: str, profile: str, tenant: Optional[str]) -> list[str]:
|
|
369
|
+
"""Return command arguments for the LightNow local runner."""
|
|
370
|
+
args = ["run", "--profile", profile, "--server", alias]
|
|
371
|
+
if tenant:
|
|
372
|
+
args.extend(["--tenant", tenant])
|
|
373
|
+
return args
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def toml_key(value: str) -> str:
|
|
377
|
+
"""Return a TOML key for a server alias."""
|
|
378
|
+
if value.replace("_", "").replace("-", "").isalnum():
|
|
379
|
+
return value
|
|
380
|
+
return json.dumps(value)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def yaml_key(value: str) -> str:
|
|
384
|
+
"""Return a YAML-safe mapping key."""
|
|
385
|
+
if value.replace("_", "").replace("-", "").isalnum():
|
|
386
|
+
return value
|
|
387
|
+
return yaml_string(value)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def yaml_string(value: str) -> str:
|
|
391
|
+
"""Return a YAML double-quoted string."""
|
|
392
|
+
return json.dumps(value)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def shell_quote(value: str) -> str:
|
|
396
|
+
"""Quote a shell argument."""
|
|
397
|
+
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def fetch_export(
|
|
401
|
+
*,
|
|
402
|
+
api_url: str,
|
|
403
|
+
token: str,
|
|
404
|
+
tenant: Optional[str],
|
|
405
|
+
profile: str,
|
|
406
|
+
client: str,
|
|
407
|
+
export_format: str,
|
|
408
|
+
secret_mode: str,
|
|
409
|
+
) -> str:
|
|
410
|
+
"""Fetch rendered client config from the Registry API."""
|
|
411
|
+
url = f"{api_url.rstrip('/')}/integrations/profiles/{profile}/export"
|
|
412
|
+
try:
|
|
413
|
+
response = request_with_refresh(
|
|
414
|
+
"GET",
|
|
415
|
+
url,
|
|
416
|
+
params={
|
|
417
|
+
"client": client,
|
|
418
|
+
"format": export_format,
|
|
419
|
+
"secret_mode": secret_mode,
|
|
420
|
+
},
|
|
421
|
+
headers={"Accept": "application/json"},
|
|
422
|
+
token=token,
|
|
423
|
+
tenant=tenant,
|
|
424
|
+
timeout=30.0,
|
|
425
|
+
)
|
|
426
|
+
except httpx.RequestError as exc:
|
|
427
|
+
raise ValueError(f"Network error: {exc}") from exc
|
|
428
|
+
|
|
429
|
+
if response.status_code == 401:
|
|
430
|
+
raise authentication_error_from_response(response)
|
|
431
|
+
|
|
432
|
+
if response.status_code >= 400:
|
|
433
|
+
raise ValueError(f"HTTP {response.status_code}: {redact(response.text)}")
|
|
434
|
+
|
|
435
|
+
payload = response.json()
|
|
436
|
+
export = payload.get("export") if isinstance(payload, dict) else None
|
|
437
|
+
content = export.get("content") if isinstance(export, dict) else None
|
|
438
|
+
if not isinstance(content, str):
|
|
439
|
+
raise ValueError("Registry API response did not include export content.")
|
|
440
|
+
return content
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def patch_config(
|
|
444
|
+
existing: str,
|
|
445
|
+
generated: str,
|
|
446
|
+
export_format: str,
|
|
447
|
+
previous_aliases: Optional[list[str]] = None,
|
|
448
|
+
previous_input_ids: Optional[list[str]] = None,
|
|
449
|
+
) -> str:
|
|
450
|
+
"""Patch the target config with generated LightNow content."""
|
|
451
|
+
if export_format == "json":
|
|
452
|
+
return patch_json_config(
|
|
453
|
+
existing,
|
|
454
|
+
generated,
|
|
455
|
+
previous_aliases or [],
|
|
456
|
+
previous_input_ids or [],
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
generated_block = generated.strip()
|
|
460
|
+
if BEGIN in generated_block and END in generated_block:
|
|
461
|
+
block = generated_block + "\n"
|
|
462
|
+
else:
|
|
463
|
+
block = f"{BEGIN}\n{generated.rstrip()}\n{END}\n"
|
|
464
|
+
if BEGIN in existing and END in existing:
|
|
465
|
+
before, rest = existing.split(BEGIN, 1)
|
|
466
|
+
_, after = rest.split(END, 1)
|
|
467
|
+
return before.rstrip() + "\n\n" + block + after.lstrip()
|
|
468
|
+
if existing.strip() == "":
|
|
469
|
+
return block
|
|
470
|
+
return existing.rstrip() + "\n\n" + block
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def patch_json_config(
|
|
474
|
+
existing: str,
|
|
475
|
+
generated: str,
|
|
476
|
+
previous_aliases: list[str],
|
|
477
|
+
previous_input_ids: Optional[list[str]] = None,
|
|
478
|
+
) -> str:
|
|
479
|
+
"""Patch JSON client configs while preserving user-managed entries."""
|
|
480
|
+
current = json.loads(existing) if existing.strip() else {}
|
|
481
|
+
incoming = json.loads(generated)
|
|
482
|
+
if not isinstance(current, dict) or not isinstance(incoming, dict):
|
|
483
|
+
raise ValueError("LightNow can only sync JSON object client configs.")
|
|
484
|
+
|
|
485
|
+
old_input_ids = set(previous_input_ids or [])
|
|
486
|
+
for key in ("mcpServers", "servers"):
|
|
487
|
+
if key not in incoming:
|
|
488
|
+
continue
|
|
489
|
+
current_section = current.get(key)
|
|
490
|
+
incoming_section = incoming[key]
|
|
491
|
+
if current_section is None:
|
|
492
|
+
current_section = {}
|
|
493
|
+
if not isinstance(current_section, dict) or not isinstance(
|
|
494
|
+
incoming_section, dict
|
|
495
|
+
):
|
|
496
|
+
raise ValueError(f"LightNow can only sync JSON object field {key}.")
|
|
497
|
+
for alias in previous_aliases:
|
|
498
|
+
current_section.pop(alias, None)
|
|
499
|
+
current_section.update(incoming_section)
|
|
500
|
+
current[key] = current_section
|
|
501
|
+
|
|
502
|
+
if "inputs" in incoming:
|
|
503
|
+
incoming_inputs = incoming["inputs"]
|
|
504
|
+
current_inputs = current.get("inputs", [])
|
|
505
|
+
if not isinstance(incoming_inputs, list) or not isinstance(
|
|
506
|
+
current_inputs, list
|
|
507
|
+
):
|
|
508
|
+
raise ValueError("LightNow can only sync JSON array field inputs.")
|
|
509
|
+
|
|
510
|
+
merged_inputs = [
|
|
511
|
+
item
|
|
512
|
+
for item in current_inputs
|
|
513
|
+
if not (
|
|
514
|
+
isinstance(item, dict)
|
|
515
|
+
and isinstance(item.get("id"), str)
|
|
516
|
+
and item["id"] in old_input_ids
|
|
517
|
+
)
|
|
518
|
+
]
|
|
519
|
+
incoming_ids: set[str] = set()
|
|
520
|
+
for item in incoming_inputs:
|
|
521
|
+
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
|
522
|
+
raise ValueError("LightNow generated an invalid JSON input entry.")
|
|
523
|
+
incoming_ids.add(item["id"])
|
|
524
|
+
|
|
525
|
+
merged_inputs = [
|
|
526
|
+
item
|
|
527
|
+
for item in merged_inputs
|
|
528
|
+
if not (
|
|
529
|
+
isinstance(item, dict)
|
|
530
|
+
and isinstance(item.get("id"), str)
|
|
531
|
+
and item["id"] in incoming_ids
|
|
532
|
+
)
|
|
533
|
+
]
|
|
534
|
+
merged_inputs.extend(incoming_inputs)
|
|
535
|
+
current["inputs"] = merged_inputs
|
|
536
|
+
|
|
537
|
+
return json.dumps(current, indent=2, ensure_ascii=False) + "\n"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def extract_json_managed(generated: str) -> dict[str, list[str]]:
|
|
541
|
+
"""Return JSON aliases and input IDs owned by the latest generated export."""
|
|
542
|
+
incoming = json.loads(generated)
|
|
543
|
+
if not isinstance(incoming, dict):
|
|
544
|
+
return {"aliases": [], "input_ids": []}
|
|
545
|
+
|
|
546
|
+
aliases: list[str] = []
|
|
547
|
+
for key in ("mcpServers", "servers"):
|
|
548
|
+
section = incoming.get(key)
|
|
549
|
+
if isinstance(section, dict):
|
|
550
|
+
aliases.extend(str(alias) for alias in section)
|
|
551
|
+
input_ids: list[str] = []
|
|
552
|
+
inputs = incoming.get("inputs")
|
|
553
|
+
if isinstance(inputs, list):
|
|
554
|
+
for item in inputs:
|
|
555
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
556
|
+
input_ids.append(item["id"])
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
"aliases": sorted(set(aliases)),
|
|
560
|
+
"input_ids": sorted(set(input_ids)),
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def extract_json_aliases(generated: str) -> list[str]:
|
|
565
|
+
"""Return aliases owned by the latest generated JSON export."""
|
|
566
|
+
return extract_json_managed(generated)["aliases"]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def read_json_manifest(path: Path) -> dict[str, list[str]]:
|
|
570
|
+
"""Read the sibling JSON manifest containing LightNow-managed aliases."""
|
|
571
|
+
if not path.exists():
|
|
572
|
+
return {"aliases": [], "input_ids": []}
|
|
573
|
+
try:
|
|
574
|
+
data = json.loads(path.read_text())
|
|
575
|
+
except json.JSONDecodeError as exc:
|
|
576
|
+
raise ValueError(f"LightNow managed JSON manifest is invalid: {path}") from exc
|
|
577
|
+
aliases = data.get("aliases") if isinstance(data, dict) else None
|
|
578
|
+
input_ids = data.get("input_ids") if isinstance(data, dict) else []
|
|
579
|
+
if not isinstance(aliases, list) or not all(
|
|
580
|
+
isinstance(alias, str) for alias in aliases
|
|
581
|
+
):
|
|
582
|
+
raise ValueError(f"LightNow managed JSON manifest has invalid aliases: {path}")
|
|
583
|
+
if not isinstance(input_ids, list) or not all(
|
|
584
|
+
isinstance(input_id, str) for input_id in input_ids
|
|
585
|
+
):
|
|
586
|
+
raise ValueError(
|
|
587
|
+
f"LightNow managed JSON manifest has invalid input IDs: {path}"
|
|
588
|
+
)
|
|
589
|
+
return {"aliases": aliases, "input_ids": input_ids}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def write_json_manifest(path: Path, managed: dict[str, list[str]]) -> None:
|
|
593
|
+
"""Persist the aliases owned by the latest LightNow JSON sync."""
|
|
594
|
+
secure_write_text(path, json.dumps(managed, indent=2) + "\n")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def secure_write_text(path: Path, content: str, *, executable: bool = False) -> None:
|
|
598
|
+
"""Atomically write secret-bearing client config with restrictive permissions."""
|
|
599
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
600
|
+
mode = 0o700 if executable else 0o600
|
|
601
|
+
if path.exists():
|
|
602
|
+
backup = path.with_suffix(path.suffix + ".lightnow.bak")
|
|
603
|
+
shutil.copy2(path, backup)
|
|
604
|
+
backup.chmod(mode)
|
|
605
|
+
|
|
606
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
607
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True
|
|
608
|
+
)
|
|
609
|
+
tmp_path = Path(tmp_name)
|
|
610
|
+
try:
|
|
611
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
612
|
+
os.fchmod(handle.fileno(), mode)
|
|
613
|
+
handle.write(content)
|
|
614
|
+
handle.flush()
|
|
615
|
+
os.fsync(handle.fileno())
|
|
616
|
+
os.replace(tmp_path, path)
|
|
617
|
+
path.chmod(mode)
|
|
618
|
+
dir_fd = os.open(path.parent, os.O_RDONLY)
|
|
619
|
+
try:
|
|
620
|
+
os.fsync(dir_fd)
|
|
621
|
+
finally:
|
|
622
|
+
os.close(dir_fd)
|
|
623
|
+
except Exception:
|
|
624
|
+
try:
|
|
625
|
+
tmp_path.unlink()
|
|
626
|
+
except FileNotFoundError:
|
|
627
|
+
pass
|
|
628
|
+
raise
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def redact(value: str) -> str:
|
|
632
|
+
"""Redact secret-like lines from terminal output."""
|
|
633
|
+
redacted = value
|
|
634
|
+
for marker in ("TOKEN", "SECRET", "PASSWORD", "PWD", "KEY"):
|
|
635
|
+
redacted = redact_marker(redacted, marker)
|
|
636
|
+
return redacted
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def redact_marker(value: str, marker: str) -> str:
|
|
640
|
+
"""Redact lines containing common secret markers unless they are placeholders."""
|
|
641
|
+
lines = []
|
|
642
|
+
for line in value.splitlines():
|
|
643
|
+
upper = line.upper()
|
|
644
|
+
if marker in upper and "${" not in line:
|
|
645
|
+
if "=" in line:
|
|
646
|
+
key, _ = line.split("=", 1)
|
|
647
|
+
line = key + '= "[REDACTED]"'
|
|
648
|
+
elif ":" in line:
|
|
649
|
+
key, _ = line.split(":", 1)
|
|
650
|
+
line = key + ': "[REDACTED]"'
|
|
651
|
+
lines.append(line)
|
|
652
|
+
return "\n".join(lines)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def raise_bad_argument(title: str, detail: str) -> None:
|
|
656
|
+
"""Print a consistent argument error and exit."""
|
|
657
|
+
console.print(f"[bold red]{title}:[/bold red] {detail}")
|
|
658
|
+
raise typer.Exit(2)
|