lightnow-cli 1.0.1__tar.gz → 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/PKG-INFO +1 -1
  2. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/__init__.py +1 -1
  3. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/client.py +2 -4
  4. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/auth.py +5 -0
  5. lightnow_cli-1.0.3/lightnow_cli/commands/context.py +198 -0
  6. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/integrations.py +4 -3
  7. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/publish.py +3 -1
  8. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/query.py +11 -3
  9. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/runner.py +3 -2
  10. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/config.py +46 -1
  11. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/main.py +2 -1
  12. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/pyproject.toml +1 -1
  13. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_cli.py +173 -2
  14. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_client.py +30 -0
  15. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_config.py +40 -2
  16. lightnow_cli-1.0.3/tests/test_context.py +190 -0
  17. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_integrations.py +160 -0
  18. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/.github/workflows/ci.yml +0 -0
  19. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/.github/workflows/release.yml +0 -0
  20. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/.gitignore +0 -0
  21. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/CHANGELOG.md +0 -0
  22. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/CONTRIBUTING.md +0 -0
  23. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/LICENSE +0 -0
  24. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/Makefile +0 -0
  25. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/README.md +0 -0
  26. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/SECURITY.md +0 -0
  27. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/docs/development.md +0 -0
  28. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/docs/release.md +0 -0
  29. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/examples/README.md +0 -0
  30. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/examples/docs.md +0 -0
  31. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/examples/openapi.json +0 -0
  32. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/examples/server.json +0 -0
  33. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/authenticated_http.py +0 -0
  34. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/__init__.py +0 -0
  35. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/validate.py +0 -0
  36. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/tls.py +0 -0
  37. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/validation.py +0 -0
  38. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/__init__.py +0 -0
  39. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_auth_flow.py +0 -0
  40. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_tls.py +0 -0
  41. {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightnow-cli
3
- Version: 1.0.1
3
+ Version: 1.0.3
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
@@ -1,3 +1,3 @@
1
1
  """LightNow CLI - A command-line tool for managing MCP servers."""
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "1.0.3"
@@ -151,8 +151,6 @@ class MCPRegistryClient:
151
151
  params["favorites"] = favorites
152
152
  if sort:
153
153
  params["sort"] = sort
154
- if tenant:
155
- params["tenant"] = tenant
156
154
  if cursor:
157
155
  params["cursor"] = cursor
158
156
 
@@ -163,6 +161,7 @@ class MCPRegistryClient:
163
161
  params=params,
164
162
  headers=self._get_headers(),
165
163
  token=self.token,
164
+ tenant=tenant,
166
165
  allow_refresh=self._refresh_on_unauthorized,
167
166
  timeout=30.0,
168
167
  )
@@ -191,8 +190,6 @@ class MCPRegistryClient:
191
190
  ) -> Dict[str, Any]:
192
191
  """Get detailed information about a specific server."""
193
192
  params: Dict[str, Any] = {}
194
- if tenant:
195
- params["tenant"] = tenant
196
193
 
197
194
  encoded_server_id = quote(server_id, safe="")
198
195
  if version:
@@ -209,6 +206,7 @@ class MCPRegistryClient:
209
206
  params=params,
210
207
  headers=self._get_headers(),
211
208
  token=self.token,
209
+ tenant=tenant,
212
210
  allow_refresh=self._refresh_on_unauthorized,
213
211
  timeout=30.0,
214
212
  )
