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