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.
@@ -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)