@@ -17,6 +17,7 @@ from typing_extensions import Annotated
17
17
  from ..config import (
18
18
  DEFAULT_CLIENT_ID,
19
19
  DEFAULT_ISSUER,
20
+ LOCAL_ADMIN_API_URL,
20
21
  LOCAL_ISSUER,
21
22
  LOCAL_REGISTRY_API_URL,
22
23
  config_manager,
@@ -380,12 +381,14 @@ def login(
380
381
  resolved_issuer = issuer or (LOCAL_ISSUER if local else DEFAULT_ISSUER)
381
382
  resolved_client_id = client_id or DEFAULT_CLIENT_ID
382
383
  resolved_registry_api_url = LOCAL_REGISTRY_API_URL if local else None
384
+ resolved_admin_api_url = LOCAL_ADMIN_API_URL if local else None
383
385
 
384
386
  # Store auth config
385
387
  config_manager.set_auth_config(
386
388
  resolved_issuer,
387
389
  resolved_client_id,
388
390
  resolved_registry_api_url,
391
+ resolved_admin_api_url,
389
392
  )
390
393
 
391
394
  # Perform device code flow
@@ -446,6 +449,8 @@ def status() -> None:
446
449
  if email:
447
450
  console.print(f"Email: [bold]{email}[/bold]")
448
451
 
452
+ console.print(f"Context: [bold]{config_manager.context_display_name()}[/bold]")
453
+
449
454
  exp = user_info.get("exp")
450
455
  if exp:
451
456
  exp_time = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(exp))
@@ -0,0 +1,198 @@
1
+ """Context selection commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from typing_extensions import Annotated
12
+
13
+ from ..authenticated_http import (
14
+ authentication_error_from_response,
15
+ request_with_refresh,
16
+ )
17
+ from ..config import config_manager
18
+ from .auth import (
19
+ ACCESS_TOKEN_EXPIRED_MESSAGE,
20
+ AccessTokenExpired,
21
+ AuthError,
22
+ require_access_token,
23
+ )
24
+
25
+ console = Console()
26
+
27
+
28
+ def context(
29
+ tenant: Annotated[
30
+ Optional[str],
31
+ typer.Option(
32
+ "--tenant",
33
+ help="Select an organization by exact tenant id or subdomain.",
34
+ ),
35
+ ] = None,
36
+ personal: Annotated[
37
+ bool,
38
+ typer.Option("--personal", help="Use the personal account context."),
39
+ ] = False,
40
+ show: Annotated[
41
+ bool,
42
+ typer.Option("--show", help="Show the currently stored context."),
43
+ ] = False,
44
+ ) -> None:
45
+ """Select the default LightNow context for context-aware commands."""
46
+ if personal and tenant:
47
+ console.print(
48
+ "[bold red]Context failed:[/bold red] choose --personal or --tenant."
49
+ )
50
+ raise typer.Exit(2)
51
+
52
+ if show:
53
+ show_current_context()
54
+ return
55
+
56
+ if personal:
57
+ config_manager.set_personal_context()
58
+ console.print("[green]Context set to Personal.[/green]")
59
+ return
60
+
61
+ try:
62
+ tenants = fetch_tenants()
63
+ except AccessTokenExpired:
64
+ console.print(f"[red]{ACCESS_TOKEN_EXPIRED_MESSAGE}[/red]")
65
+ raise typer.Exit(1)
66
+ except (AuthError, ValueError) as exc:
67
+ console.print(f"[bold red]Context failed:[/bold red] {exc}")
68
+ raise typer.Exit(1) from exc
69
+
70
+ if tenant:
71
+ selected = find_tenant(tenants, tenant)
72
+ if selected is None:
73
+ console.print(
74
+ "[bold red]Context failed:[/bold red] "
75
+ f"No organization matches '{tenant}'. Use 'lightnow context' "
76
+ "to choose from available organizations."
77
+ )
78
+ raise typer.Exit(1)
79
+ store_tenant_context(selected)
80
+ return
81
+
82
+ choose_interactively(tenants)
83
+
84
+
85
+ def show_current_context() -> None:
86
+ """Print the currently stored context."""
87
+ config = config_manager.load_config()
88
+ table = Table(title="LightNow Context")
89
+ table.add_column("Scope", style="cyan")
90
+ table.add_column("Details", style="white")
91
+ if config.context_type == "tenant":
92
+ table.add_row(
93
+ "Organization", config.context_label or config.context_tenant or ""
94
+ )
95
+ if config.context_tenant:
96
+ table.add_row("Tenant ID", config.context_tenant)
97
+ else:
98
+ table.add_row("Personal", "Personal LightNow account")
99
+ console.print(table)
100
+
101
+
102
+ def fetch_tenants() -> list[dict[str, Any]]:
103
+ """Fetch organizations available to the authenticated account."""
104
+ token = require_access_token()
105
+ config = config_manager.load_config()
106
+ admin_api_url = config.admin_api_url
107
+ if not admin_api_url:
108
+ raise ValueError("Admin API URL required. Run 'lightnow login' again.")
109
+
110
+ try:
111
+ response = request_with_refresh(
112
+ "GET",
113
+ f"{admin_api_url.rstrip('/')}/tenants",
114
+ token=token,
115
+ headers={"Accept": "application/json"},
116
+ timeout=30.0,
117
+ )
118
+ except httpx.RequestError as exc:
119
+ raise ValueError(f"Network error: {exc}") from exc
120
+
121
+ if response.status_code == 401:
122
+ raise authentication_error_from_response(response)
123
+ if response.status_code >= 400:
124
+ raise ValueError(f"HTTP {response.status_code}: {response.text}")
125
+
126
+ payload = response.json()
127
+ if not isinstance(payload, list):
128
+ raise ValueError("Admin API response did not include an organization list.")
129
+ return [item for item in payload if isinstance(item, dict)]
130
+
131
+
132
+ def choose_interactively(tenants: list[dict[str, Any]]) -> None:
133
+ """Render choices and store the selected context."""
134
+ choices: list[tuple[str, Optional[dict[str, Any]]]] = [("Personal", None)]
135
+ choices.extend((tenant_label(item), item) for item in tenants)
136
+
137
+ console.print("[bold blue]Select LightNow context[/bold blue]")
138
+ for index, (label, item) in enumerate(choices, start=1):
139
+ suffix = ""
140
+ if item is not None:
141
+ role = text_value(item.get("role"))
142
+ plan = text_value(item.get("plan"))
143
+ details = " · ".join(value for value in [role, plan] if value)
144
+ suffix = f" ({details})" if details else ""
145
+ console.print(f" {index}. {label}{suffix}")
146
+
147
+ selected = typer.prompt("Context", default=1)
148
+ try:
149
+ selected_index = int(str(selected).strip())
150
+ except ValueError as exc:
151
+ raise typer.BadParameter("Context must be a number from the list.") from exc
152
+
153
+ if selected_index < 1 or selected_index > len(choices):
154
+ raise typer.BadParameter("Context selection is out of range.")
155
+
156
+ _, item = choices[selected_index - 1]
157
+ if item is None:
158
+ config_manager.set_personal_context()
159
+ console.print("[green]Context set to Personal.[/green]")
160
+ return
161
+
162
+ store_tenant_context(item)
163
+
164
+
165
+ def find_tenant(tenants: list[dict[str, Any]], value: str) -> Optional[dict[str, Any]]:
166
+ """Find a tenant by exact id or subdomain."""
167
+ for item in tenants:
168
+ tenant_id = text_value(item.get("id"))
169
+ subdomain = text_value(item.get("subdomain"))
170
+ if value in {tenant_id, subdomain}:
171
+ return item
172
+ return None
173
+
174
+
175
+ def store_tenant_context(item: dict[str, Any]) -> None:
176
+ """Persist a selected tenant context."""
177
+ tenant_id = text_value(item.get("id"))
178
+ if not tenant_id:
179
+ raise ValueError("Selected organization has no tenant id.")
180
+ label = tenant_label(item)
181
+ config_manager.set_tenant_context(tenant_id, label)
182
+ console.print(f"[green]Context set to organization {label}.[/green]")
183
+
184
+
185
+ def tenant_label(item: dict[str, Any]) -> str:
186
+ """Return a display label for one tenant."""
187
+ name = text_value(item.get("name"))
188
+ subdomain = text_value(item.get("subdomain"))
189
+ if name and subdomain:
190
+ return f"{name} ({subdomain})"
191
+ return name or subdomain or text_value(item.get("id")) or "Organization"
192
+
193
+
194
+ def text_value(value: Any) -> str:
195
+ """Return a safe string value."""
196
+ if isinstance(value, str):
197
+ return value
198
+ return ""
@@ -121,6 +121,7 @@ def sync(
121
121
  console.print(f"[yellow]{exc}[/yellow]")
122
122
  raise typer.Exit(1)
123
123
 
124
+ effective_tenant = config_manager.effective_tenant(tenant)
124
125
  registry_api_url = api_url or config_manager.load_config().registry_api_url
125
126
  if not registry_api_url:
126
127
  raise_bad_argument(
@@ -133,7 +134,7 @@ def sync(
133
134
  profile_payload = fetch_profile_servers(
134
135
  api_url=registry_api_url,
135
136
  token=bearer_token,
136
- tenant=tenant,
137
+ tenant=effective_tenant,
137
138
  profile=profile,
138
139
  )
139
140
  generated = build_runner_export(
@@ -141,13 +142,13 @@ def sync(
141
142
  client=client,
142
143
  export_format=export_format,
143
144
  profile=profile,
144
- tenant=tenant,
145
+ tenant=effective_tenant,
145
146
  )
146
147
  else:
147
148
  generated = fetch_export(
148
149
  api_url=registry_api_url,
149
150
  token=bearer_token,
150
- tenant=tenant,
151
+ tenant=effective_tenant,
151
152
  profile=profile,
152
153
  client=client,
153
154
  export_format=export_format,
@@ -9,6 +9,7 @@ from rich.console import Console
9
9
  from typing_extensions import Annotated
10
10
 
11
11
  from ..client import get_client
12
+ from ..config import config_manager
12
13
  from ..validation import validate_artifacts
13
14
 
14
15
  console = Console()
@@ -74,6 +75,7 @@ def publish(
74
75
  try:
75
76
  # Get client and publish
76
77
  client = get_client()
78
+ effective_tenant = config_manager.effective_tenant(tenant)
77
79
  console.print("Publishing to registry...")
78
80
 
79
81
  result = asyncio.run(
@@ -81,7 +83,7 @@ def publish(
81
83
  server_json=server_data,
82
84
  docs_content=docs_content,
83
85
  spec_content=spec_data,
84
- tenant=tenant,
86
+ tenant=effective_tenant,
85
87
  )
86
88
  )
87
89
 
@@ -9,6 +9,7 @@ from rich.table import Table
9
9
  from typing_extensions import Annotated
10
10
 
11
11
  from ..client import get_client
12
+ from ..config import config_manager
12
13
 
13
14
  console = Console()
14
15
 
@@ -43,11 +44,12 @@ def search_servers(
43
44
 
44
45
  try:
45
46
  client = get_client()
47
+ effective_tenant = config_manager.effective_tenant(tenant)
46
48
  result = asyncio.run(
47
49
  client.list_servers(
48
50
  search=query,
49
51
  sort=sort,
50
- tenant=tenant,
52
+ tenant=effective_tenant,
51
53
  limit=limit,
52
54
  cursor=cursor,
53
55
  )
@@ -109,11 +111,12 @@ def favorite_servers(
109
111
 
110
112
  try:
111
113
  client = get_client()
114
+ effective_tenant = config_manager.effective_tenant(tenant)
112
115
  result = asyncio.run(
113
116
  client.list_servers(
114
117
  favorites=normalized_scope,
115
118
  sort=sort,
116
- tenant=tenant,
119
+ tenant=effective_tenant,
117
120
  limit=limit,
118
121
  cursor=cursor,
119
122
  )
@@ -208,8 +211,13 @@ def server_info(
208
211
 
209
212
  try:
210
213
  client = get_client()
214
+ effective_tenant = config_manager.effective_tenant(tenant)
211
215
  result = asyncio.run(
212
- client.get_server_info(server_id=server_id, version=version, tenant=tenant)
216
+ client.get_server_info(
217
+ server_id=server_id,
218
+ version=version,
219
+ tenant=effective_tenant,
220
+ )
213
221
  )
214
222
  payload = server_payload(result)
215
223
 
@@ -62,6 +62,7 @@ def run(
62
62
  try:
63
63
  token = require_access_token()
64
64
  registry_api_url = api_url or config_manager.load_config().registry_api_url
65
+ effective_tenant = config_manager.effective_tenant(tenant)
65
66
  if not registry_api_url:
66
67
  raise ValueError(
67
68
  "Registry API URL required. Configure the CLI or pass --api-url."
@@ -69,14 +70,14 @@ def run(
69
70
  selected = resolve_profile_server(
70
71
  api_url=registry_api_url,
71
72
  token=token,
72
- tenant=tenant,
73
+ tenant=effective_tenant,
73
74
  profile=profile,
74
75
  server=server,
75
76
  )
76
77
  context = fetch_runtime_context(
77
78
  api_url=registry_api_url,
78
79
  token=token,
79
- tenant=tenant,
80
+ tenant=effective_tenant,
80
81
  profile=profile,
81
82
  server_name=str(selected["server_name"]),
82
83
  version=str(selected["version"]),
@@ -12,8 +12,10 @@ from pydantic import BaseModel, Field
12
12
  DEFAULT_ISSUER = "https://auth.lightnow.ai/realms/lightnow"
13
13
  DEFAULT_CLIENT_ID = "lightnow-cli"
14
14
  DEFAULT_REGISTRY_API_URL = "https://registry-api.lightnow.ai/v0.1"
15
+ DEFAULT_ADMIN_API_URL = "https://admin-api.lightnow.ai/v0/portal"
15
16
  LOCAL_ISSUER = "https://auth.lightnow.local/realms/lightnow-local"
16
17
  LOCAL_REGISTRY_API_URL = "https://registry-api.lightnow.local/v0.1"
18
+ LOCAL_ADMIN_API_URL = "https://admin-api.lightnow.local/v0/portal"
17
19
 
18
20
 
19
21
  class Config(BaseModel):
@@ -24,7 +26,11 @@ class Config(BaseModel):
24
26
  issuer: Optional[str] = Field(default=DEFAULT_ISSUER)
25
27
  client_id: Optional[str] = Field(default=DEFAULT_CLIENT_ID)
26
28
  registry_api_url: Optional[str] = Field(default=DEFAULT_REGISTRY_API_URL)
29
+ admin_api_url: Optional[str] = Field(default=DEFAULT_ADMIN_API_URL)
27
30
  user_info: Optional[Dict[str, Any]] = None
31
+ context_type: str = Field(default="personal")
32
+ context_tenant: Optional[str] = None
33
+ context_label: Optional[str] = None
28
34
 
29
35
 
30
36
  class ConfigManager:
@@ -127,11 +133,14 @@ class ConfigManager:
127
133
  self.save_config(config)
128
134
 
129
135
  def clear_token(self) -> None:
130
- """Clear stored access token."""
136
+ """Clear stored access token and account-scoped context."""
131
137
  config = self.load_config()
132
138
  config.access_token = None
133
139
  config.refresh_token = None
134
140
  config.user_info = None
141
+ config.context_type = "personal"
142
+ config.context_tenant = None
143
+ config.context_label = None
135
144
  self.save_config(config)
136
145
 
137
146
  def set_auth_config(
@@ -139,14 +148,50 @@ class ConfigManager:
139
148
  issuer: str,
140
149
  client_id: str,
141
150
  registry_api_url: Optional[str] = None,
151
+ admin_api_url: Optional[str] = None,
142
152
  ) -> None:
143
153
  """Set authentication configuration."""
144
154
  config = self.load_config()
145
155
  config.issuer = issuer
146
156
  config.client_id = client_id
147
157
  config.registry_api_url = registry_api_url or DEFAULT_REGISTRY_API_URL
158
+ config.admin_api_url = admin_api_url or DEFAULT_ADMIN_API_URL
148
159
  self.save_config(config)
149
160
 
161
+ def set_personal_context(self) -> None:
162
+ """Use the personal LightNow context by default."""
163
+ config = self.load_config()
164
+ config.context_type = "personal"
165
+ config.context_tenant = None
166
+ config.context_label = None
167
+ self.save_config(config)
168
+
169
+ def set_tenant_context(self, tenant_id: str, label: str) -> None:
170
+ """Use a tenant context by default."""
171
+ if not tenant_id:
172
+ raise ValueError("Tenant context requires a tenant id.")
173
+ config = self.load_config()
174
+ config.context_type = "tenant"
175
+ config.context_tenant = tenant_id
176
+ config.context_label = label
177
+ self.save_config(config)
178
+
179
+ def effective_tenant(self, explicit_tenant: Optional[str] = None) -> Optional[str]:
180
+ """Return explicit tenant or the stored default tenant context."""
181
+ if explicit_tenant:
182
+ return explicit_tenant
183
+ config = self.load_config()
184
+ if config.context_type == "tenant":
185
+ return config.context_tenant
186
+ return None
187
+
188
+ def context_display_name(self) -> str:
189
+ """Return a human-readable current context label."""
190
+ config = self.load_config()
191
+ if config.context_type == "tenant":
192
+ return config.context_label or config.context_tenant or "Organization"
193
+ return "Personal"
194
+
150
195
 
151
196
  # Global config manager instance
152
197
  config_manager = ConfigManager()
@@ -4,7 +4,7 @@ import typer
4
4
  from typing_extensions import Annotated
5
5
 
6
6
  from . import __version__
7
- from .commands import auth, integrations, publish, query, runner, validate
7
+ from .commands import auth, context, integrations, publish, query, runner, validate
8
8
  from .tls import configure_tls_trust_store
9
9
 
10
10
  configure_tls_trust_store()
@@ -19,6 +19,7 @@ app.command("login")(auth.login)
19
19
  app.command("logout")(auth.logout)
20
20
  app.command("status")(auth.status)
21
21
  app.command("whoami")(auth.whoami)
22
+ app.command("context")(context.context)
22
23
  app.command("publish")(publish.publish)
23
24
  app.command("search")(query.search_servers)
24
25
  app.command("favorites")(query.favorite_servers)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lightnow-cli"
7
- version = "1.0.1"
7
+ version = "1.0.3"
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"}
@@ -11,6 +11,7 @@ from typer.testing import CliRunner
11
11
  from lightnow_cli.config import (
12
12
  DEFAULT_CLIENT_ID,
13
13
  DEFAULT_ISSUER,
14
+ LOCAL_ADMIN_API_URL,
14
15
  LOCAL_ISSUER,
15
16
  LOCAL_REGISTRY_API_URL,
16
17
  )
@@ -51,7 +52,7 @@ def test_cli_version(runner):
51
52
  """Test CLI version command."""
52
53
  result = runner.invoke(app, ["--version"])
53
54
  assert result.exit_code == 0
54
- assert "LightNow CLI 1.0.1" in result.stdout
55
+ assert "LightNow CLI 1.0.3" in result.stdout
55
56
 
56
57
 
57
58
  def test_cli_help(runner):
@@ -69,6 +70,7 @@ def test_cli_help(runner):
69
70
  assert "status" in result.stdout
70
71
  assert "logout" in result.stdout
71
72
  assert "whoami" in result.stdout
73
+ assert "context" in result.stdout
72
74
  assert "list" not in result.stdout
73
75
  assert "│ auth " not in result.stdout
74
76
  assert "│ integrations " not in result.stdout
@@ -91,6 +93,7 @@ def test_login_help_is_customer_friendly(runner):
91
93
  "logout",
92
94
  "status",
93
95
  "whoami",
96
+ "context",
94
97
  "publish",
95
98
  "search",
96
99
  "favorites",
@@ -175,6 +178,173 @@ def test_run_command_starts_profile_server(runner):
175
178
  assert run_process.call_args.kwargs["env"]["SONARQUBE_TOKEN"] == "secret"
176
179
 
177
180
 
181
+ def test_run_command_passes_tenant_to_profile_and_context_requests(runner):
182
+ """The local runner keeps organization context for lookup and secret context."""
183
+ with (
184
+ patch(
185
+ "lightnow_cli.commands.runner.require_access_token",
186
+ return_value="token",
187
+ ),
188
+ patch(
189
+ "lightnow_cli.commands.runner.resolve_profile_server",
190
+ return_value={
191
+ "alias": "sonarqube",
192
+ "server_name": "io.github.sonarsource/sonarqube-mcp-server",
193
+ "version": "1.2.3",
194
+ "status": "linked",
195
+ },
196
+ ) as resolve,
197
+ patch(
198
+ "lightnow_cli.commands.runner.fetch_runtime_context",
199
+ return_value={"probe_request": {"transport": "stdio", "stdio": {}}},
200
+ ) as fetch_context,
201
+ patch(
202
+ "lightnow_cli.commands.runner.launch_config_from_context",
203
+ return_value=("python", ["-m", "server"], {}, None),
204
+ ),
205
+ patch(
206
+ "lightnow_cli.commands.runner.subprocess.run",
207
+ return_value=MagicMock(returncode=0),
208
+ ),
209
+ ):
210
+ result = runner.invoke(
211
+ app,
212
+ [
213
+ "run",
214
+ "--server",
215
+ "sonarqube",
216
+ "--profile",
217
+ "default",
218
+ "--tenant",
219
+ "acme",
220
+ "--api-url",
221
+ "https://registry-api.lightnow.local/v0.1",
222
+ ],
223
+ )
224
+
225
+ assert result.exit_code == 0
226
+ assert resolve.call_args.kwargs["tenant"] == "acme"
227
+ assert fetch_context.call_args.kwargs["tenant"] == "acme"
228
+
229
+
230
+ def test_run_command_uses_stored_context(runner):
231
+ """The local runner uses the stored organization context by default."""
232
+ with (
233
+ patch(
234
+ "lightnow_cli.commands.runner.require_access_token",
235
+ return_value="token",
236
+ ),
237
+ patch(
238
+ "lightnow_cli.commands.runner.config_manager.effective_tenant",
239
+ return_value="tenant-uuid",
240
+ ),
241
+ patch(
242
+ "lightnow_cli.commands.runner.resolve_profile_server",
243
+ return_value={
244
+ "alias": "sonarqube",
245
+ "server_name": "io.github.sonarsource/sonarqube-mcp-server",
246
+ "version": "1.2.3",
247
+ "status": "linked",
248
+ },
249
+ ) as resolve,
250
+ patch(
251
+ "lightnow_cli.commands.runner.fetch_runtime_context",
252
+ return_value={"probe_request": {"transport": "stdio", "stdio": {}}},
253
+ ) as fetch_context,
254
+ patch(
255
+ "lightnow_cli.commands.runner.launch_config_from_context",
256
+ return_value=("python", ["-m", "server"], {}, None),
257
+ ),
258
+ patch(
259
+ "lightnow_cli.commands.runner.subprocess.run",
260
+ return_value=MagicMock(returncode=0),
261
+ ),
262
+ ):
263
+ result = runner.invoke(
264
+ app,
265
+ [
266
+ "run",
267
+ "--server",
268
+ "sonarqube",
269
+ "--profile",
270
+ "default",
271
+ "--api-url",
272
+ "https://registry-api.lightnow.local/v0.1",
273
+ ],
274
+ )
275
+
276
+ assert result.exit_code == 0
277
+ assert resolve.call_args.kwargs["tenant"] == "tenant-uuid"
278
+ assert fetch_context.call_args.kwargs["tenant"] == "tenant-uuid"
279
+
280
+
281
+ def test_search_command_passes_tenant_context(runner):
282
+ """Search supports organization context from the public CLI surface."""
283
+ fake_client = MagicMock()
284
+ fake_client.list_servers = AsyncMock(return_value={"servers": []})
285
+
286
+ with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
287
+ result = runner.invoke(
288
+ app, ["search", "redis", "--tenant", "acme", "--limit", "5"]
289
+ )
290
+
291
+ assert result.exit_code == 0
292
+ assert fake_client.list_servers.call_args.kwargs["tenant"] == "acme"
293
+
294
+
295
+ def test_search_command_uses_stored_context(runner):
296
+ """Search uses the stored context when --tenant is omitted."""
297
+ fake_client = MagicMock()
298
+ fake_client.list_servers = AsyncMock(return_value={"servers": []})
299
+
300
+ with (
301
+ patch("lightnow_cli.commands.query.get_client", return_value=fake_client),
302
+ patch(
303
+ "lightnow_cli.commands.query.config_manager.effective_tenant",
304
+ return_value="tenant-uuid",
305
+ ) as effective_tenant,
306
+ ):
307
+ result = runner.invoke(app, ["search", "redis"])
308
+
309
+ assert result.exit_code == 0
310
+ effective_tenant.assert_called_once_with(None)
311
+ assert fake_client.list_servers.call_args.kwargs["tenant"] == "tenant-uuid"
312
+
313
+
314
+ def test_favorites_command_passes_tenant_context(runner):
315
+ """Favorites supports organization-scoped favorites from the CLI."""
316
+ fake_client = MagicMock()
317
+ fake_client.list_servers = AsyncMock(return_value={"servers": []})
318
+
319
+ with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
320
+ result = runner.invoke(
321
+ app, ["favorites", "--scope", "tenant", "--tenant", "acme"]
322
+ )
323
+
324
+ assert result.exit_code == 0
325
+ assert fake_client.list_servers.call_args.kwargs["favorites"] == "tenant"
326
+ assert fake_client.list_servers.call_args.kwargs["tenant"] == "acme"
327
+
328
+
329
+ def test_info_command_passes_tenant_context(runner):
330
+ """Server info supports organization context from the public CLI surface."""
331
+ fake_client = MagicMock()
332
+ fake_client.get_server_info = AsyncMock(
333
+ return_value={
334
+ "name": "io.github.test/server",
335
+ "version": "1.0.0",
336
+ }
337
+ )
338
+
339
+ with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
340
+ result = runner.invoke(
341
+ app, ["info", "io.github.test/server", "--tenant", "acme"]
342
+ )
343
+
344
+ assert result.exit_code == 0
345
+ assert fake_client.get_server_info.call_args.kwargs["tenant"] == "acme"
346
+
347
+
178
348
  def test_run_command_reports_expired_token(runner):
179
349
  """Runner commands use the shared expired-token message."""
180
350
  from lightnow_cli.commands.auth import (
@@ -304,7 +474,7 @@ def test_login_command_success(
304
474
 
305
475
  # Verify mocks were called
306
476
  mock_set_auth_config.assert_called_once_with(
307
- DEFAULT_ISSUER, DEFAULT_CLIENT_ID, None
477
+ DEFAULT_ISSUER, DEFAULT_CLIENT_ID, None, None
308
478
  )
309
479
  mock_device_flow.assert_called_once_with(DEFAULT_ISSUER, DEFAULT_CLIENT_ID)
310
480
  mock_fetch_user_info.assert_called_once_with(DEFAULT_ISSUER, "mock-access-token")
@@ -333,6 +503,7 @@ def test_login_command_local_profile(
333
503
  LOCAL_ISSUER,
334
504
  DEFAULT_CLIENT_ID,
335
505
  LOCAL_REGISTRY_API_URL,
506
+ LOCAL_ADMIN_API_URL,
336
507
  )
337
508
  mock_device_flow.assert_called_once_with(LOCAL_ISSUER, DEFAULT_CLIENT_ID)
338
509
  mock_fetch_user_info.assert_called_once_with(LOCAL_ISSUER, "mock-access-token")
@@ -221,6 +221,21 @@ async def test_client_lists_servers(monkeypatch) -> None:
221
221
  assert fake.last_params["cursor"] == "cursor-token"
222
222
 
223
223
 
224
+ @pytest.mark.asyncio
225
+ async def test_client_lists_servers_with_tenant_header(monkeypatch) -> None:
226
+ """Tenant context for list queries is sent as X-Tenant, not as a query param."""
227
+ fake = FakeHttpClient()
228
+ monkeypatch.setattr("lightnow_cli.client.httpx.AsyncClient", lambda: fake)
229
+
230
+ client = MCPRegistryClient(
231
+ base_url="https://registry-api.lightnow.local/v0.1", token="token"
232
+ )
233
+ await client.list_servers(favorites="tenant", tenant="tenant-uuid")
234
+
235
+ assert fake.last_headers["X-Tenant"] == "tenant-uuid"
236
+ assert "tenant" not in fake.last_params
237
+
238
+
224
239
  @pytest.mark.asyncio
225
240
  async def test_client_gets_server_info(monkeypatch) -> None:
226
241
  """Server info lookup returns the decoded registry payload."""
@@ -236,6 +251,21 @@ async def test_client_gets_server_info(monkeypatch) -> None:
236
251
  assert fake.last_url.endswith("/servers/github/versions/1.0.0")
237
252
 
238
253
 
254
+ @pytest.mark.asyncio
255
+ async def test_client_gets_server_info_with_tenant_header(monkeypatch) -> None:
256
+ """Tenant context for server detail lookup is sent as X-Tenant."""
257
+ fake = FakeHttpClient()
258
+ monkeypatch.setattr("lightnow_cli.client.httpx.AsyncClient", lambda: fake)
259
+
260
+ client = MCPRegistryClient(
261
+ base_url="https://registry-api.lightnow.local/v0.1", token="token"
262
+ )
263
+ await client.get_server_info("github", version="1.0.0", tenant="tenant-uuid")
264
+
265
+ assert fake.last_headers["X-Tenant"] == "tenant-uuid"
266
+ assert "tenant" not in fake.last_params
267
+
268
+
239
269
  @pytest.mark.asyncio
240
270
  async def test_client_encodes_registry_server_names(monkeypatch) -> None:
241
271
  """Registry server names with slashes are encoded in path parameters."""
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import pytest
8
8
 
9
9
  from lightnow_cli.config import (
10
+ DEFAULT_ADMIN_API_URL,
10
11
  DEFAULT_CLIENT_ID,
11
12
  DEFAULT_ISSUER,
12
13
  DEFAULT_REGISTRY_API_URL,
@@ -48,6 +49,9 @@ def test_load_default_config(config_manager):
48
49
  assert config.issuer == DEFAULT_ISSUER
49
50
  assert config.client_id == DEFAULT_CLIENT_ID
50
51
  assert config.registry_api_url == DEFAULT_REGISTRY_API_URL
52
+ assert config.admin_api_url == DEFAULT_ADMIN_API_URL
53
+ assert config.context_type == "personal"
54
+ assert config.context_tenant is None
51
55
 
52
56
 
53
57
  def test_save_and_load_config(config_manager):
@@ -120,20 +124,54 @@ def test_auth_config_methods(config_manager):
120
124
  assert config.issuer == "https://auth.example.com"
121
125
  assert config.client_id == "my-client"
122
126
  assert config.registry_api_url == DEFAULT_REGISTRY_API_URL
127
+ assert config.admin_api_url == DEFAULT_ADMIN_API_URL
123
128
 
124
129
 
125
- def test_auth_config_accepts_registry_api_override(config_manager):
126
- """Local auth can persist the matching local Registry API URL."""
130
+ def test_auth_config_accepts_api_overrides(config_manager):
131
+ """Local auth can persist matching local API URLs."""
127
132
  config_manager.set_auth_config(
128
133
  "https://auth.example.com",
129
134
  "my-client",
130
135
  "https://registry-api.example.com/v0.1",
136
+ "https://admin-api.example.com/v0/portal",
131
137
  )
132
138
 
133
139
  config = config_manager.load_config()
134
140
  assert config.issuer == "https://auth.example.com"
135
141
  assert config.client_id == "my-client"
136
142
  assert config.registry_api_url == "https://registry-api.example.com/v0.1"
143
+ assert config.admin_api_url == "https://admin-api.example.com/v0/portal"
144
+
145
+
146
+ def test_context_methods(config_manager):
147
+ """Stored context controls default tenant headers."""
148
+ assert config_manager.effective_tenant() is None
149
+ assert config_manager.context_display_name() == "Personal"
150
+
151
+ config_manager.set_tenant_context("tenant-uuid", "Acme (acme)")
152
+
153
+ assert config_manager.effective_tenant() == "tenant-uuid"
154
+ assert config_manager.effective_tenant("explicit-tenant") == "explicit-tenant"
155
+ assert config_manager.context_display_name() == "Acme (acme)"
156
+
157
+ config_manager.set_personal_context()
158
+
159
+ assert config_manager.effective_tenant() is None
160
+ assert config_manager.context_display_name() == "Personal"
161
+
162
+
163
+ def test_logout_clears_context(config_manager):
164
+ """Logging out removes account-scoped organization context."""
165
+ config_manager.set_token("token", "refresh", None)
166
+ config_manager.set_tenant_context("tenant-uuid", "Acme (acme)")
167
+
168
+ config_manager.clear_token()
169
+
170
+ config = config_manager.load_config()
171
+ assert config.access_token is None
172
+ assert config.refresh_token is None
173
+ assert config.context_type == "personal"
174
+ assert config.context_tenant is None
137
175
 
138
176
 
139
177
  def test_config_file_permissions(config_manager):
@@ -0,0 +1,190 @@
1
+ """Tests for LightNow context selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from typer.testing import CliRunner
8
+
9
+ from lightnow_cli.config import Config
10
+ from lightnow_cli.main import app
11
+
12
+
13
+ class FakeResponse:
14
+ """Small response stub for context API tests."""
15
+
16
+ def __init__(self, status_code: int, payload: object, text: str = "") -> None:
17
+ self.status_code = status_code
18
+ self._payload = payload
19
+ self.text = text
20
+
21
+ def json(self) -> object:
22
+ return self._payload
23
+
24
+
25
+ def test_context_show_displays_personal_context() -> None:
26
+ """The default context is Personal."""
27
+ runner = CliRunner()
28
+
29
+ with patch(
30
+ "lightnow_cli.commands.context.config_manager.load_config",
31
+ return_value=Config(),
32
+ ):
33
+ result = runner.invoke(app, ["context", "--show"])
34
+
35
+ assert result.exit_code == 0
36
+ assert "Personal" in result.stdout
37
+
38
+
39
+ def test_context_personal_stores_personal_context() -> None:
40
+ """Users can switch back to personal context explicitly."""
41
+ runner = CliRunner()
42
+
43
+ with patch(
44
+ "lightnow_cli.commands.context.config_manager.set_personal_context"
45
+ ) as set_personal:
46
+ result = runner.invoke(app, ["context", "--personal"])
47
+
48
+ assert result.exit_code == 0
49
+ set_personal.assert_called_once_with()
50
+ assert "Context set to Personal" in result.stdout
51
+
52
+
53
+ def test_context_tenant_resolves_subdomain_and_stores_tenant_id() -> None:
54
+ """Tenant subdomains are resolved once and stored as tenant IDs."""
55
+ runner = CliRunner()
56
+ response = FakeResponse(
57
+ 200,
58
+ [
59
+ {
60
+ "id": "tenant-uuid",
61
+ "name": "Acme Inc.",
62
+ "subdomain": "acme",
63
+ "role": "owner",
64
+ "plan": "enterprise",
65
+ }
66
+ ],
67
+ )
68
+
69
+ with (
70
+ patch(
71
+ "lightnow_cli.commands.context.require_access_token", return_value="token"
72
+ ),
73
+ patch(
74
+ "lightnow_cli.commands.context.config_manager.load_config",
75
+ return_value=Config(
76
+ admin_api_url="https://admin-api.example.com/v0/portal"
77
+ ),
78
+ ),
79
+ patch(
80
+ "lightnow_cli.commands.context.request_with_refresh", return_value=response
81
+ ) as request,
82
+ patch(
83
+ "lightnow_cli.commands.context.config_manager.set_tenant_context"
84
+ ) as set_tenant,
85
+ ):
86
+ result = runner.invoke(app, ["context", "--tenant", "acme"])
87
+
88
+ assert result.exit_code == 0
89
+ request.assert_called_once()
90
+ assert request.call_args.args[:2] == (
91
+ "GET",
92
+ "https://admin-api.example.com/v0/portal/tenants",
93
+ )
94
+ set_tenant.assert_called_once_with("tenant-uuid", "Acme Inc. (acme)")
95
+ assert "Context set to organization Acme Inc. (acme)" in result.stdout
96
+
97
+
98
+ def test_context_unknown_tenant_fails_without_changing_context() -> None:
99
+ """Unknown tenants fail explicitly."""
100
+ runner = CliRunner()
101
+ response = FakeResponse(
102
+ 200,
103
+ [{"id": "tenant-uuid", "name": "Acme Inc.", "subdomain": "acme"}],
104
+ )
105
+
106
+ with (
107
+ patch(
108
+ "lightnow_cli.commands.context.require_access_token", return_value="token"
109
+ ),
110
+ patch(
111
+ "lightnow_cli.commands.context.config_manager.load_config",
112
+ return_value=Config(
113
+ admin_api_url="https://admin-api.example.com/v0/portal"
114
+ ),
115
+ ),
116
+ patch(
117
+ "lightnow_cli.commands.context.request_with_refresh", return_value=response
118
+ ),
119
+ patch(
120
+ "lightnow_cli.commands.context.config_manager.set_tenant_context"
121
+ ) as set_tenant,
122
+ ):
123
+ result = runner.invoke(app, ["context", "--tenant", "missing"])
124
+
125
+ assert result.exit_code == 1
126
+ set_tenant.assert_not_called()
127
+ assert "No organization matches 'missing'" in result.stdout
128
+
129
+
130
+ def test_context_interactive_choice_stores_selected_tenant() -> None:
131
+ """Interactive context selection stores the chosen organization."""
132
+ runner = CliRunner()
133
+ response = FakeResponse(
134
+ 200,
135
+ [
136
+ {"id": "tenant-uuid", "name": "Acme Inc.", "subdomain": "acme"},
137
+ ],
138
+ )
139
+
140
+ with (
141
+ patch(
142
+ "lightnow_cli.commands.context.require_access_token", return_value="token"
143
+ ),
144
+ patch(
145
+ "lightnow_cli.commands.context.config_manager.load_config",
146
+ return_value=Config(
147
+ admin_api_url="https://admin-api.example.com/v0/portal"
148
+ ),
149
+ ),
150
+ patch(
151
+ "lightnow_cli.commands.context.request_with_refresh", return_value=response
152
+ ),
153
+ patch(
154
+ "lightnow_cli.commands.context.config_manager.set_tenant_context"
155
+ ) as set_tenant,
156
+ ):
157
+ result = runner.invoke(app, ["context"], input="2\n")
158
+
159
+ assert result.exit_code == 0
160
+ set_tenant.assert_called_once_with("tenant-uuid", "Acme Inc. (acme)")
161
+
162
+
163
+ def test_context_api_401_uses_authentication_error() -> None:
164
+ """Tenant listing uses the shared auth error path."""
165
+ runner = CliRunner()
166
+ response = MagicMock()
167
+ response.status_code = 401
168
+
169
+ with (
170
+ patch(
171
+ "lightnow_cli.commands.context.require_access_token", return_value="token"
172
+ ),
173
+ patch(
174
+ "lightnow_cli.commands.context.config_manager.load_config",
175
+ return_value=Config(
176
+ admin_api_url="https://admin-api.example.com/v0/portal"
177
+ ),
178
+ ),
179
+ patch(
180
+ "lightnow_cli.commands.context.request_with_refresh", return_value=response
181
+ ),
182
+ patch(
183
+ "lightnow_cli.commands.context.authentication_error_from_response",
184
+ side_effect=ValueError("unauthorized"),
185
+ ),
186
+ ):
187
+ result = runner.invoke(app, ["context", "--tenant", "acme"])
188
+
189
+ assert result.exit_code == 1
190
+ assert "Context failed" in result.stdout
@@ -173,6 +173,74 @@ def test_sync_defaults_to_plaintext_export_mode() -> None:
173
173
  assert fetch.call_args.kwargs["secret_mode"] == "plaintext"
174
174
 
175
175
 
176
+ def test_sync_passes_tenant_to_export_request() -> None:
177
+ """Client export sync uses the selected organization context."""
178
+ runner = CliRunner()
179
+ with tempfile.TemporaryDirectory() as tmp:
180
+ target = Path(tmp) / "config.toml"
181
+ with (
182
+ patch(
183
+ "lightnow_cli.commands.integrations.require_access_token",
184
+ return_value="token",
185
+ ),
186
+ patch(
187
+ "lightnow_cli.commands.integrations.fetch_export",
188
+ return_value='[mcp_servers.github]\ncommand = "docker"\n',
189
+ ) as fetch,
190
+ ):
191
+ result = runner.invoke(
192
+ app,
193
+ [
194
+ "sync",
195
+ "--client",
196
+ "codex",
197
+ "--tenant",
198
+ "acme",
199
+ "--config-path",
200
+ str(target),
201
+ ],
202
+ input="y\n",
203
+ )
204
+
205
+ assert result.exit_code == 0
206
+ assert fetch.call_args.kwargs["tenant"] == "acme"
207
+
208
+
209
+ def test_sync_uses_stored_context_when_tenant_is_omitted() -> None:
210
+ """Client export sync uses the stored organization context by default."""
211
+ runner = CliRunner()
212
+ with tempfile.TemporaryDirectory() as tmp:
213
+ target = Path(tmp) / "config.toml"
214
+ with (
215
+ patch(
216
+ "lightnow_cli.commands.integrations.require_access_token",
217
+ return_value="token",
218
+ ),
219
+ patch(
220
+ "lightnow_cli.commands.integrations.config_manager.effective_tenant",
221
+ return_value="tenant-uuid",
222
+ ),
223
+ patch(
224
+ "lightnow_cli.commands.integrations.fetch_export",
225
+ return_value='[mcp_servers.github]\ncommand = "docker"\n',
226
+ ) as fetch,
227
+ ):
228
+ result = runner.invoke(
229
+ app,
230
+ [
231
+ "sync",
232
+ "--client",
233
+ "codex",
234
+ "--config-path",
235
+ str(target),
236
+ ],
237
+ input="y\n",
238
+ )
239
+
240
+ assert result.exit_code == 0
241
+ assert fetch.call_args.kwargs["tenant"] == "tenant-uuid"
242
+
243
+
176
244
  def test_sync_json_writes_manifest_and_preserves_user_config() -> None:
177
245
  """JSON sync stores the LightNow-owned aliases in a sibling manifest."""
178
246
  runner = CliRunner()
@@ -509,6 +577,98 @@ def test_sync_runner_dry_run_does_not_fetch_plaintext_export() -> None:
509
577
  assert not target.exists()
510
578
 
511
579
 
580
+ def test_sync_runner_passes_tenant_to_profile_server_lookup() -> None:
581
+ """Runner sync builds organization-aware client commands from tenant profiles."""
582
+ runner = CliRunner()
583
+ with tempfile.TemporaryDirectory() as tmp:
584
+ target = Path(tmp) / "config.toml"
585
+ with (
586
+ patch(
587
+ "lightnow_cli.commands.integrations.require_access_token",
588
+ return_value="token",
589
+ ),
590
+ patch(
591
+ "lightnow_cli.commands.integrations.fetch_profile_servers",
592
+ return_value={
593
+ "servers": [
594
+ {
595
+ "alias": "sonarqube",
596
+ "server_name": "io.github.sonarsource/sonarqube-mcp-server",
597
+ "version": "1.2.3",
598
+ "status": "linked",
599
+ }
600
+ ]
601
+ },
602
+ ) as fetch_profile,
603
+ ):
604
+ result = runner.invoke(
605
+ app,
606
+ [
607
+ "sync",
608
+ "--client",
609
+ "codex",
610
+ "--tenant",
611
+ "acme",
612
+ "--runner",
613
+ "--config-path",
614
+ str(target),
615
+ "--dry-run",
616
+ ],
617
+ )
618
+
619
+ assert result.exit_code == 0
620
+ assert fetch_profile.call_args.kwargs["tenant"] == "acme"
621
+ assert "--tenant" in result.stdout
622
+ assert "acme" in result.stdout
623
+
624
+
625
+ def test_sync_runner_uses_stored_context_in_generated_wrappers() -> None:
626
+ """Runner sync embeds the stored organization context in client wrappers."""
627
+ runner = CliRunner()
628
+ with tempfile.TemporaryDirectory() as tmp:
629
+ target = Path(tmp) / "config.toml"
630
+ with (
631
+ patch(
632
+ "lightnow_cli.commands.integrations.require_access_token",
633
+ return_value="token",
634
+ ),
635
+ patch(
636
+ "lightnow_cli.commands.integrations.config_manager.effective_tenant",
637
+ return_value="tenant-uuid",
638
+ ),
639
+ patch(
640
+ "lightnow_cli.commands.integrations.fetch_profile_servers",
641
+ return_value={
642
+ "servers": [
643
+ {
644
+ "alias": "sonarqube",
645
+ "server_name": "io.github.sonarsource/sonarqube-mcp-server",
646
+ "version": "1.2.3",
647
+ "status": "linked",
648
+ }
649
+ ]
650
+ },
651
+ ) as fetch_profile,
652
+ ):
653
+ result = runner.invoke(
654
+ app,
655
+ [
656
+ "sync",
657
+ "--client",
658
+ "codex",
659
+ "--runner",
660
+ "--config-path",
661
+ str(target),
662
+ "--dry-run",
663
+ ],
664
+ )
665
+
666
+ assert result.exit_code == 0
667
+ assert fetch_profile.call_args.kwargs["tenant"] == "tenant-uuid"
668
+ assert "--tenant" in result.stdout
669
+ assert "tenant-uuid" in result.stdout
670
+
671
+
512
672
  def test_fetch_runtime_context_requests_local_runner_secret_context() -> None:
513
673
  """The runner asks for secret context with consumer=local-runner."""
514
674
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes