lightnow-cli 1.0.4__tar.gz → 1.1.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 (45) hide show
  1. lightnow_cli-1.1.0/.github/workflows/release-please.yml +24 -0
  2. lightnow_cli-1.1.0/.release-please-manifest.json +3 -0
  3. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/CHANGELOG.md +14 -0
  4. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/PKG-INFO +7 -1
  5. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/README.md +4 -0
  6. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/docs/development.md +17 -2
  7. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/docs/release.md +25 -1
  8. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/__init__.py +1 -1
  9. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/commands/auth.py +26 -7
  10. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/commands/integrations.py +129 -8
  11. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/pyproject.toml +3 -1
  12. lightnow_cli-1.1.0/release-please-config.json +18 -0
  13. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/test_auth_flow.py +23 -0
  14. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/test_cli.py +32 -1
  15. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/test_integrations.py +338 -1
  16. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/.github/workflows/ci.yml +0 -0
  17. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/.github/workflows/release.yml +0 -0
  18. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/.gitignore +0 -0
  19. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/CONTRIBUTING.md +0 -0
  20. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/LICENSE +0 -0
  21. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/Makefile +0 -0
  22. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/SECURITY.md +0 -0
  23. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/examples/README.md +0 -0
  24. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/examples/docs.md +0 -0
  25. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/examples/openapi.json +0 -0
  26. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/examples/server.json +0 -0
  27. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/authenticated_http.py +0 -0
  28. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/client.py +0 -0
  29. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/commands/__init__.py +0 -0
  30. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/commands/context.py +0 -0
  31. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/commands/publish.py +0 -0
  32. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/commands/query.py +0 -0
  33. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/commands/runner.py +0 -0
  34. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/commands/validate.py +0 -0
  35. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/config.py +0 -0
  36. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/main.py +0 -0
  37. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/tls.py +0 -0
  38. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/lightnow_cli/validation.py +0 -0
  39. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/__init__.py +0 -0
  40. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/test_authenticated_http.py +0 -0
  41. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/test_client.py +0 -0
  42. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/test_config.py +0 -0
  43. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/test_context.py +0 -0
  44. {lightnow_cli-1.0.4 → lightnow_cli-1.1.0}/tests/test_tls.py +0 -0
  45. {lightnow_cli-1.0.4 → lightnow_cli-1.1.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.1.0"
3
+ }
@@ -4,6 +4,20 @@ All notable changes to the LightNow CLI are documented here.
4
4
 
5
5
  This project follows semantic versioning.
6
6
 
7
+ ## [1.1.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.0.5...v1.1.0) (2026-07-07)
8
+
9
+
10
+ ### Features
11
+
12
+ * add whoami json output ([9eab480](https://github.com/lightnow-ai/lightnow-cli/commit/9eab480b85765fa50506f4d7126790c2a3ebd094))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **#2:** fix import wizard client configs ([ae0f0b4](https://github.com/lightnow-ai/lightnow-cli/commit/ae0f0b4931dcb1516be65473df3ca73397d6c16e))
18
+ * support wizard client config imports ([35a7347](https://github.com/lightnow-ai/lightnow-cli/commit/35a7347fa438af6e4d9fb754f87a7a5d6a709548))
19
+ * write default local proxy config alias ([47223f6](https://github.com/lightnow-ai/lightnow-cli/commit/47223f62b96ef0790d027ada11abf84459eb63fb))
20
+
7
21
  ## 1.0.0 - Unreleased
8
22
 
9
23
  Initial stable release candidate.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightnow-cli
3
- Version: 1.0.4
3
+ Version: 1.1.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,8 @@ 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'
41
43
  Description-Content-Type: text/markdown
42
44
 
43
45
  # LightNow CLI
@@ -170,6 +172,10 @@ Let LightNow choose the configured organization policy for a client:
170
172
  lightnow sync --client codex --from-settings
171
173
  ```
172
174
 
175
+ When LightNow Config blocks unmanaged MCP servers, managed clients keep only the
176
+ `LightNow` entry. When unmanaged servers are allowed, existing user-managed MCP
177
+ entries stay in the client config and `config-status` reports `mixed`.
178
+
173
179
  Check whether a client is configured as expected:
174
180
 
175
181
  ```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.4"
3
+ __version__ = "1.1.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:
@@ -82,6 +82,14 @@ CLIENT_DEFAULTS: dict[str, tuple[str, Path]] = {
82
82
  }
83
83
 
84
84
  CLIENTS = sorted(CLIENT_DEFAULTS)
85
+ IMPORT_CLIENTS = {
86
+ "antigravity",
87
+ "claude-code",
88
+ "claude-desktop",
89
+ "codex",
90
+ "cursor",
91
+ "vscode",
92
+ }
85
93
  SECRET_MODES = ["placeholder", "plaintext"]
86
94
  LOCAL_PROXY_MCP_SERVERS_JSON_CLIENTS = {
87
95
  "antigravity",
@@ -99,7 +107,9 @@ LOCAL_LIGHTNOW_CA_RELATIVE_PATH = Path(".local-runtime/certs/lightnow-local-ca.c
99
107
  LOCAL_PROXY_EXECUTABLE = "lightnow-proxy"
100
108
  LEGACY_LOCAL_PROXY_EXECUTABLE = "mcp-proxy"
101
109
  LIGHTNOW_PROXY_ALIASES = {"lightnow", "LightNow"}
102
- LOCAL_PROXY_RUNNER_VERSION = "0.1.2"
110
+ CLIENT_INTERNAL_MCP_SERVERS = {
111
+ "codex": {"node_repl"},
112
+ }
103
113
  VSCODE_VIRTUAL_TOOLS_THRESHOLD_SETTING = "github.copilot.chat.virtualTools.threshold"
104
114
  VSCODE_VIRTUAL_TOOLS_THRESHOLD = 128
105
115
  LOCAL_PROXY_STDIO_SUPPORT_MESSAGE = (
@@ -122,6 +132,27 @@ def default_local_proxy_config_path(client: str) -> Path:
122
132
  return DEFAULT_LOCAL_PROXY_CONFIG_DIR / f"{safe_client}.yaml"
123
133
 
124
134
 
135
+ def default_local_proxy_profile_config_path(profile: str = "default") -> Path:
136
+ """Return the profile-level Local Proxy config path used by proxy defaults."""
137
+ safe_profile = re.sub(r"[^A-Za-z0-9_.-]+", "-", profile).strip("-") or "default"
138
+ return DEFAULT_LOCAL_PROXY_CONFIG_DIR / f"{safe_profile}.yaml"
139
+
140
+
141
+ def local_proxy_default_config_alias(
142
+ *,
143
+ profile: str,
144
+ explicit_config_path: Optional[Path],
145
+ proxy_target: Path,
146
+ ) -> Optional[Path]:
147
+ """Return the default health-check alias path that should mirror a sync."""
148
+ if explicit_config_path is not None or profile != "default":
149
+ return None
150
+ alias = default_local_proxy_profile_config_path(profile).expanduser()
151
+ if alias == proxy_target.expanduser():
152
+ return None
153
+ return alias
154
+
155
+
125
156
  def discover_local_lightnow_ca_file(registry_api_url: str) -> Optional[Path]:
126
157
  """Find the local LightNow CA for local development Registry API URLs."""
127
158
  parsed = urlparse(registry_api_url)
@@ -323,8 +354,11 @@ def sync(
323
354
  proxy_target = (
324
355
  local_proxy_config_path or default_local_proxy_config_path(client)
325
356
  ).expanduser()
357
+ proxy_default_alias: Optional[Path] = None
326
358
  settings_local_proxy_summary: dict[str, Any] = {}
327
359
  removed_direct_servers: list[str] = []
360
+ remove_unmanaged_client_servers = local_proxy
361
+ policy_managed_sync = False
328
362
 
329
363
  try:
330
364
  if from_settings:
@@ -345,6 +379,10 @@ def sync(
345
379
  )
346
380
  if local_proxy_settings.get("enabled") is True and client_is_managed:
347
381
  local_proxy = True
382
+ policy_managed_sync = True
383
+ remove_unmanaged_client_servers = (
384
+ local_proxy_settings.get("allowUnmanagedClientServers") is False
385
+ )
348
386
  configured_profile = local_proxy_settings.get("profile")
349
387
  if isinstance(configured_profile, str) and configured_profile:
350
388
  profile = configured_profile
@@ -352,6 +390,11 @@ def sync(
352
390
  profile = str(settings_payload["defaultProfile"])
353
391
 
354
392
  if local_proxy:
393
+ proxy_default_alias = local_proxy_default_config_alias(
394
+ profile=profile,
395
+ explicit_config_path=local_proxy_config_path,
396
+ proxy_target=proxy_target,
397
+ )
355
398
  generated = build_local_proxy_export(
356
399
  client=client,
357
400
  export_format=export_format,
@@ -402,12 +445,18 @@ def sync(
402
445
  if export_format == "json"
403
446
  else {"aliases": [], "input_ids": []}
404
447
  )
405
- if local_proxy:
448
+ if local_proxy and remove_unmanaged_client_servers:
406
449
  removed_direct_servers = direct_server_aliases(existing, export_format)
407
- if local_proxy and client == "codex" and export_format == "toml":
450
+ if (
451
+ local_proxy
452
+ and remove_unmanaged_client_servers
453
+ and client == "codex"
454
+ and export_format == "toml"
455
+ ):
408
456
  existing = prepare_codex_local_proxy_config(existing)
409
457
  if (
410
458
  local_proxy
459
+ and remove_unmanaged_client_servers
411
460
  and client in LOCAL_PROXY_JSON_CLIENTS
412
461
  and export_format == "json"
413
462
  ):
@@ -442,6 +491,10 @@ def sync(
442
491
  err_console.print(
443
492
  f"[cyan]Would write Local Proxy config: {proxy_target}[/cyan]"
444
493
  )
494
+ if proxy_default_alias is not None:
495
+ err_console.print(
496
+ f"[cyan]Would update default Local Proxy config: {proxy_default_alias}[/cyan]"
497
+ )
445
498
  if removed_direct_servers:
446
499
  err_console.print(
447
500
  "[yellow]Would remove direct MCP server entries:[/yellow] "
@@ -468,7 +521,7 @@ def sync(
468
521
  console.print("[yellow]Canceled.[/yellow]")
469
522
  raise typer.Exit(1)
470
523
 
471
- if local_proxy and removed_direct_servers and not yes:
524
+ if local_proxy and removed_direct_servers and not yes and not policy_managed_sync:
472
525
  console.print(
473
526
  f"[yellow]Local Proxy Mode removes direct MCP server entries from {target}:[/yellow] "
474
527
  f"{', '.join(removed_direct_servers)}"
@@ -487,6 +540,11 @@ def sync(
487
540
  if local_proxy:
488
541
  secure_write_text(proxy_target, proxy_config)
489
542
  console.print(f"[green]Wrote Local Proxy config to {proxy_target}[/green]")
543
+ if proxy_default_alias is not None:
544
+ secure_write_text(proxy_default_alias, proxy_config)
545
+ console.print(
546
+ f"[green]Updated default Local Proxy config at {proxy_default_alias}[/green]"
547
+ )
490
548
  if client == "claude-code":
491
549
  warm_local_proxy_tools_cache(proxy_target)
492
550
 
@@ -517,6 +575,10 @@ def sync(
517
575
  console.print(
518
576
  "[cyan]LightNow policy is enforce: managed clients should keep only the Local Proxy MCP entry.[/cyan]"
519
577
  )
578
+ if settings_local_proxy_summary.get("allowUnmanagedClientServers") is False:
579
+ console.print(
580
+ "[cyan]LightNow policy blocks unmanaged MCP servers for this client config.[/cyan]"
581
+ )
520
582
  console.print(
521
583
  f"[cyan]Next: restart {client} so it picks up the LightNow MCP entry.[/cyan]"
522
584
  )
@@ -598,9 +660,10 @@ def import_config(
598
660
  if client not in CLIENT_DEFAULTS:
599
661
  raise_bad_argument("Unsupported client", f"Use one of: {', '.join(CLIENTS)}")
600
662
  _, default_path = CLIENT_DEFAULTS[client]
601
- if client != "codex":
663
+ if client not in IMPORT_CLIENTS:
602
664
  raise_bad_argument(
603
- "Unsupported import client", "Config import currently supports Codex."
665
+ "Unsupported import client",
666
+ f"Config import supports: {', '.join(sorted(IMPORT_CLIENTS))}.",
604
667
  )
605
668
 
606
669
  target = (config_path or default_path).expanduser()
@@ -858,7 +921,6 @@ def build_local_proxy_config(
858
921
  "client_name": client,
859
922
  "client_version": None,
860
923
  "runner_name": "lightnow-local-proxy",
861
- "runner_version": LOCAL_PROXY_RUNNER_VERSION,
862
924
  "client_transport": (
863
925
  "streamable-http" if local_proxy_transport == "http" else "stdio"
864
926
  ),
@@ -1330,9 +1392,11 @@ def analyze_client_config_content(
1330
1392
  local_proxy_aliases: list[str] = []
1331
1393
  local_proxy_commands: list[str] = []
1332
1394
  legacy_runner_servers: list[str] = []
1395
+ internal_servers: list[str] = []
1333
1396
  unmanaged_servers: list[str] = []
1334
1397
  proxy_config_path = str(expected_proxy_config_path.expanduser())
1335
1398
  warnings: list[str] = []
1399
+ client_internal_servers = CLIENT_INTERNAL_MCP_SERVERS.get(client, set())
1336
1400
  for alias, entry in entries.items():
1337
1401
  raw_command = entry.get("command")
1338
1402
  command: str = raw_command if isinstance(raw_command, str) else ""
@@ -1362,6 +1426,9 @@ def analyze_client_config_content(
1362
1426
  if is_lightnow_runner:
1363
1427
  legacy_runner_servers.append(alias)
1364
1428
  continue
1429
+ if alias in client_internal_servers:
1430
+ internal_servers.append(alias)
1431
+ continue
1365
1432
  unmanaged_servers.append(alias)
1366
1433
 
1367
1434
  if local_proxy_aliases and not unmanaged_servers and not legacy_runner_servers:
@@ -1384,6 +1451,7 @@ def analyze_client_config_content(
1384
1451
  "local_proxy_present": bool(local_proxy_aliases),
1385
1452
  "local_proxy_aliases": sorted(local_proxy_aliases),
1386
1453
  "local_proxy_commands": sorted(set(local_proxy_commands)),
1454
+ "internal_servers": sorted(internal_servers),
1387
1455
  "unmanaged_servers": sorted(unmanaged_servers),
1388
1456
  "legacy_runner_servers": sorted(legacy_runner_servers),
1389
1457
  "warnings": sorted(set(warnings)),
@@ -1394,7 +1462,10 @@ def augment_local_proxy_posture(status: dict[str, Any]) -> dict[str, Any]:
1394
1462
  """Add Local Proxy runtime checks (config file, lightnow-proxy binary) to a posture."""
1395
1463
  warnings = [str(item) for item in status.get("warnings", [])]
1396
1464
  expected_path = status.get("expected_proxy_config_path")
1397
- proxy_config_exists = bool(expected_path) and Path(str(expected_path)).exists()
1465
+ proxy_config_path = Path(str(expected_path)).expanduser() if expected_path else None
1466
+ proxy_config_exists = False
1467
+ if proxy_config_path is not None:
1468
+ proxy_config_exists = proxy_config_path.exists()
1398
1469
  proxy_commands = [
1399
1470
  str(item) for item in status.get("local_proxy_commands", []) if str(item)
1400
1471
  ]
@@ -1409,6 +1480,39 @@ def augment_local_proxy_posture(status: dict[str, Any]) -> dict[str, Any]:
1409
1480
  if status.get("local_proxy_present"):
1410
1481
  if not proxy_config_exists:
1411
1482
  warnings.append("proxy_config_missing")
1483
+ elif proxy_config_path is not None:
1484
+ try:
1485
+ raw_proxy_config = yaml.safe_load(proxy_config_path.read_text()) or {}
1486
+ except yaml.YAMLError as exc:
1487
+ warnings.append(f"proxy_config_invalid:{exc.__class__.__name__}")
1488
+ raw_proxy_config = {}
1489
+ except OSError as exc:
1490
+ warnings.append(f"proxy_config_read_failed:{exc.__class__.__name__}")
1491
+ raw_proxy_config = {}
1492
+ if isinstance(raw_proxy_config, dict):
1493
+ local_proxy = raw_proxy_config.get("local_proxy")
1494
+ if isinstance(local_proxy, dict):
1495
+ client_name = local_proxy.get("client_name")
1496
+ status["local_proxy_profile"] = local_proxy.get("profile")
1497
+ status["local_proxy_client_name"] = client_name
1498
+ status["local_proxy_client_transport"] = local_proxy.get(
1499
+ "client_transport"
1500
+ )
1501
+ status["local_proxy_telemetry_enabled"] = local_proxy.get(
1502
+ "telemetry_enabled"
1503
+ )
1504
+ status["local_proxy_policy_mode"] = local_proxy.get("policy_mode")
1505
+ status["local_proxy_allow_unmanaged_client_servers"] = (
1506
+ local_proxy.get("allow_unmanaged_client_servers")
1507
+ )
1508
+ if client_name and str(client_name) != str(status.get("client")):
1509
+ warnings.append("proxy_config_client_mismatch")
1510
+ if (
1511
+ status.get("status") == "mixed"
1512
+ and local_proxy.get("policy_mode") == "enforce"
1513
+ and local_proxy.get("allow_unmanaged_client_servers") is False
1514
+ ):
1515
+ warnings.append("policy_blocks_unmanaged_servers")
1412
1516
  if not proxy_command_available:
1413
1517
  if proxy_commands and all(
1414
1518
  "/" in command or "\\" in command for command in proxy_commands
@@ -1506,6 +1610,14 @@ def warning_hint(code: str, client: str) -> Optional[str]:
1506
1610
  "the LightNow entry points at a different Local Proxy config; "
1507
1611
  f"re-run `{sync_command}` to update it."
1508
1612
  ),
1613
+ "proxy_config_client_mismatch": (
1614
+ "the Local Proxy config belongs to a different client; "
1615
+ f"re-run `{sync_command}` for this client."
1616
+ ),
1617
+ "policy_blocks_unmanaged_servers": (
1618
+ "LightNow policy is set to enforce and unmanaged MCP servers are still present; "
1619
+ f"re-run `{sync_command}` or remove the unmanaged entries."
1620
+ ),
1509
1621
  }
1510
1622
  return hints.get(code)
1511
1623
 
@@ -1558,6 +1670,15 @@ def print_config_status(status: dict[str, Any], path: Path) -> None:
1558
1670
  exists = status.get("proxy_config_exists")
1559
1671
  suffix = "" if exists is None else (" (present)" if exists else " (missing)")
1560
1672
  console.print(f"Local Proxy config: {expected_path}{suffix}")
1673
+ proxy_profile = status.get("local_proxy_profile")
1674
+ if proxy_profile:
1675
+ policy_mode = status.get("local_proxy_policy_mode") or "observe"
1676
+ allow_unmanaged = status.get("local_proxy_allow_unmanaged_client_servers")
1677
+ unmanaged_label = "allowed" if allow_unmanaged is not False else "blocked"
1678
+ console.print(
1679
+ "LightNow policy: "
1680
+ f"profile={proxy_profile}, mode={policy_mode}, unmanaged={unmanaged_label}"
1681
+ )
1561
1682
  unmanaged = status.get("unmanaged_servers")
1562
1683
  if isinstance(unmanaged, list) and unmanaged:
1563
1684
  console.print(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lightnow-cli"
7
- version = "1.0.4"
7
+ version = "1.1.0"
8
8
  description = "Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows"
9
9
  authors = [{name = "LightNow Team"}]
10
10
  license = {text = "Apache-2.0"}
@@ -45,6 +45,8 @@ dev = [
45
45
  "isort>=5.12.0",
46
46
  "flake8>=6.0.0",
47
47
  "mypy>=1.0.0",
48
+ "types-jsonschema>=4.25.0",
49
+ "types-PyYAML>=6.0.12",
48
50
  "twine>=5.0.0",
49
51
  ]
50
52
 
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "python",
6
+ "package-name": "lightnow-cli",
7
+ "changelog-path": "CHANGELOG.md",
8
+ "include-component-in-tag": false,
9
+ "include-v-in-tag": true,
10
+ "extra-files": [
11
+ {
12
+ "type": "generic",
13
+ "path": "lightnow_cli/__init__.py"
14
+ }
15
+ ]
16
+ }
17
+ }
18
+ }
@@ -379,3 +379,26 @@ def test_current_user_info_retries_userinfo_after_refresh(monkeypatch) -> None:
379
379
 
380
380
  assert auth.current_user_info()["email"] == "test@lightnow.local"
381
381
  assert calls == ["old-access-token", "new-access-token"]
382
+
383
+
384
+ def test_current_user_info_falls_back_to_token_claims_when_userinfo_rejects(
385
+ monkeypatch,
386
+ ) -> None:
387
+ """Whoami/status can still display identity when the issuer rejects userinfo."""
388
+ token = (
389
+ "eyJhbGciOiJub25lIn0."
390
+ "eyJzdWIiOiIxMjMiLCJlbWFpbCI6InRlc3RAbGlnaHRub3cubG9jYWwiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0ZXN0IiwiZXhwIjo5OTk5OTk5OTk5fQ."
391
+ )
392
+ config = Config(access_token=token)
393
+
394
+ async def fetch(_: str, __: str) -> dict[str, str]:
395
+ raise auth.AuthError("Failed to fetch user information: HTTP 403")
396
+
397
+ monkeypatch.setattr(auth.config_manager, "load_config", lambda: config)
398
+ monkeypatch.setattr(auth, "require_access_token", lambda: token)
399
+ monkeypatch.setattr(auth, "fetch_user_info", fetch)
400
+
401
+ user_info = auth.current_user_info()
402
+
403
+ assert user_info["sub"] == "123"
404
+ assert user_info["email"] == "test@lightnow.local"
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
8
8
  import pytest
9
9
  from typer.testing import CliRunner
10
10
 
11
+ from lightnow_cli import __version__
11
12
  from lightnow_cli.config import (
12
13
  DEFAULT_CLIENT_ID,
13
14
  DEFAULT_ISSUER,
@@ -52,7 +53,7 @@ def test_cli_version(runner):
52
53
  """Test CLI version command."""
53
54
  result = runner.invoke(app, ["--version"])
54
55
  assert result.exit_code == 0
55
- assert "LightNow CLI 1.0.4" in result.stdout
56
+ assert f"LightNow CLI {__version__}" in result.stdout
56
57
 
57
58
 
58
59
  def test_cli_help(runner):
@@ -582,6 +583,36 @@ def test_whoami_command_authenticated(mock_fetch_user_info, mock_load_config, ru
582
583
  assert "openid" not in result.stdout
583
584
 
584
585
 
586
+ @patch("lightnow_cli.config.config_manager.load_config")
587
+ @patch("lightnow_cli.commands.auth.fetch_user_info")
588
+ def test_whoami_command_json(mock_fetch_user_info, mock_load_config, runner):
589
+ """Whoami can emit machine-readable identity details."""
590
+ mock_config = MagicMock()
591
+ mock_config.access_token = (
592
+ "eyJhbGciOiJub25lIn0." "eyJzdWIiOiIxMjMiLCJleHAiOjk5OTk5OTk5OTl9."
593
+ )
594
+ mock_config.refresh_token = None
595
+ mock_config.issuer = DEFAULT_ISSUER
596
+ mock_fetch_user_info.return_value = {
597
+ "email": "test@example.com",
598
+ "name": "Test User",
599
+ "preferred_username": "test",
600
+ "sub": "123456",
601
+ }
602
+ mock_load_config.return_value = mock_config
603
+
604
+ result = runner.invoke(app, ["whoami", "--json"])
605
+
606
+ assert result.exit_code == 0
607
+ assert json.loads(result.stdout) == {
608
+ "email": "test@example.com",
609
+ "name": "Test User",
610
+ "preferred_username": "test",
611
+ "sub": "123456",
612
+ }
613
+ assert "Current User Information" not in result.stdout
614
+
615
+
585
616
  @patch("lightnow_cli.commands.auth.config_manager.clear_token")
586
617
  def test_logout_command(mock_clear_token, runner):
587
618
  """Test logout clears the stored token."""
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from unittest.mock import patch
8
8
 
9
9
  import httpx
10
+ import pytest
10
11
  import yaml
11
12
  from typer.testing import CliRunner
12
13
 
@@ -18,6 +19,7 @@ from lightnow_cli.commands.auth import (
18
19
  from lightnow_cli.commands.integrations import (
19
20
  BEGIN,
20
21
  END,
22
+ IMPORT_CLIENTS,
21
23
  JSON_MANIFEST_SUFFIX,
22
24
  analyze_client_config_content,
23
25
  analyze_client_config_file,
@@ -26,6 +28,7 @@ from lightnow_cli.commands.integrations import (
26
28
  build_runner_export,
27
29
  configure_vscode_virtual_tools,
28
30
  default_local_proxy_config_path,
31
+ default_local_proxy_profile_config_path,
29
32
  discover_local_lightnow_ca_file,
30
33
  extract_json_managed,
31
34
  fetch_export,
@@ -36,6 +39,7 @@ from lightnow_cli.commands.integrations import (
36
39
  prepare_json_local_proxy_config,
37
40
  print_import_summary,
38
41
  redact,
42
+ render_local_proxy_codex_stdio_toml,
39
43
  render_local_proxy_codex_toml,
40
44
  render_runner_config,
41
45
  secure_write_text,
@@ -498,6 +502,15 @@ def test_default_local_proxy_config_path_is_client_specific() -> None:
498
502
  )
499
503
 
500
504
 
505
+ def test_default_local_proxy_profile_config_path_is_profile_specific() -> None:
506
+ """The proxy health default is keyed by profile, not by MCP client."""
507
+ assert default_local_proxy_profile_config_path("default").name == "default.yaml"
508
+ assert (
509
+ default_local_proxy_profile_config_path("Engineering Team").name
510
+ == "Engineering-Team.yaml"
511
+ )
512
+
513
+
501
514
  def test_analyzes_managed_codex_local_proxy_config() -> None:
502
515
  """Posture scanner recognizes a clean Local Proxy config."""
503
516
  status = analyze_client_config_content(
@@ -512,6 +525,34 @@ def test_analyzes_managed_codex_local_proxy_config() -> None:
512
525
  assert status["unmanaged_servers"] == []
513
526
 
514
527
 
528
+ def test_analyzes_codex_local_proxy_with_internal_node_repl_as_managed() -> None:
529
+ """Codex may add internal MCP plumbing that should not be reported as bypass drift."""
530
+ content = (
531
+ render_local_proxy_codex_stdio_toml(Path("/tmp/lightnow/codex.yaml")) + """
532
+
533
+ [mcp_servers.node_repl]
534
+ command = "/Applications/Codex.app/Contents/Resources/cua_node/bin/node_repl"
535
+ args = []
536
+ startup_timeout_sec = 120
537
+
538
+ [mcp_servers.node_repl.env]
539
+ CODEX_HOME = "/Users/example/.codex"
540
+ """
541
+ )
542
+
543
+ status = analyze_client_config_content(
544
+ client="codex",
545
+ export_format="toml",
546
+ content=content,
547
+ expected_proxy_config_path=Path("/tmp/lightnow/codex.yaml"),
548
+ )
549
+
550
+ assert status["status"] == "managed"
551
+ assert status["local_proxy_aliases"] == ["lightnow"]
552
+ assert status["internal_servers"] == ["node_repl"]
553
+ assert status["unmanaged_servers"] == []
554
+
555
+
515
556
  def test_analyzes_mixed_json_config_with_unmanaged_servers() -> None:
516
557
  """Posture scanner reports bypassable client config entries."""
517
558
  content = json.dumps(
@@ -1089,7 +1130,6 @@ def test_sync_local_proxy_replaces_existing_codex_mcp_servers() -> None:
1089
1130
  "client_name": "codex",
1090
1131
  "client_version": None,
1091
1132
  "runner_name": "lightnow-local-proxy",
1092
- "runner_version": "0.1.2",
1093
1133
  "client_transport": "stdio",
1094
1134
  }
1095
1135
  assert proxy_payload["registry_api"]["use_cli_session"] is True
@@ -1098,6 +1138,50 @@ def test_sync_local_proxy_replaces_existing_codex_mcp_servers() -> None:
1098
1138
  assert proxy_payload["upstreams"] == {}
1099
1139
 
1100
1140
 
1141
+ def test_sync_local_proxy_updates_default_profile_config_alias() -> None:
1142
+ """Default-profile sync also updates the config path used by lightnow-proxy --health."""
1143
+ runner = CliRunner()
1144
+ with tempfile.TemporaryDirectory() as tmp:
1145
+ target = Path(tmp) / "config.toml"
1146
+ proxy_config = Path(tmp) / "codex.yaml"
1147
+ default_config = Path(tmp) / "default.yaml"
1148
+ with (
1149
+ patch(
1150
+ "lightnow_cli.commands.integrations.require_access_token",
1151
+ return_value="token",
1152
+ ),
1153
+ patch(
1154
+ "lightnow_cli.commands.integrations.default_local_proxy_config_path",
1155
+ return_value=proxy_config,
1156
+ ),
1157
+ patch(
1158
+ "lightnow_cli.commands.integrations.default_local_proxy_profile_config_path",
1159
+ return_value=default_config,
1160
+ ),
1161
+ ):
1162
+ result = runner.invoke(
1163
+ app,
1164
+ [
1165
+ "sync",
1166
+ "--client",
1167
+ "codex",
1168
+ "--local-proxy",
1169
+ "--yes",
1170
+ "--config-path",
1171
+ str(target),
1172
+ ],
1173
+ )
1174
+ proxy_exists = proxy_config.exists()
1175
+ default_exists = default_config.exists()
1176
+ configs_match = default_config.read_text() == proxy_config.read_text()
1177
+
1178
+ assert result.exit_code == 0
1179
+ assert proxy_exists
1180
+ assert default_exists
1181
+ assert configs_match
1182
+ assert "Updated default Local Proxy config" in result.stdout
1183
+
1184
+
1101
1185
  def test_sync_local_proxy_replaces_existing_claude_desktop_mcp_servers() -> None:
1102
1186
  """Claude Desktop Local Proxy sync leaves one LightNow MCP server entry."""
1103
1187
  runner = CliRunner()
@@ -1565,6 +1649,118 @@ def test_sync_from_settings_uses_local_proxy_policy() -> None:
1565
1649
  assert "policy is enforce" in result.stdout
1566
1650
 
1567
1651
 
1652
+ def test_sync_from_settings_blocks_unmanaged_servers_without_prompt() -> None:
1653
+ """Enterprise block policy removes unmanaged client MCP entries non-interactively."""
1654
+ runner = CliRunner()
1655
+ with tempfile.TemporaryDirectory() as tmp:
1656
+ target = Path(tmp) / "config.toml"
1657
+ proxy_config = Path(tmp) / "codex-proxy.yaml"
1658
+ target.write_text("""
1659
+ [mcp_servers.github]
1660
+ command = "docker"
1661
+
1662
+ [mcp_servers.github.env]
1663
+ GITHUB_TOKEN = "secret"
1664
+ """.lstrip())
1665
+ with (
1666
+ patch(
1667
+ "lightnow_cli.commands.integrations.require_access_token",
1668
+ return_value="token",
1669
+ ),
1670
+ patch(
1671
+ "lightnow_cli.commands.integrations.fetch_integration_settings",
1672
+ return_value={
1673
+ "defaultProfile": "default",
1674
+ "localProxy": {
1675
+ "enabled": True,
1676
+ "profile": "engineering",
1677
+ "managedClients": ["codex"],
1678
+ "allowUnmanagedClientServers": False,
1679
+ "telemetryEnabled": True,
1680
+ "policyMode": "enforce",
1681
+ },
1682
+ },
1683
+ ),
1684
+ ):
1685
+ result = runner.invoke(
1686
+ app,
1687
+ [
1688
+ "sync",
1689
+ "--client",
1690
+ "codex",
1691
+ "--from-settings",
1692
+ "--config-path",
1693
+ str(target),
1694
+ "--local-proxy-config-path",
1695
+ str(proxy_config),
1696
+ ],
1697
+ )
1698
+
1699
+ patched = target.read_text()
1700
+
1701
+ assert result.exit_code == 0
1702
+ assert "[mcp_servers.lightnow]" in patched
1703
+ assert "[mcp_servers.github]" not in patched
1704
+ assert "GITHUB_TOKEN" not in patched
1705
+ assert "LightNow policy blocks unmanaged MCP servers" in result.stdout
1706
+ assert "Removed direct MCP server entries" in result.stdout
1707
+
1708
+
1709
+ def test_sync_from_settings_allows_unmanaged_servers_when_policy_allows() -> None:
1710
+ """Enterprise allow policy keeps existing user-managed MCP entries."""
1711
+ runner = CliRunner()
1712
+ with tempfile.TemporaryDirectory() as tmp:
1713
+ target = Path(tmp) / "config.toml"
1714
+ proxy_config = Path(tmp) / "codex-proxy.yaml"
1715
+ target.write_text("""
1716
+ [mcp_servers.github]
1717
+ command = "docker"
1718
+ args = ["run", "github-mcp"]
1719
+ """.lstrip())
1720
+ with (
1721
+ patch(
1722
+ "lightnow_cli.commands.integrations.require_access_token",
1723
+ return_value="token",
1724
+ ),
1725
+ patch(
1726
+ "lightnow_cli.commands.integrations.fetch_integration_settings",
1727
+ return_value={
1728
+ "defaultProfile": "default",
1729
+ "localProxy": {
1730
+ "enabled": True,
1731
+ "profile": "engineering",
1732
+ "managedClients": ["codex"],
1733
+ "allowUnmanagedClientServers": True,
1734
+ "telemetryEnabled": True,
1735
+ "policyMode": "observe",
1736
+ },
1737
+ },
1738
+ ),
1739
+ ):
1740
+ result = runner.invoke(
1741
+ app,
1742
+ [
1743
+ "sync",
1744
+ "--client",
1745
+ "codex",
1746
+ "--from-settings",
1747
+ "--config-path",
1748
+ str(target),
1749
+ "--local-proxy-config-path",
1750
+ str(proxy_config),
1751
+ ],
1752
+ )
1753
+
1754
+ patched = target.read_text()
1755
+
1756
+ assert result.exit_code == 0
1757
+ assert "[mcp_servers.lightnow]" in patched
1758
+ assert "[mcp_servers.github]" in patched
1759
+ assert "Client config posture: mixed" in result.stdout
1760
+ assert "Unmanaged MCP servers" in result.stdout
1761
+ assert "Removed direct MCP server entries" not in result.stdout
1762
+
1763
+
1568
1764
  def test_sync_from_settings_uses_direct_export_for_unmanaged_client() -> None:
1569
1765
  """Settings-driven sync leaves clients outside managedClients on direct config."""
1570
1766
  runner = CliRunner()
@@ -2420,6 +2616,81 @@ def test_import_config_command_prints_only_redacted_summary() -> None:
2420
2616
  importer.assert_called_once()
2421
2617
 
2422
2618
 
2619
+ @pytest.mark.parametrize(
2620
+ "client, filename",
2621
+ [
2622
+ ("antigravity", "mcp_config.json"),
2623
+ ("claude-code", "claude.json"),
2624
+ ("claude-desktop", "claude_desktop_config.json"),
2625
+ ("cursor", "mcp.json"),
2626
+ ("vscode", "mcp.json"),
2627
+ ],
2628
+ )
2629
+ def test_import_config_command_accepts_wizard_clients(
2630
+ client: str, filename: str
2631
+ ) -> None:
2632
+ """Wizard clients can import their existing JSON MCP config."""
2633
+ runner = CliRunner()
2634
+ with tempfile.TemporaryDirectory() as tmp:
2635
+ target = Path(tmp) / filename
2636
+ target.write_text('{"mcpServers":{"github":{"command":"docker"}}}')
2637
+ with (
2638
+ patch(
2639
+ "lightnow_cli.commands.integrations.require_access_token",
2640
+ return_value="token",
2641
+ ),
2642
+ patch(
2643
+ "lightnow_cli.commands.integrations.import_profile_config",
2644
+ return_value={
2645
+ "dry_run": True,
2646
+ "profile": {"name": "default"},
2647
+ "summary": {
2648
+ "total": 1,
2649
+ "mapped": 1,
2650
+ "custom": 0,
2651
+ "importable": 1,
2652
+ "blocked": 0,
2653
+ },
2654
+ "servers": [
2655
+ {
2656
+ "alias": "github",
2657
+ "status": "mapped",
2658
+ "server_name": "io.github.github/github-mcp-server",
2659
+ }
2660
+ ],
2661
+ },
2662
+ ) as importer,
2663
+ ):
2664
+ result = runner.invoke(
2665
+ app,
2666
+ [
2667
+ "import-config",
2668
+ "--client",
2669
+ client,
2670
+ "--config-path",
2671
+ str(target),
2672
+ "--dry-run",
2673
+ ],
2674
+ )
2675
+
2676
+ assert result.exit_code == 0
2677
+ assert "Previewed" in result.stdout
2678
+ importer.assert_called_once()
2679
+ assert importer.call_args.kwargs["source"] == client
2680
+ assert client in IMPORT_CLIENTS
2681
+
2682
+
2683
+ def test_import_config_command_rejects_non_wizard_clients() -> None:
2684
+ """Import stays limited to clients that the onboarding wizard supports."""
2685
+ runner = CliRunner()
2686
+ result = runner.invoke(app, ["import-config", "--client", "gemini-cli"])
2687
+
2688
+ assert result.exit_code != 0
2689
+ assert "Unsupported import client" in result.stdout
2690
+ assert "antigravity" in result.stdout
2691
+ assert "gemini-cli" not in result.stdout.split("Config import supports:", 1)[-1]
2692
+
2693
+
2423
2694
  def test_fetch_export_refreshes_and_retries_after_unauthorized() -> None:
2424
2695
  """Export requests share the central refresh-and-retry path."""
2425
2696
 
@@ -2946,6 +3217,72 @@ def test_config_status_reports_missing_absolute_proxy_command() -> None:
2946
3217
  assert payload["warnings"] == ["lightnow_proxy_command_missing"]
2947
3218
 
2948
3219
 
3220
+ def test_config_status_reports_local_proxy_policy_from_proxy_config() -> None:
3221
+ """Posture includes the managed policy state without exposing secrets."""
3222
+ runner = CliRunner()
3223
+ with tempfile.TemporaryDirectory() as tmp:
3224
+ proxy_config = Path(tmp) / "proxy.yaml"
3225
+ proxy_config.write_text(
3226
+ yaml.safe_dump(
3227
+ {
3228
+ "local_proxy": {
3229
+ "profile": "engineering",
3230
+ "client_name": "claude-desktop",
3231
+ "client_transport": "stdio",
3232
+ "telemetry_enabled": True,
3233
+ "policy_mode": "enforce",
3234
+ "allow_unmanaged_client_servers": False,
3235
+ }
3236
+ }
3237
+ )
3238
+ )
3239
+ target = Path(tmp) / "mcp.json"
3240
+ target.write_text(
3241
+ json.dumps(
3242
+ {
3243
+ "mcpServers": {
3244
+ "LightNow": {
3245
+ "command": "lightnow-proxy",
3246
+ "args": [
3247
+ "--config",
3248
+ str(proxy_config),
3249
+ "--transport",
3250
+ "stdio",
3251
+ ],
3252
+ },
3253
+ "github": {"command": "docker"},
3254
+ }
3255
+ }
3256
+ )
3257
+ )
3258
+ with patch(
3259
+ "lightnow_cli.commands.integrations.shutil.which",
3260
+ return_value="/usr/local/bin/lightnow-proxy",
3261
+ ):
3262
+ result = runner.invoke(
3263
+ app,
3264
+ [
3265
+ "config-status",
3266
+ "--client",
3267
+ "claude-desktop",
3268
+ "--config-path",
3269
+ str(target),
3270
+ "--local-proxy-config-path",
3271
+ str(proxy_config),
3272
+ "--json",
3273
+ ],
3274
+ )
3275
+
3276
+ assert result.exit_code == 0
3277
+ payload = json.loads(result.stdout)
3278
+ assert payload["status"] == "mixed"
3279
+ assert payload["local_proxy_profile"] == "engineering"
3280
+ assert payload["local_proxy_policy_mode"] == "enforce"
3281
+ assert payload["local_proxy_allow_unmanaged_client_servers"] is False
3282
+ assert payload["local_proxy_telemetry_enabled"] is True
3283
+ assert "policy_blocks_unmanaged_servers" in payload["warnings"]
3284
+
3285
+
2949
3286
  def test_config_status_prints_next_step_for_unmanaged_config() -> None:
2950
3287
  """Human posture output tells lazy users the exact next command."""
2951
3288
  runner = CliRunner()
File without changes
File without changes
File without changes
File without changes