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