lightnow-cli 1.0.5__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.5 → lightnow_cli-1.1.0}/CHANGELOG.md +14 -0
  4. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/PKG-INFO +7 -1
  5. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/README.md +4 -0
  6. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/docs/development.md +17 -2
  7. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/docs/release.md +25 -1
  8. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/__init__.py +1 -1
  9. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/commands/auth.py +26 -7
  10. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/commands/integrations.py +120 -8
  11. {lightnow_cli-1.0.5 → 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.5 → lightnow_cli-1.1.0}/tests/test_auth_flow.py +23 -0
  14. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/tests/test_cli.py +32 -1
  15. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/tests/test_integrations.py +309 -1
  16. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/.github/workflows/ci.yml +0 -0
  17. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/.github/workflows/release.yml +0 -0
  18. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/.gitignore +0 -0
  19. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/CONTRIBUTING.md +0 -0
  20. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/LICENSE +0 -0
  21. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/Makefile +0 -0
  22. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/SECURITY.md +0 -0
  23. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/examples/README.md +0 -0
  24. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/examples/docs.md +0 -0
  25. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/examples/openapi.json +0 -0
  26. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/examples/server.json +0 -0
  27. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/authenticated_http.py +0 -0
  28. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/client.py +0 -0
  29. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/commands/__init__.py +0 -0
  30. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/commands/context.py +0 -0
  31. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/commands/publish.py +0 -0
  32. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/commands/query.py +0 -0
  33. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/commands/runner.py +0 -0
  34. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/commands/validate.py +0 -0
  35. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/config.py +0 -0
  36. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/main.py +0 -0
  37. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/tls.py +0 -0
  38. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/lightnow_cli/validation.py +0 -0
  39. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/tests/__init__.py +0 -0
  40. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/tests/test_authenticated_http.py +0 -0
  41. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/tests/test_client.py +0 -0
  42. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/tests/test_config.py +0 -0
  43. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/tests/test_context.py +0 -0
  44. {lightnow_cli-1.0.5 → lightnow_cli-1.1.0}/tests/test_tls.py +0 -0
  45. {lightnow_cli-1.0.5 → 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.5
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.5"
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",
@@ -102,7 +110,6 @@ LIGHTNOW_PROXY_ALIASES = {"lightnow", "LightNow"}
102
110
  CLIENT_INTERNAL_MCP_SERVERS = {
103
111
  "codex": {"node_repl"},
104
112
  }
105
- LOCAL_PROXY_RUNNER_VERSION = "0.1.2"
106
113
  VSCODE_VIRTUAL_TOOLS_THRESHOLD_SETTING = "github.copilot.chat.virtualTools.threshold"
107
114
  VSCODE_VIRTUAL_TOOLS_THRESHOLD = 128
108
115
  LOCAL_PROXY_STDIO_SUPPORT_MESSAGE = (
@@ -125,6 +132,27 @@ def default_local_proxy_config_path(client: str) -> Path:
125
132
  return DEFAULT_LOCAL_PROXY_CONFIG_DIR / f"{safe_client}.yaml"
126
133
 
127
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
+
128
156
  def discover_local_lightnow_ca_file(registry_api_url: str) -> Optional[Path]:
129
157
  """Find the local LightNow CA for local development Registry API URLs."""
130
158
  parsed = urlparse(registry_api_url)
@@ -326,8 +354,11 @@ def sync(
326
354
  proxy_target = (
327
355
  local_proxy_config_path or default_local_proxy_config_path(client)
328
356
  ).expanduser()
357
+ proxy_default_alias: Optional[Path] = None
329
358
  settings_local_proxy_summary: dict[str, Any] = {}
330
359
  removed_direct_servers: list[str] = []
360
+ remove_unmanaged_client_servers = local_proxy
361
+ policy_managed_sync = False
331
362
 
332
363
  try:
333
364
  if from_settings:
@@ -348,6 +379,10 @@ def sync(
348
379
  )
349
380
  if local_proxy_settings.get("enabled") is True and client_is_managed:
350
381
  local_proxy = True
382
+ policy_managed_sync = True
383
+ remove_unmanaged_client_servers = (
384
+ local_proxy_settings.get("allowUnmanagedClientServers") is False
385
+ )
351
386
  configured_profile = local_proxy_settings.get("profile")
352
387
  if isinstance(configured_profile, str) and configured_profile:
353
388
  profile = configured_profile
@@ -355,6 +390,11 @@ def sync(
355
390
  profile = str(settings_payload["defaultProfile"])
356
391
 
357
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
+ )
358
398
  generated = build_local_proxy_export(
359
399
  client=client,
360
400
  export_format=export_format,
@@ -405,12 +445,18 @@ def sync(
405
445
  if export_format == "json"
406
446
  else {"aliases": [], "input_ids": []}
407
447
  )
408
- if local_proxy:
448
+ if local_proxy and remove_unmanaged_client_servers:
409
449
  removed_direct_servers = direct_server_aliases(existing, export_format)
410
- 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
+ ):
411
456
  existing = prepare_codex_local_proxy_config(existing)
412
457
  if (
413
458
  local_proxy
459
+ and remove_unmanaged_client_servers
414
460
  and client in LOCAL_PROXY_JSON_CLIENTS
415
461
  and export_format == "json"
416
462
  ):
@@ -445,6 +491,10 @@ def sync(
445
491
  err_console.print(
446
492
  f"[cyan]Would write Local Proxy config: {proxy_target}[/cyan]"
447
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
+ )
448
498
  if removed_direct_servers:
449
499
  err_console.print(
450
500
  "[yellow]Would remove direct MCP server entries:[/yellow] "
@@ -471,7 +521,7 @@ def sync(
471
521
  console.print("[yellow]Canceled.[/yellow]")
472
522
  raise typer.Exit(1)
473
523
 
474
- 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:
475
525
  console.print(
476
526
  f"[yellow]Local Proxy Mode removes direct MCP server entries from {target}:[/yellow] "
477
527
  f"{', '.join(removed_direct_servers)}"
@@ -490,6 +540,11 @@ def sync(
490
540
  if local_proxy:
491
541
  secure_write_text(proxy_target, proxy_config)
492
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
+ )
493
548
  if client == "claude-code":
494
549
  warm_local_proxy_tools_cache(proxy_target)
495
550
 
@@ -520,6 +575,10 @@ def sync(
520
575
  console.print(
521
576
  "[cyan]LightNow policy is enforce: managed clients should keep only the Local Proxy MCP entry.[/cyan]"
522
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
+ )
523
582
  console.print(
524
583
  f"[cyan]Next: restart {client} so it picks up the LightNow MCP entry.[/cyan]"
525
584
  )
@@ -601,9 +660,10 @@ def import_config(
601
660
  if client not in CLIENT_DEFAULTS:
602
661
  raise_bad_argument("Unsupported client", f"Use one of: {', '.join(CLIENTS)}")
603
662
  _, default_path = CLIENT_DEFAULTS[client]
604
- if client != "codex":
663
+ if client not in IMPORT_CLIENTS:
605
664
  raise_bad_argument(
606
- "Unsupported import client", "Config import currently supports Codex."
665
+ "Unsupported import client",
666
+ f"Config import supports: {', '.join(sorted(IMPORT_CLIENTS))}.",
607
667
  )
608
668
 
609
669
  target = (config_path or default_path).expanduser()
@@ -861,7 +921,6 @@ def build_local_proxy_config(
861
921
  "client_name": client,
862
922
  "client_version": None,
863
923
  "runner_name": "lightnow-local-proxy",
864
- "runner_version": LOCAL_PROXY_RUNNER_VERSION,
865
924
  "client_transport": (
866
925
  "streamable-http" if local_proxy_transport == "http" else "stdio"
867
926
  ),
@@ -1403,7 +1462,10 @@ def augment_local_proxy_posture(status: dict[str, Any]) -> dict[str, Any]:
1403
1462
  """Add Local Proxy runtime checks (config file, lightnow-proxy binary) to a posture."""
1404
1463
  warnings = [str(item) for item in status.get("warnings", [])]
1405
1464
  expected_path = status.get("expected_proxy_config_path")
1406
- 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()
1407
1469
  proxy_commands = [
1408
1470
  str(item) for item in status.get("local_proxy_commands", []) if str(item)
1409
1471
  ]
@@ -1418,6 +1480,39 @@ def augment_local_proxy_posture(status: dict[str, Any]) -> dict[str, Any]:
1418
1480
  if status.get("local_proxy_present"):
1419
1481
  if not proxy_config_exists:
1420
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")
1421
1516
  if not proxy_command_available:
1422
1517
  if proxy_commands and all(
1423
1518
  "/" in command or "\\" in command for command in proxy_commands
@@ -1515,6 +1610,14 @@ def warning_hint(code: str, client: str) -> Optional[str]:
1515
1610
  "the LightNow entry points at a different Local Proxy config; "
1516
1611
  f"re-run `{sync_command}` to update it."
1517
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
+ ),
1518
1621
  }
1519
1622
  return hints.get(code)
1520
1623
 
@@ -1567,6 +1670,15 @@ def print_config_status(status: dict[str, Any], path: Path) -> None:
1567
1670
  exists = status.get("proxy_config_exists")
1568
1671
  suffix = "" if exists is None else (" (present)" if exists else " (missing)")
1569
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
+ )
1570
1682
  unmanaged = status.get("unmanaged_servers")
1571
1683
  if isinstance(unmanaged, list) and unmanaged:
1572
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.5"
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.5" 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,
@@ -499,6 +502,15 @@ def test_default_local_proxy_config_path_is_client_specific() -> None:
499
502
  )
500
503
 
501
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
+
502
514
  def test_analyzes_managed_codex_local_proxy_config() -> None:
503
515
  """Posture scanner recognizes a clean Local Proxy config."""
504
516
  status = analyze_client_config_content(
@@ -1118,7 +1130,6 @@ def test_sync_local_proxy_replaces_existing_codex_mcp_servers() -> None:
1118
1130
  "client_name": "codex",
1119
1131
  "client_version": None,
1120
1132
  "runner_name": "lightnow-local-proxy",
1121
- "runner_version": "0.1.2",
1122
1133
  "client_transport": "stdio",
1123
1134
  }
1124
1135
  assert proxy_payload["registry_api"]["use_cli_session"] is True
@@ -1127,6 +1138,50 @@ def test_sync_local_proxy_replaces_existing_codex_mcp_servers() -> None:
1127
1138
  assert proxy_payload["upstreams"] == {}
1128
1139
 
1129
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
+
1130
1185
  def test_sync_local_proxy_replaces_existing_claude_desktop_mcp_servers() -> None:
1131
1186
  """Claude Desktop Local Proxy sync leaves one LightNow MCP server entry."""
1132
1187
  runner = CliRunner()
@@ -1594,6 +1649,118 @@ def test_sync_from_settings_uses_local_proxy_policy() -> None:
1594
1649
  assert "policy is enforce" in result.stdout
1595
1650
 
1596
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
+
1597
1764
  def test_sync_from_settings_uses_direct_export_for_unmanaged_client() -> None:
1598
1765
  """Settings-driven sync leaves clients outside managedClients on direct config."""
1599
1766
  runner = CliRunner()
@@ -2449,6 +2616,81 @@ def test_import_config_command_prints_only_redacted_summary() -> None:
2449
2616
  importer.assert_called_once()
2450
2617
 
2451
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
+
2452
2694
  def test_fetch_export_refreshes_and_retries_after_unauthorized() -> None:
2453
2695
  """Export requests share the central refresh-and-retry path."""
2454
2696
 
@@ -2975,6 +3217,72 @@ def test_config_status_reports_missing_absolute_proxy_command() -> None:
2975
3217
  assert payload["warnings"] == ["lightnow_proxy_command_missing"]
2976
3218
 
2977
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
+
2978
3286
  def test_config_status_prints_next_step_for_unmanaged_config() -> None:
2979
3287
  """Human posture output tells lazy users the exact next command."""
2980
3288
  runner = CliRunner()
File without changes
File without changes
File without changes
File without changes