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.
- lightnow_cli-1.2.0/.github/workflows/release-please.yml +24 -0
- lightnow_cli-1.2.0/.release-please-manifest.json +3 -0
- lightnow_cli-1.2.0/CHANGELOG.md +58 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/PKG-INFO +9 -1
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/README.md +4 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/docs/development.md +17 -2
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/docs/release.md +25 -1
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/__init__.py +1 -1
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/auth.py +26 -7
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/integrations.py +228 -8
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/runner.py +181 -1
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/pyproject.toml +6 -1
- lightnow_cli-1.2.0/release-please-config.json +18 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_auth_flow.py +23 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_cli.py +32 -1
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_integrations.py +522 -1
- lightnow_cli-1.0.5/CHANGELOG.md +0 -30
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/.github/workflows/ci.yml +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/.github/workflows/release.yml +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/.gitignore +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/CONTRIBUTING.md +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/LICENSE +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/Makefile +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/SECURITY.md +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/examples/README.md +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/examples/docs.md +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/examples/openapi.json +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/examples/server.json +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/authenticated_http.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/client.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/__init__.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/context.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/publish.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/query.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/commands/validate.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/config.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/main.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/tls.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/lightnow_cli/validation.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/__init__.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_authenticated_http.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_client.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_config.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_context.py +0 -0
- {lightnow_cli-1.0.5 → lightnow_cli-1.2.0}/tests/test_tls.py +0 -0
- {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,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
|
|
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`:
|
|
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
|
-
|
|
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.
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
752
|
+
if client not in IMPORT_CLIENTS:
|
|
605
753
|
raise_bad_argument(
|
|
606
|
-
"Unsupported import client",
|
|
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
|
-
|
|
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(
|