lightnow-cli 1.0.5__tar.gz → 1.2.0__tar.gz

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.
Files changed (46) hide show
  1. lightnow_cli-1.2.0/.github/workflows/release-please.yml +24 -0
  2. lightnow_cli-1.2.0/.release-please-manifest.json +3 -0
  3. lightnow_cli-1.2.0/CHANGELOG.md +58 -0
  4. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/PKG-INFO +9 -1
  5. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/README.md +4 -0
  6. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/docs/development.md +17 -2
  7. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/docs/release.md +25 -1
  8. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/__init__.py +1 -1
  9. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/auth.py +26 -7
  10. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/integrations.py +228 -8
  11. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/runner.py +181 -1
  12. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/pyproject.toml +6 -1
  13. lightnow_cli-1.2.0/release-please-config.json +18 -0
  14. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_auth_flow.py +23 -0
  15. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_cli.py +32 -1
  16. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_integrations.py +522 -1
  17. lightnow_cli-1.0.5/CHANGELOG.md +0 -30
  18. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/.github/workflows/ci.yml +0 -0
  19. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/.github/workflows/release.yml +0 -0
  20. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/.gitignore +0 -0
  21. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/CONTRIBUTING.md +0 -0
  22. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/LICENSE +0 -0
  23. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/Makefile +0 -0
  24. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/SECURITY.md +0 -0
  25. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/examples/README.md +0 -0
  26. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/examples/docs.md +0 -0
  27. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/examples/openapi.json +0 -0
  28. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/examples/server.json +0 -0
  29. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/authenticated_http.py +0 -0
  30. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/client.py +0 -0
  31. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/__init__.py +0 -0
  32. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/context.py +0 -0
  33. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/publish.py +0 -0
  34. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/query.py +0 -0
  35. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/validate.py +0 -0
  36. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/config.py +0 -0
  37. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/main.py +0 -0
  38. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/tls.py +0 -0
  39. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/validation.py +0 -0
  40. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/__init__.py +0 -0
  41. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_authenticated_http.py +0 -0
  42. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_client.py +0 -0
  43. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_config.py +0 -0
  44. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_context.py +0 -0
  45. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_tls.py +0 -0
  46. {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_validation.py +0 -0
@@ -0,0 +1,24 @@
1
+ name: Release Please
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+ issues: write
11
+ pull-requests: write
12
+
13
+ jobs:
14
+ release-please:
15
+ name: Open or tag release PR
16
+ runs-on: ubuntu-latest
17
+
18
+ steps:
19
+ - name: Run Release Please
20
+ uses: googleapis/release-please-action@v4
21
+ with:
22
+ token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
23
+ config-file: release-please-config.json
24
+ manifest-file: .release-please-manifest.json
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.2.0"
3
+ }
@@ -0,0 +1,58 @@
1
+ # Changelog
2
+
3
+ All notable changes to the LightNow CLI are documented here.
4
+
5
+ This project follows semantic versioning.
6
+
7
+ ## [1.2.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.1.0...v1.2.0) (2026-07-12)
8
+
9
+
10
+ ### Features
11
+
12
+ * **#5:** support runtime Vault bindings ([7750c27](https://github.com/lightnow-ai/lightnow-cli/commit/7750c27b87567012eadb6e96a934a25d5d205b0e))
13
+ * **secrets:** support runtime Vault bindings ([7cacff6](https://github.com/lightnow-ai/lightnow-cli/commit/7cacff66c162c1c320fef5195c6acd0f3a77ca45))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **#4:** fix/empty mcp server sync ([a2865bf](https://github.com/lightnow-ai/lightnow-cli/commit/a2865bf21ba7fba7b7bb7115388ad45f153c3280))
19
+ * **sync:** handle empty MCP server profiles ([065d940](https://github.com/lightnow-ai/lightnow-cli/commit/065d940ea0d7a4016705a8fb124ebdc07d204a7f))
20
+
21
+ ## [1.1.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.0.5...v1.1.0) (2026-07-07)
22
+
23
+
24
+ ### Features
25
+
26
+ * add whoami json output ([9eab480](https://github.com/lightnow-ai/lightnow-cli/commit/9eab480b85765fa50506f4d7126790c2a3ebd094))
27
+
28
+
29
+ ### Bug Fixes
30
+
31
+ * **#2:** fix import wizard client configs ([ae0f0b4](https://github.com/lightnow-ai/lightnow-cli/commit/ae0f0b4931dcb1516be65473df3ca73397d6c16e))
32
+ * support wizard client config imports ([35a7347](https://github.com/lightnow-ai/lightnow-cli/commit/35a7347fa438af6e4d9fb754f87a7a5d6a709548))
33
+ * write default local proxy config alias ([47223f6](https://github.com/lightnow-ai/lightnow-cli/commit/47223f62b96ef0790d027ada11abf84459eb63fb))
34
+
35
+ ## 1.0.0 - Unreleased
36
+
37
+ Initial stable release candidate.
38
+
39
+ ### Added
40
+
41
+ - OIDC Device Code login for LightNow.
42
+ - Automatic access-token refresh using the stored refresh token.
43
+ - `status`, `whoami`, and `logout` account commands.
44
+ - MCP registry discovery with `search`, `favorites`, and `info`.
45
+ - MCP publishing and local validation commands.
46
+ - Integration profile sync for Codex, Claude, Cursor, Windsurf, Continue,
47
+ Gemini CLI, LibreChat, VS Code, and MCP Inspector targets.
48
+ - Client-side sync with explicit plaintext-secret confirmation.
49
+ - Placeholder sync for secret-safe client configs.
50
+ - Local runner mode for resolving LightNow-managed secrets at process runtime.
51
+ - Atomic client-config writes with restrictive permissions.
52
+ - Secret redaction for dry-run previews, errors, and terminal output.
53
+
54
+ ### Security
55
+
56
+ - Device authorization uses PKCE.
57
+ - API calls use shared 401 handling and refresh retry behavior.
58
+ - Expired sessions use consistent messaging across commands.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightnow-cli
3
- Version: 1.0.5
3
+ Version: 1.2.0
4
4
  Summary: Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows
5
5
  Project-URL: Homepage, https://github.com/lightnow-ai/lightnow-cli
6
6
  Project-URL: Repository, https://github.com/lightnow-ai/lightnow-cli
@@ -38,6 +38,10 @@ Requires-Dist: pytest-mock>=3.10.0; extra == 'dev'
38
38
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
39
39
  Requires-Dist: responses>=0.23.0; extra == 'dev'
40
40
  Requires-Dist: twine>=5.0.0; extra == 'dev'
41
+ Requires-Dist: types-jsonschema>=4.25.0; extra == 'dev'
42
+ Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
43
+ Provides-Extra: keyring
44
+ Requires-Dist: keyring>=25.6.0; extra == 'keyring'
41
45
  Description-Content-Type: text/markdown
42
46
 
43
47
  # LightNow CLI
@@ -170,6 +174,10 @@ Let LightNow choose the configured organization policy for a client:
170
174
  lightnow sync --client codex --from-settings
171
175
  ```
172
176
 
177
+ When LightNow Config blocks unmanaged MCP servers, managed clients keep only the
178
+ `LightNow` entry. When unmanaged servers are allowed, existing user-managed MCP
179
+ entries stay in the client config and `config-status` reports `mixed`.
180
+
173
181
  Check whether a client is configured as expected:
174
182
 
175
183
  ```bash
@@ -128,6 +128,10 @@ Let LightNow choose the configured organization policy for a client:
128
128
  lightnow sync --client codex --from-settings
129
129
  ```
130
130
 
131
+ When LightNow Config blocks unmanaged MCP servers, managed clients keep only the
132
+ `LightNow` entry. When unmanaged servers are allowed, existing user-managed MCP
133
+ entries stay in the client config and `config-status` reports `mixed`.
134
+
131
135
  Check whether a client is configured as expected:
132
136
 
133
137
  ```bash
@@ -55,13 +55,28 @@ lightnow config-status --client antigravity --json
55
55
 
56
56
  Expected states:
57
57
 
58
- - `managed`: only the LightNow Local Proxy entry is configured.
58
+ - `managed`: the LightNow Local Proxy entry is configured and no unmanaged
59
+ user MCP servers are present. Client-internal MCP entries may be reported
60
+ separately as `internal_servers`.
59
61
  - `mixed`: LightNow Local Proxy exists, but unmanaged MCP entries are also
60
- present.
62
+ present. This is acceptable when LightNow Config allows unmanaged client
63
+ servers, but those entries bypass LightNow monitoring and policy.
61
64
  - `unmanaged`: MCP entries exist, but none point at the LightNow Local Proxy.
62
65
  - `legacy_runner`: older per-server `lightnow run` wrappers are still present.
63
66
  - `missing`, `invalid` or `unreadable`: the client config cannot be used as-is.
64
67
 
68
+ For settings-driven rollout, `lightnow sync --client <client> --from-settings`
69
+ uses the tenant policy. Managed clients remove unmanaged MCP entries only when
70
+ `allowUnmanagedClientServers` is false. If the policy allows unmanaged entries,
71
+ sync preserves them and the posture remains `mixed`.
72
+
73
+ When a Local Proxy config exists, `config-status` also reads the non-secret
74
+ runtime posture from that file: selected profile, client identity, client
75
+ transport, telemetry flag, policy mode, and whether unmanaged client MCP
76
+ servers are allowed. If policy mode is `enforce`, unmanaged entries are blocked
77
+ by policy, and the client config still contains unmanaged MCP servers, the
78
+ command reports `policy_blocks_unmanaged_servers`.
79
+
65
80
  The command must not print secrets, headers or payloads. It is the local building
66
81
  block for future UI-led enablement and enterprise posture reporting.
67
82
 
@@ -11,7 +11,31 @@ Use these settings for the public repository:
11
11
 
12
12
  ## Checklist
13
13
 
14
- Before publishing a release:
14
+ Release Please owns normal version bumps, changelog updates, Git tags, and
15
+ GitHub Releases from Conventional Commits.
16
+
17
+ Required GitHub setup:
18
+
19
+ - Repository secret: `RELEASE_PLEASE_TOKEN`
20
+ - Token permissions: contents, pull requests, and issues read/write for this
21
+ repository
22
+ - Repository Actions setting: allow GitHub Actions to create pull requests
23
+
24
+ Use a real PAT or GitHub App token for `RELEASE_PLEASE_TOKEN`, not the default
25
+ `GITHUB_TOKEN`. Tags created with the default token do not trigger the existing
26
+ tag-based `Release` workflow, so PyPI publishing would not run.
27
+
28
+ Normal release flow:
29
+
30
+ 1. Merge Conventional Commit changes to `main`.
31
+ 2. Release Please opens or updates a release PR.
32
+ 3. Merge the release PR when ready.
33
+ 4. Release Please creates the `vX.Y.Z` tag and GitHub Release.
34
+ 5. The existing tag-based `Release` workflow builds and publishes PyPI
35
+ distributions through Trusted Publishing.
36
+ 6. Update the Homebrew tap after the release artifact is available.
37
+
38
+ Manual release checklist for fallback or first-time setup:
15
39
 
16
40
  1. Update `pyproject.toml` and `lightnow_cli/__init__.py` to the same
17
41
  semantic version.
@@ -1,3 +1,3 @@
1
1
  """LightNow CLI - A command-line tool for managing MCP servers."""
2
2
 
3
- __version__ = "1.0.5"
3
+ __version__ = "1.2.0" # x-release-please-version
@@ -3,6 +3,7 @@
3
3
  import asyncio
4
4
  import base64
5
5
  import hashlib
6
+ import json
6
7
  import secrets
7
8
  import time
8
9
  import webbrowser
@@ -457,10 +458,19 @@ def status() -> None:
457
458
  console.print(f"Token expires: [dim]{exp_time}[/dim]")
458
459
 
459
460
 
460
- def whoami() -> None:
461
+ def whoami(
462
+ json_output: Annotated[
463
+ bool,
464
+ typer.Option("--json", help="Print machine-readable identity JSON"),
465
+ ] = False,
466
+ ) -> None:
461
467
  """Show current user information."""
462
468
  user_info = current_user_info()
463
469
 
470
+ if json_output:
471
+ typer.echo(json.dumps(user_info, indent=2, sort_keys=True))
472
+ return
473
+
464
474
  # Display user information
465
475
  console.print("[bold blue]Current User Information:[/bold blue]")
466
476
 
@@ -488,6 +498,14 @@ def whoami() -> None:
488
498
 
489
499
  def current_user_info() -> Dict[str, Any]:
490
500
  """Return current user info after verifying or refreshing the access token."""
501
+
502
+ def fallback_claims_or_exit(token_value: str, exc: AuthError) -> Dict[str, Any]:
503
+ claims = get_user_info(token_value)
504
+ if claims is not None and claims.get("sub"):
505
+ return claims
506
+ console.print(f"[red]{exc}[/red]")
507
+ raise typer.Exit(1) from exc
508
+
491
509
  try:
492
510
  token = require_access_token()
493
511
  except AccessTokenExpired as exc:
@@ -504,16 +522,17 @@ def current_user_info() -> Dict[str, Any]:
504
522
  try:
505
523
  token = refresh_current_session()
506
524
  config = config_manager.load_config()
507
- return asyncio.run(fetch_user_info(config.issuer or DEFAULT_ISSUER, token))
525
+ try:
526
+ return asyncio.run(
527
+ fetch_user_info(config.issuer or DEFAULT_ISSUER, token)
528
+ )
529
+ except AuthError as exc:
530
+ return fallback_claims_or_exit(token, exc)
508
531
  except AccessTokenExpired as exc:
509
532
  console.print(f"[red]{exc}[/red]")
510
533
  raise typer.Exit(1) from exc
511
- except AuthError as exc:
512
- console.print(f"[red]{exc}[/red]")
513
- raise typer.Exit(1) from exc
514
534
  except AuthError as exc:
515
- console.print(f"[red]{exc}[/red]")
516
- raise typer.Exit(1) from exc
535
+ return fallback_claims_or_exit(token, exc)
517
536
 
518
537
 
519
538
  def logout() -> None:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import re
8
+ import shlex
8
9
  import shutil
9
10
  import subprocess
10
11
  import sys
@@ -82,6 +83,14 @@ CLIENT_DEFAULTS: dict[str, tuple[str, Path]] = {
82
83
  }
83
84
 
84
85
  CLIENTS = sorted(CLIENT_DEFAULTS)
86
+ IMPORT_CLIENTS = {
87
+ "antigravity",
88
+ "claude-code",
89
+ "claude-desktop",
90
+ "codex",
91
+ "cursor",
92
+ "vscode",
93
+ }
85
94
  SECRET_MODES = ["placeholder", "plaintext"]
86
95
  LOCAL_PROXY_MCP_SERVERS_JSON_CLIENTS = {
87
96
  "antigravity",
@@ -102,7 +111,6 @@ LIGHTNOW_PROXY_ALIASES = {"lightnow", "LightNow"}
102
111
  CLIENT_INTERNAL_MCP_SERVERS = {
103
112
  "codex": {"node_repl"},
104
113
  }
105
- LOCAL_PROXY_RUNNER_VERSION = "0.1.2"
106
114
  VSCODE_VIRTUAL_TOOLS_THRESHOLD_SETTING = "github.copilot.chat.virtualTools.threshold"
107
115
  VSCODE_VIRTUAL_TOOLS_THRESHOLD = 128
108
116
  LOCAL_PROXY_STDIO_SUPPORT_MESSAGE = (
@@ -125,6 +133,27 @@ def default_local_proxy_config_path(client: str) -> Path:
125
133
  return DEFAULT_LOCAL_PROXY_CONFIG_DIR / f"{safe_client}.yaml"
126
134
 
127
135
 
136
+ def default_local_proxy_profile_config_path(profile: str = "default") -> Path:
137
+ """Return the profile-level Local Proxy config path used by proxy defaults."""
138
+ safe_profile = re.sub(r"[^A-Za-z0-9_.-]+", "-", profile).strip("-") or "default"
139
+ return DEFAULT_LOCAL_PROXY_CONFIG_DIR / f"{safe_profile}.yaml"
140
+
141
+
142
+ def local_proxy_default_config_alias(
143
+ *,
144
+ profile: str,
145
+ explicit_config_path: Optional[Path],
146
+ proxy_target: Path,
147
+ ) -> Optional[Path]:
148
+ """Return the default health-check alias path that should mirror a sync."""
149
+ if explicit_config_path is not None or profile != "default":
150
+ return None
151
+ alias = default_local_proxy_profile_config_path(profile).expanduser()
152
+ if alias == proxy_target.expanduser():
153
+ return None
154
+ return alias
155
+
156
+
128
157
  def discover_local_lightnow_ca_file(registry_api_url: str) -> Optional[Path]:
129
158
  """Find the local LightNow CA for local development Registry API URLs."""
130
159
  parsed = urlparse(registry_api_url)
@@ -194,6 +223,70 @@ def direct_server_aliases(content: str, export_format: str) -> list[str]:
194
223
  return sorted(alias for alias in entries if alias not in LIGHTNOW_PROXY_ALIASES)
195
224
 
196
225
 
226
+ def json_server_maps_are_empty(content: str) -> bool:
227
+ """Return whether a JSON export contains only empty server maps."""
228
+ payload = json.loads(content)
229
+ if not isinstance(payload, dict):
230
+ return False
231
+ sections = [payload[key] for key in ("mcpServers", "servers") if key in payload]
232
+ return bool(sections) and all(
233
+ isinstance(section, dict) and not section for section in sections
234
+ )
235
+
236
+
237
+ def empty_profile_import_command(
238
+ *,
239
+ client: str,
240
+ profile: str,
241
+ target: Path,
242
+ tenant: Optional[str],
243
+ api_url: Optional[str],
244
+ ) -> str:
245
+ """Build a copyable command that previews importing an existing client config."""
246
+ args = [
247
+ "lightnow",
248
+ "import-config",
249
+ "--client",
250
+ client,
251
+ "--profile",
252
+ profile,
253
+ "--config-path",
254
+ str(target),
255
+ "--dry-run",
256
+ ]
257
+ if tenant:
258
+ args.extend(["--tenant", tenant])
259
+ if api_url:
260
+ args.extend(["--api-url", api_url])
261
+ return shlex.join(args)
262
+
263
+
264
+ def print_empty_profile_import_hint(
265
+ *,
266
+ client: str,
267
+ profile: str,
268
+ target: Path,
269
+ aliases: list[str],
270
+ tenant: Optional[str],
271
+ api_url: Optional[str],
272
+ ) -> None:
273
+ """Explain that existing client servers were preserved and can be imported."""
274
+ err_console.print(
275
+ f"[yellow]LightNow profile {profile} has no MCP servers, but {client} already "
276
+ f"configures: {', '.join(aliases)}. These existing client entries were kept.[/yellow]"
277
+ )
278
+ command = empty_profile_import_command(
279
+ client=client,
280
+ profile=profile,
281
+ target=target,
282
+ tenant=tenant,
283
+ api_url=api_url,
284
+ )
285
+ err_console.print(
286
+ f"[cyan]To preview importing them into LightNow, run:[/cyan] {command}"
287
+ )
288
+
289
+
197
290
  @app.command("sync")
198
291
  def sync(
199
292
  client: Annotated[str, typer.Option("--client", help="Target MCP client")],
@@ -326,8 +419,12 @@ def sync(
326
419
  proxy_target = (
327
420
  local_proxy_config_path or default_local_proxy_config_path(client)
328
421
  ).expanduser()
422
+ proxy_default_alias: Optional[Path] = None
329
423
  settings_local_proxy_summary: dict[str, Any] = {}
330
424
  removed_direct_servers: list[str] = []
425
+ empty_profile_existing_aliases: list[str] = []
426
+ remove_unmanaged_client_servers = local_proxy
427
+ policy_managed_sync = False
331
428
 
332
429
  try:
333
430
  if from_settings:
@@ -348,6 +445,10 @@ def sync(
348
445
  )
349
446
  if local_proxy_settings.get("enabled") is True and client_is_managed:
350
447
  local_proxy = True
448
+ policy_managed_sync = True
449
+ remove_unmanaged_client_servers = (
450
+ local_proxy_settings.get("allowUnmanagedClientServers") is False
451
+ )
351
452
  configured_profile = local_proxy_settings.get("profile")
352
453
  if isinstance(configured_profile, str) and configured_profile:
353
454
  profile = configured_profile
@@ -355,6 +456,11 @@ def sync(
355
456
  profile = str(settings_payload["defaultProfile"])
356
457
 
357
458
  if local_proxy:
459
+ proxy_default_alias = local_proxy_default_config_alias(
460
+ profile=profile,
461
+ explicit_config_path=local_proxy_config_path,
462
+ proxy_target=proxy_target,
463
+ )
358
464
  generated = build_local_proxy_export(
359
465
  client=client,
360
466
  export_format=export_format,
@@ -373,6 +479,7 @@ def sync(
373
479
  local_proxy_settings=(
374
480
  settings_local_proxy_summary if from_settings else None
375
481
  ),
482
+ runtime_secrets=read_local_runtime_secrets(proxy_target),
376
483
  )
377
484
  elif runner:
378
485
  profile_payload = fetch_profile_servers(
@@ -405,12 +512,18 @@ def sync(
405
512
  if export_format == "json"
406
513
  else {"aliases": [], "input_ids": []}
407
514
  )
408
- if local_proxy:
515
+ if local_proxy and remove_unmanaged_client_servers:
409
516
  removed_direct_servers = direct_server_aliases(existing, export_format)
410
- if local_proxy and client == "codex" and export_format == "toml":
517
+ if (
518
+ local_proxy
519
+ and remove_unmanaged_client_servers
520
+ and client == "codex"
521
+ and export_format == "toml"
522
+ ):
411
523
  existing = prepare_codex_local_proxy_config(existing)
412
524
  if (
413
525
  local_proxy
526
+ and remove_unmanaged_client_servers
414
527
  and client in LOCAL_PROXY_JSON_CLIENTS
415
528
  and export_format == "json"
416
529
  ):
@@ -422,6 +535,18 @@ def sync(
422
535
  previous_managed["aliases"],
423
536
  previous_managed["input_ids"],
424
537
  )
538
+ if (
539
+ not local_proxy
540
+ and not runner
541
+ and export_format == "json"
542
+ and json_server_maps_are_empty(generated)
543
+ ):
544
+ managed_aliases = set(previous_managed["aliases"])
545
+ empty_profile_existing_aliases = [
546
+ alias
547
+ for alias in direct_server_aliases(existing, export_format)
548
+ if alias not in managed_aliases
549
+ ]
425
550
  except AccessTokenExpired:
426
551
  console.print(f"[red]{ACCESS_TOKEN_EXPIRED_MESSAGE}[/red]")
427
552
  raise typer.Exit(1)
@@ -437,6 +562,16 @@ def sync(
437
562
  console.print(f"[bold red]Integration sync failed:[/bold red] {exc}")
438
563
  raise typer.Exit(1) from exc
439
564
 
565
+ if empty_profile_existing_aliases:
566
+ print_empty_profile_import_hint(
567
+ client=client,
568
+ profile=profile,
569
+ target=target,
570
+ aliases=empty_profile_existing_aliases,
571
+ tenant=tenant,
572
+ api_url=api_url,
573
+ )
574
+
440
575
  if dry_run:
441
576
  typer.echo(redact(patched))
442
577
  err_console.print("[cyan]Dry run: nothing was written.[/cyan]")
@@ -445,6 +580,10 @@ def sync(
445
580
  err_console.print(
446
581
  f"[cyan]Would write Local Proxy config: {proxy_target}[/cyan]"
447
582
  )
583
+ if proxy_default_alias is not None:
584
+ err_console.print(
585
+ f"[cyan]Would update default Local Proxy config: {proxy_default_alias}[/cyan]"
586
+ )
448
587
  if removed_direct_servers:
449
588
  err_console.print(
450
589
  "[yellow]Would remove direct MCP server entries:[/yellow] "
@@ -471,7 +610,7 @@ def sync(
471
610
  console.print("[yellow]Canceled.[/yellow]")
472
611
  raise typer.Exit(1)
473
612
 
474
- if local_proxy and removed_direct_servers and not yes:
613
+ if local_proxy and removed_direct_servers and not yes and not policy_managed_sync:
475
614
  console.print(
476
615
  f"[yellow]Local Proxy Mode removes direct MCP server entries from {target}:[/yellow] "
477
616
  f"{', '.join(removed_direct_servers)}"
@@ -490,6 +629,11 @@ def sync(
490
629
  if local_proxy:
491
630
  secure_write_text(proxy_target, proxy_config)
492
631
  console.print(f"[green]Wrote Local Proxy config to {proxy_target}[/green]")
632
+ if proxy_default_alias is not None:
633
+ secure_write_text(proxy_default_alias, proxy_config)
634
+ console.print(
635
+ f"[green]Updated default Local Proxy config at {proxy_default_alias}[/green]"
636
+ )
493
637
  if client == "claude-code":
494
638
  warm_local_proxy_tools_cache(proxy_target)
495
639
 
@@ -520,6 +664,10 @@ def sync(
520
664
  console.print(
521
665
  "[cyan]LightNow policy is enforce: managed clients should keep only the Local Proxy MCP entry.[/cyan]"
522
666
  )
667
+ if settings_local_proxy_summary.get("allowUnmanagedClientServers") is False:
668
+ console.print(
669
+ "[cyan]LightNow policy blocks unmanaged MCP servers for this client config.[/cyan]"
670
+ )
523
671
  console.print(
524
672
  f"[cyan]Next: restart {client} so it picks up the LightNow MCP entry.[/cyan]"
525
673
  )
@@ -601,9 +749,10 @@ def import_config(
601
749
  if client not in CLIENT_DEFAULTS:
602
750
  raise_bad_argument("Unsupported client", f"Use one of: {', '.join(CLIENTS)}")
603
751
  _, default_path = CLIENT_DEFAULTS[client]
604
- if client != "codex":
752
+ if client not in IMPORT_CLIENTS:
605
753
  raise_bad_argument(
606
- "Unsupported import client", "Config import currently supports Codex."
754
+ "Unsupported import client",
755
+ f"Config import supports: {', '.join(sorted(IMPORT_CLIENTS))}.",
607
756
  )
608
757
 
609
758
  target = (config_path or default_path).expanduser()
@@ -826,6 +975,7 @@ def build_local_proxy_config(
826
975
  tenant: Optional[str],
827
976
  registry_ca_file: Optional[Path] = None,
828
977
  local_proxy_settings: Optional[dict[str, Any]] = None,
978
+ runtime_secrets: Optional[dict[str, Any]] = None,
829
979
  ) -> str:
830
980
  """Render the local LightNow Proxy config for LightNow-managed profile sync."""
831
981
  parsed = urlparse(local_proxy_url)
@@ -861,7 +1011,6 @@ def build_local_proxy_config(
861
1011
  "client_name": client,
862
1012
  "client_version": None,
863
1013
  "runner_name": "lightnow-local-proxy",
864
- "runner_version": LOCAL_PROXY_RUNNER_VERSION,
865
1014
  "client_transport": (
866
1015
  "streamable-http" if local_proxy_transport == "http" else "stdio"
867
1016
  ),
@@ -893,12 +1042,30 @@ def build_local_proxy_config(
893
1042
  "jwks_cache_seconds": 300,
894
1043
  },
895
1044
  "registry_api": registry_api,
1045
+ "runtime_secrets": runtime_secrets or {"providers": {}},
896
1046
  "profiles": {profile: {}},
897
1047
  "upstreams": {},
898
1048
  }
899
1049
  return cast(str, yaml.safe_dump(payload, sort_keys=False))
900
1050
 
901
1051
 
1052
+ def read_local_runtime_secrets(path: Path) -> dict[str, Any]:
1053
+ """Preserve non-secret local provider mappings across managed config syncs."""
1054
+ try:
1055
+ payload = yaml.safe_load(path.expanduser().read_text()) or {}
1056
+ except (FileNotFoundError, OSError, yaml.YAMLError):
1057
+ return {"providers": {}}
1058
+ if not isinstance(payload, dict):
1059
+ return {"providers": {}}
1060
+ runtime_secrets = payload.get("runtime_secrets")
1061
+ if not isinstance(runtime_secrets, dict):
1062
+ return {"providers": {}}
1063
+ providers = runtime_secrets.get("providers")
1064
+ if not isinstance(providers, dict):
1065
+ return {"providers": {}}
1066
+ return {"providers": providers}
1067
+
1068
+
902
1069
  def build_runner_export(
903
1070
  *,
904
1071
  profile_payload: dict[str, Any],
@@ -1403,7 +1570,10 @@ def augment_local_proxy_posture(status: dict[str, Any]) -> dict[str, Any]:
1403
1570
  """Add Local Proxy runtime checks (config file, lightnow-proxy binary) to a posture."""
1404
1571
  warnings = [str(item) for item in status.get("warnings", [])]
1405
1572
  expected_path = status.get("expected_proxy_config_path")
1406
- proxy_config_exists = bool(expected_path) and Path(str(expected_path)).exists()
1573
+ proxy_config_path = Path(str(expected_path)).expanduser() if expected_path else None
1574
+ proxy_config_exists = False
1575
+ if proxy_config_path is not None:
1576
+ proxy_config_exists = proxy_config_path.exists()
1407
1577
  proxy_commands = [
1408
1578
  str(item) for item in status.get("local_proxy_commands", []) if str(item)
1409
1579
  ]
@@ -1418,6 +1588,39 @@ def augment_local_proxy_posture(status: dict[str, Any]) -> dict[str, Any]:
1418
1588
  if status.get("local_proxy_present"):
1419
1589
  if not proxy_config_exists:
1420
1590
  warnings.append("proxy_config_missing")
1591
+ elif proxy_config_path is not None:
1592
+ try:
1593
+ raw_proxy_config = yaml.safe_load(proxy_config_path.read_text()) or {}
1594
+ except yaml.YAMLError as exc:
1595
+ warnings.append(f"proxy_config_invalid:{exc.__class__.__name__}")
1596
+ raw_proxy_config = {}
1597
+ except OSError as exc:
1598
+ warnings.append(f"proxy_config_read_failed:{exc.__class__.__name__}")
1599
+ raw_proxy_config = {}
1600
+ if isinstance(raw_proxy_config, dict):
1601
+ local_proxy = raw_proxy_config.get("local_proxy")
1602
+ if isinstance(local_proxy, dict):
1603
+ client_name = local_proxy.get("client_name")
1604
+ status["local_proxy_profile"] = local_proxy.get("profile")
1605
+ status["local_proxy_client_name"] = client_name
1606
+ status["local_proxy_client_transport"] = local_proxy.get(
1607
+ "client_transport"
1608
+ )
1609
+ status["local_proxy_telemetry_enabled"] = local_proxy.get(
1610
+ "telemetry_enabled"
1611
+ )
1612
+ status["local_proxy_policy_mode"] = local_proxy.get("policy_mode")
1613
+ status["local_proxy_allow_unmanaged_client_servers"] = (
1614
+ local_proxy.get("allow_unmanaged_client_servers")
1615
+ )
1616
+ if client_name and str(client_name) != str(status.get("client")):
1617
+ warnings.append("proxy_config_client_mismatch")
1618
+ if (
1619
+ status.get("status") == "mixed"
1620
+ and local_proxy.get("policy_mode") == "enforce"
1621
+ and local_proxy.get("allow_unmanaged_client_servers") is False
1622
+ ):
1623
+ warnings.append("policy_blocks_unmanaged_servers")
1421
1624
  if not proxy_command_available:
1422
1625
  if proxy_commands and all(
1423
1626
  "/" in command or "\\" in command for command in proxy_commands
@@ -1515,6 +1718,14 @@ def warning_hint(code: str, client: str) -> Optional[str]:
1515
1718
  "the LightNow entry points at a different Local Proxy config; "
1516
1719
  f"re-run `{sync_command}` to update it."
1517
1720
  ),
1721
+ "proxy_config_client_mismatch": (
1722
+ "the Local Proxy config belongs to a different client; "
1723
+ f"re-run `{sync_command}` for this client."
1724
+ ),
1725
+ "policy_blocks_unmanaged_servers": (
1726
+ "LightNow policy is set to enforce and unmanaged MCP servers are still present; "
1727
+ f"re-run `{sync_command}` or remove the unmanaged entries."
1728
+ ),
1518
1729
  }
1519
1730
  return hints.get(code)
1520
1731
 
@@ -1567,6 +1778,15 @@ def print_config_status(status: dict[str, Any], path: Path) -> None:
1567
1778
  exists = status.get("proxy_config_exists")
1568
1779
  suffix = "" if exists is None else (" (present)" if exists else " (missing)")
1569
1780
  console.print(f"Local Proxy config: {expected_path}{suffix}")
1781
+ proxy_profile = status.get("local_proxy_profile")
1782
+ if proxy_profile:
1783
+ policy_mode = status.get("local_proxy_policy_mode") or "observe"
1784
+ allow_unmanaged = status.get("local_proxy_allow_unmanaged_client_servers")
1785
+ unmanaged_label = "allowed" if allow_unmanaged is not False else "blocked"
1786
+ console.print(
1787
+ "LightNow policy: "
1788
+ f"profile={proxy_profile}, mode={policy_mode}, unmanaged={unmanaged_label}"
1789
+ )
1570
1790
  unmanaged = status.get("unmanaged_servers")
1571
1791
  if isinstance(unmanaged, list) and unmanaged:
1572
1792
  console.print(