lcloud-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Unofficial community CLI for the Lambda Cloud API."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m lambda_cloud`."""
2
+
3
+ from .cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,17 @@
1
+ """API access layer: HTTP client and high-level service functions."""
2
+
3
+ from . import service
4
+ from .client import (
5
+ DEFAULT_BASE_URL,
6
+ DEFAULT_MAX_RETRIES,
7
+ DEFAULT_MIN_INTERVAL_SECONDS,
8
+ LambdaCloudClient,
9
+ )
10
+
11
+ __all__ = [
12
+ "DEFAULT_BASE_URL",
13
+ "DEFAULT_MAX_RETRIES",
14
+ "DEFAULT_MIN_INTERVAL_SECONDS",
15
+ "LambdaCloudClient",
16
+ "service",
17
+ ]
@@ -0,0 +1,150 @@
1
+ """HTTP client for the Lambda Cloud API.
2
+
3
+ Handles authentication, the documented rate limits (1 request/second in
4
+ general), retries on ``429 Too Many Requests``, and conversion of error
5
+ responses into :class:`~lambda_cloud.errors.APIError` exceptions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from .. import __version__
17
+ from ..core.errors import APIError
18
+
19
+ DEFAULT_BASE_URL = "https://cloud.lambda.ai/api/v1"
20
+ BASE_URL_ENV_VAR = "LAMBDA_CLOUD_API_URL"
21
+ MIN_INTERVAL_ENV_VAR = "LAMBDA_CLOUD_MIN_INTERVAL"
22
+
23
+ #: Default minimum delay between two API calls, per the documented rate limit.
24
+ DEFAULT_MIN_INTERVAL_SECONDS = 1.05
25
+
26
+ #: Maximum number of retries after a 429 response.
27
+ DEFAULT_MAX_RETRIES = 3
28
+
29
+
30
+ class LambdaCloudClient:
31
+ """Thin wrapper around :class:`httpx.Client` for the Lambda Cloud API."""
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: str,
36
+ *,
37
+ base_url: str | None = None,
38
+ timeout: float = 30.0,
39
+ min_interval: float | None = None,
40
+ max_retries: int = DEFAULT_MAX_RETRIES,
41
+ transport: httpx.BaseTransport | None = None,
42
+ ) -> None:
43
+ resolved_base_url = base_url or os.environ.get(BASE_URL_ENV_VAR) or DEFAULT_BASE_URL
44
+ if min_interval is None:
45
+ min_interval = float(os.environ.get(MIN_INTERVAL_ENV_VAR, DEFAULT_MIN_INTERVAL_SECONDS))
46
+ self._min_interval = min_interval
47
+ self._max_retries = max_retries
48
+ self._last_request_at = 0.0
49
+ self._client = httpx.Client(
50
+ base_url=resolved_base_url.rstrip("/"),
51
+ headers={
52
+ "Authorization": f"Bearer {api_key}",
53
+ "Accept": "application/json",
54
+ "User-Agent": f"lambda-cloud-cli/{__version__}",
55
+ },
56
+ timeout=timeout,
57
+ transport=transport,
58
+ )
59
+
60
+ def __enter__(self) -> LambdaCloudClient:
61
+ return self
62
+
63
+ def __exit__(self, *exc_info: object) -> None:
64
+ self.close()
65
+
66
+ def close(self) -> None:
67
+ self._client.close()
68
+
69
+ def _request(
70
+ self,
71
+ method: str,
72
+ path: str,
73
+ *,
74
+ params: dict[str, Any] | None = None,
75
+ json: Any | None = None,
76
+ ) -> dict[str, Any]:
77
+ """Perform one API call (with retries) and return the raw payload."""
78
+ for attempt in range(self._max_retries + 1):
79
+ self._throttle()
80
+ response = self._client.request(method, path, params=params, json=json)
81
+
82
+ if response.status_code == 429 and attempt < self._max_retries:
83
+ time.sleep(self._retry_delay(response))
84
+ continue
85
+
86
+ if not response.is_success:
87
+ raise APIError.from_response(response)
88
+
89
+ if response.status_code == 204 or not response.content:
90
+ return {}
91
+ payload = response.json()
92
+ return payload if isinstance(payload, dict) else {"data": payload}
93
+
94
+ # Unreachable in practice: the last attempt either returns or raises.
95
+ raise APIError(429, "global/rate-limited", "Too many requests after retries.")
96
+
97
+ def request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ *,
102
+ params: dict[str, Any] | None = None,
103
+ json: Any | None = None,
104
+ ) -> Any:
105
+ """Perform an API call and return the unwrapped ``data`` payload.
106
+
107
+ Raises:
108
+ APIError: if the API responds with a non-2xx status.
109
+ """
110
+ return self._request(method, path, params=params, json=json).get("data")
111
+
112
+ def get_page(
113
+ self, path: str, *, params: dict[str, Any] | None = None
114
+ ) -> tuple[Any, str | None]:
115
+ """GET an endpoint that paginates with a top-level ``page_token``."""
116
+ payload = self._request("GET", path, params=params)
117
+ return payload.get("data"), payload.get("page_token")
118
+
119
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
120
+ return self.request("GET", path, params=params)
121
+
122
+ def post(
123
+ self, path: str, *, json: Any | None = None, params: dict[str, Any] | None = None
124
+ ) -> Any:
125
+ return self.request("POST", path, json=json, params=params)
126
+
127
+ def patch(self, path: str, *, json: Any | None = None) -> Any:
128
+ return self.request("PATCH", path, json=json)
129
+
130
+ def put(self, path: str, *, json: Any | None = None) -> Any:
131
+ return self.request("PUT", path, json=json)
132
+
133
+ def delete(self, path: str) -> Any:
134
+ return self.request("DELETE", path)
135
+
136
+ def _throttle(self) -> None:
137
+ """Enforce the minimum interval between two API requests."""
138
+ if self._min_interval <= 0:
139
+ return
140
+ elapsed = time.monotonic() - self._last_request_at
141
+ if elapsed < self._min_interval:
142
+ time.sleep(self._min_interval - elapsed)
143
+ self._last_request_at = time.monotonic()
144
+
145
+ @staticmethod
146
+ def _retry_delay(response: httpx.Response) -> float:
147
+ try:
148
+ return min(float(response.headers.get("Retry-After", "1")), 30.0)
149
+ except ValueError:
150
+ return 1.0
@@ -0,0 +1,242 @@
1
+ """High-level API operations shared by commands and tests.
2
+
3
+ Every function takes a :class:`LambdaCloudClient` and returns validated
4
+ pydantic models, keeping commands free of HTTP details.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+
11
+ from ..core.errors import LambdaCloudError
12
+ from ..mngr.models import (
13
+ AuditEvent,
14
+ Filesystem,
15
+ FirewallRule,
16
+ FirewallRuleset,
17
+ GeneratedSSHKey,
18
+ GlobalFirewallRuleset,
19
+ Image,
20
+ Instance,
21
+ InstanceTypeOffer,
22
+ NetworkProtocol,
23
+ Region,
24
+ SSHKey,
25
+ TagEntry,
26
+ )
27
+ from .client import LambdaCloudClient
28
+
29
+
30
+ def validate_tags(tags: list[TagEntry]) -> list[dict[str, str]]:
31
+ """Serialize tags for the launch payload."""
32
+ return [tag.model_dump() for tag in tags]
33
+
34
+
35
+ def validate_firewall_rules(rules: list[FirewallRule]) -> None:
36
+ """Enforce the API's port_range constraints before submitting rules."""
37
+ for rule in rules:
38
+ if rule.protocol is NetworkProtocol.ICMP and rule.port_range is not None:
39
+ raise LambdaCloudError("Firewall rule with protocol 'icmp' must not define port_range.")
40
+ if rule.protocol is not NetworkProtocol.ICMP and rule.port_range is None:
41
+ raise LambdaCloudError(
42
+ f"Firewall rule with protocol '{rule.protocol.value}' requires "
43
+ "port_range [min, max]."
44
+ )
45
+
46
+
47
+ def serialize_firewall_rules(rules: list[FirewallRule]) -> list[dict]:
48
+ """Serialize rules, dropping an empty port_range for icmp."""
49
+ payload = []
50
+ for rule in rules:
51
+ item = rule.model_dump(mode="json")
52
+ if item.get("port_range") is None:
53
+ item.pop("port_range", None)
54
+ payload.append(item)
55
+ return payload
56
+
57
+
58
+ def filter_images(
59
+ images: list[Image],
60
+ region: str | None = None,
61
+ family: str | None = None,
62
+ ) -> list[Image]:
63
+ """Apply client-side filters to an image list."""
64
+ if region:
65
+ images = [img for img in images if img.region and img.region.name == region]
66
+ if family:
67
+ images = [img for img in images if img.family == family]
68
+ return images
69
+
70
+
71
+ def sort_images_by_updated(images: list[Image]) -> list[Image]:
72
+ """Sort images by last update, oldest first."""
73
+ return sorted(images, key=lambda img: img.updated_time or img.created_time or datetime.min)
74
+
75
+
76
+ def parse_instance_types(data: dict[str, dict]) -> list[InstanceTypeOffer]:
77
+ """Parse the dict response of ``GET /instance-types``."""
78
+ offers = [InstanceTypeOffer.model_validate(value) for value in data.values()]
79
+ return sorted(offers, key=lambda offer: offer.instance_type.price_cents_per_hour)
80
+
81
+
82
+ def list_instance_types(client: LambdaCloudClient) -> list[InstanceTypeOffer]:
83
+ """Fetch and sort all instance type offers by hourly price."""
84
+ return parse_instance_types(client.get("/instance-types"))
85
+
86
+
87
+ # ---------------------------------------------------------------- thin wrappers
88
+
89
+
90
+ def list_instances(client: LambdaCloudClient, cluster_id: str | None = None) -> list[Instance]:
91
+ params = {"cluster_id": cluster_id} if cluster_id else None
92
+ data = client.get("/instances", params=params)
93
+ return [Instance.model_validate(item) for item in data]
94
+
95
+
96
+ def get_instance(client: LambdaCloudClient, instance_id: str) -> Instance:
97
+ return Instance.model_validate(client.get(f"/instances/{instance_id}"))
98
+
99
+
100
+ def launch_instances(client: LambdaCloudClient, payload: dict) -> list[str]:
101
+ data = client.post("/instance-operations/launch", json=payload)
102
+ return data["instance_ids"]
103
+
104
+
105
+ def restart_instances(client: LambdaCloudClient, instance_ids: list[str]) -> list[Instance]:
106
+ data = client.post("/instance-operations/restart", json={"instance_ids": instance_ids})
107
+ return [Instance.model_validate(item) for item in data["restarted_instances"]]
108
+
109
+
110
+ def terminate_instances(client: LambdaCloudClient, instance_ids: list[str]) -> list[Instance]:
111
+ data = client.post("/instance-operations/terminate", json={"instance_ids": instance_ids})
112
+ return [Instance.model_validate(item) for item in data["terminated_instances"]]
113
+
114
+
115
+ def rename_instance(client: LambdaCloudClient, instance_id: str, name: str) -> None:
116
+ client.post(f"/instances/{instance_id}", json={"name": name})
117
+
118
+
119
+ def list_ssh_keys(client: LambdaCloudClient) -> list[SSHKey]:
120
+ data = client.get("/ssh-keys")
121
+ return [SSHKey.model_validate(item) for item in data]
122
+
123
+
124
+ def add_ssh_key(
125
+ client: LambdaCloudClient, name: str, public_key: str | None = None
126
+ ) -> SSHKey | GeneratedSSHKey:
127
+ payload: dict[str, str] = {"name": name}
128
+ if public_key:
129
+ payload["public_key"] = public_key
130
+ data = client.post("/ssh-keys", json=payload)
131
+ if "private_key" in data:
132
+ return GeneratedSSHKey.model_validate(data)
133
+ return SSHKey.model_validate(data)
134
+
135
+
136
+ def delete_ssh_key(client: LambdaCloudClient, key_id: str) -> None:
137
+ client.delete(f"/ssh-keys/{key_id}")
138
+
139
+
140
+ def list_filesystems(client: LambdaCloudClient) -> list[Filesystem]:
141
+ data = client.get("/file-systems")
142
+ return [Filesystem.model_validate(item) for item in data]
143
+
144
+
145
+ def create_filesystem(client: LambdaCloudClient, name: str, region: str) -> Filesystem:
146
+ data = client.post("/filesystems", json={"name": name, "region": region})
147
+ return Filesystem.model_validate(data)
148
+
149
+
150
+ def delete_filesystem(client: LambdaCloudClient, filesystem_id: str) -> None:
151
+ client.delete(f"/filesystems/{filesystem_id}")
152
+
153
+
154
+ def list_images(client: LambdaCloudClient) -> list[Image]:
155
+ data = client.get("/images")
156
+ return [Image.model_validate(item) for item in data]
157
+
158
+
159
+ def list_regions(client: LambdaCloudClient) -> list[Region]:
160
+ data = client.get("/regions")
161
+ return [Region.model_validate(item) for item in data]
162
+
163
+
164
+ def list_firewall_rulesets(client: LambdaCloudClient) -> list[FirewallRuleset]:
165
+ data = client.get("/firewall-rulesets")
166
+ return [FirewallRuleset.model_validate(item) for item in data]
167
+
168
+
169
+ def get_firewall_ruleset(client: LambdaCloudClient, ruleset_id: str) -> FirewallRuleset:
170
+ return FirewallRuleset.model_validate(client.get(f"/firewall-rulesets/{ruleset_id}"))
171
+
172
+
173
+ def create_firewall_ruleset(
174
+ client: LambdaCloudClient, name: str, region: str, rules: list[FirewallRule]
175
+ ) -> FirewallRuleset:
176
+ validate_firewall_rules(rules)
177
+ data = client.post(
178
+ "/firewall-rulesets",
179
+ json={"name": name, "region": region, "rules": serialize_firewall_rules(rules)},
180
+ )
181
+ return FirewallRuleset.model_validate(data)
182
+
183
+
184
+ def update_firewall_ruleset(
185
+ client: LambdaCloudClient,
186
+ ruleset_id: str,
187
+ name: str | None = None,
188
+ rules: list[FirewallRule] | None = None,
189
+ ) -> FirewallRuleset:
190
+ payload: dict = {}
191
+ if name is not None:
192
+ payload["name"] = name
193
+ if rules is not None:
194
+ validate_firewall_rules(rules)
195
+ payload["rules"] = serialize_firewall_rules(rules)
196
+ data = client.patch(f"/firewall-rulesets/{ruleset_id}", json=payload)
197
+ return FirewallRuleset.model_validate(data)
198
+
199
+
200
+ def delete_firewall_ruleset(client: LambdaCloudClient, ruleset_id: str) -> None:
201
+ client.delete(f"/firewall-rulesets/{ruleset_id}")
202
+
203
+
204
+ def get_global_firewall_ruleset(client: LambdaCloudClient) -> GlobalFirewallRuleset:
205
+ return GlobalFirewallRuleset.model_validate(client.get("/firewall-rulesets/global"))
206
+
207
+
208
+ def update_global_firewall_ruleset(
209
+ client: LambdaCloudClient, rules: list[FirewallRule]
210
+ ) -> GlobalFirewallRuleset:
211
+ validate_firewall_rules(rules)
212
+ data = client.patch(
213
+ "/firewall-rulesets/global", json={"rules": serialize_firewall_rules(rules)}
214
+ )
215
+ return GlobalFirewallRuleset.model_validate(data)
216
+
217
+
218
+ def list_audit_events(
219
+ client: LambdaCloudClient,
220
+ start: str | None = None,
221
+ end: str | None = None,
222
+ resource_type: str | None = None,
223
+ all_pages: bool = False,
224
+ ) -> list[AuditEvent]:
225
+ params = {
226
+ key: value
227
+ for key, value in {
228
+ "start": start,
229
+ "end": end,
230
+ "resource_type": resource_type,
231
+ }.items()
232
+ if value is not None
233
+ }
234
+ events: list[AuditEvent] = []
235
+ data, page_token = client.get_page("/audit-events", params=params)
236
+ events.extend(AuditEvent.model_validate(item) for item in data)
237
+ while all_pages and page_token:
238
+ data, page_token = client.get_page(
239
+ "/audit-events", params={**params, "page_token": page_token}
240
+ )
241
+ events.extend(AuditEvent.model_validate(item) for item in data)
242
+ return events
@@ -0,0 +1 @@
1
+ """Command-line interface layer."""
@@ -0,0 +1,111 @@
1
+ """Root Typer application assembling every command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import typer
7
+
8
+ from .. import __version__
9
+ from ..core.errors import LambdaCloudError
10
+ from .commands import (
11
+ audit,
12
+ auth,
13
+ completion,
14
+ config_cmd,
15
+ filesystems,
16
+ firewall,
17
+ images,
18
+ instance_types,
19
+ instances,
20
+ regions,
21
+ ssh_keys,
22
+ )
23
+ from .state import State
24
+ from .ui.console import OutputFormat, console, err_console, exit_with_error
25
+
26
+
27
+ def _version_callback(value: bool) -> None:
28
+ if value:
29
+ console.print(f"lambda-cloud {__version__}")
30
+ raise typer.Exit()
31
+
32
+
33
+ app = typer.Typer(
34
+ name="lambda-cloud",
35
+ help=(
36
+ "Unofficial community CLI for the Lambda Cloud API "
37
+ "(https://cloud.lambda.ai)."
38
+ "\n\nManage on-demand GPU instances, SSH keys, filesystems, images, "
39
+ "regions and firewall rulesets."
40
+ ),
41
+ no_args_is_help=True,
42
+ add_completion=False,
43
+ rich_markup_mode="rich",
44
+ )
45
+
46
+
47
+ @app.callback()
48
+ def callback(
49
+ ctx: typer.Context,
50
+ api_key: str | None = typer.Option(
51
+ None,
52
+ "--api-key",
53
+ help="API key. Overrides LAMBDA_API_KEY and the stored config.",
54
+ show_envvar=False,
55
+ ),
56
+ output_format: OutputFormat = typer.Option(
57
+ OutputFormat.TABLE,
58
+ "--output",
59
+ "-o",
60
+ help="Output format.",
61
+ ),
62
+ verbose: bool = typer.Option(False, "--verbose", help="Verbose output."),
63
+ show_completion: str | None = typer.Option(
64
+ None,
65
+ "--show-completion",
66
+ help="Print shell completion instructions (bash, zsh, fish, powershell).",
67
+ is_eager=True,
68
+ ),
69
+ version: bool = typer.Option(
70
+ False,
71
+ "--version",
72
+ callback=_version_callback,
73
+ is_eager=True,
74
+ help="Show version and exit.",
75
+ ),
76
+ ) -> None:
77
+ """Store global options on the context; commands pick them up lazily."""
78
+ completion.handle_completion(show_completion)
79
+ state = State(api_key=api_key, output=output_format, verbose=verbose)
80
+ ctx.obj = state
81
+ ctx.call_on_close(state.close)
82
+
83
+
84
+ app.add_typer(instances.app, name="instances")
85
+ app.add_typer(instance_types.app, name="types")
86
+ app.add_typer(ssh_keys.app, name="ssh-keys")
87
+ app.add_typer(filesystems.app, name="filesystems")
88
+ app.add_typer(images.app, name="images")
89
+ app.add_typer(regions.app, name="regions")
90
+ app.add_typer(firewall.app, name="firewall")
91
+ app.add_typer(audit.app, name="audit")
92
+ app.add_typer(config_cmd.app, name="config")
93
+
94
+ app.command("login", help="Store and validate your Lambda Cloud API key.")(auth.login)
95
+ app.command("logout", help="Remove the stored API key.")(auth.logout)
96
+ app.command("whoami", help="Show which credentials are in use and validate them.")(auth.whoami)
97
+
98
+
99
+ def main() -> None:
100
+ """Console entry point: convert errors into clean exits."""
101
+ try:
102
+ app()
103
+ except LambdaCloudError as exc:
104
+ exit_with_error(exc)
105
+ except httpx.HTTPError as exc:
106
+ err_console.print(f"Network error: {exc}", style="bold red")
107
+ raise SystemExit(1) from None
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
@@ -0,0 +1 @@
1
+ """Typer command groups for every Lambda Cloud resource."""
@@ -0,0 +1,37 @@
1
+ """Query account audit events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from ...api import service
8
+ from ..state import CommandBase
9
+ from ..ui.tables import audit_events_table
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Query account audit events.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_audit_events(
16
+ ctx: typer.Context,
17
+ start: str | None = typer.Option(
18
+ None, "--start", help="ISO 8601 timestamp, inclusive (e.g. 2025-09-01T00:00:00Z)."
19
+ ),
20
+ end: str | None = typer.Option(None, "--end", help="ISO 8601 timestamp, inclusive."),
21
+ resource_type: str | None = typer.Option(
22
+ None, "--resource-type", help="Filter by resource type, e.g. cloud.api_key."
23
+ ),
24
+ all_pages: bool = typer.Option(
25
+ False, "--all", help="Follow pagination until all events are fetched."
26
+ ),
27
+ ) -> None:
28
+ """List audit events (newest time range by default)."""
29
+ cmd = CommandBase(ctx)
30
+ events = service.list_audit_events(
31
+ cmd.client,
32
+ start=start,
33
+ end=end,
34
+ resource_type=resource_type,
35
+ all_pages=all_pages,
36
+ )
37
+ cmd.emit(events, audit_events_table(events))
@@ -0,0 +1,69 @@
1
+ """Manage the local Lambda Cloud API key."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import typer
8
+
9
+ from ...api import LambdaCloudClient
10
+ from ...core.config import (
11
+ API_KEY_ENV_VAR,
12
+ delete_stored_config,
13
+ describe_api_key_source,
14
+ mask_api_key,
15
+ save_api_key,
16
+ )
17
+ from ...core.errors import ConfigError, LambdaCloudError
18
+ from ..state import CommandBase
19
+ from ..ui.console import info, success, warn
20
+
21
+
22
+ def login(
23
+ ctx: typer.Context,
24
+ api_key: str | None = typer.Option(
25
+ None,
26
+ "--api-key",
27
+ help="Skip the prompt by passing the key directly.",
28
+ ),
29
+ ) -> None:
30
+ """Prompt for a key, validate it against the API, and save it."""
31
+ CommandBase(ctx, needs_client=False)
32
+ if api_key is None:
33
+ api_key = typer.prompt("Lambda Cloud API key (input hidden)", hide_input=True)
34
+ api_key = api_key.strip()
35
+ if not api_key:
36
+ raise LambdaCloudError("Empty API key.")
37
+
38
+ with LambdaCloudClient(api_key) as client:
39
+ client.get("/instances")
40
+
41
+ path = save_api_key(api_key)
42
+ success(f"Key is valid. Configuration written to {path}")
43
+
44
+
45
+ def logout(ctx: typer.Context) -> None:
46
+ """Delete the stored config file, if any."""
47
+ CommandBase(ctx, needs_client=False)
48
+ if delete_stored_config():
49
+ success("Stored configuration removed.")
50
+ else:
51
+ info("No stored configuration found.")
52
+ if os.environ.get(API_KEY_ENV_VAR):
53
+ warn(f"{API_KEY_ENV_VAR} is still set in your environment; unset it to fully log out.")
54
+
55
+
56
+ def whoami(ctx: typer.Context) -> None:
57
+ """Show the credentials currently in use and verify they are valid."""
58
+ CommandBase(ctx, needs_client=False)
59
+ source = describe_api_key_source(ctx.obj.api_key)
60
+ if source is None:
61
+ raise ConfigError(
62
+ f"No API key configured. Run `lambda-cloud login` or set {API_KEY_ENV_VAR}."
63
+ )
64
+ label, key = source
65
+ info(f"Source: {label}")
66
+ info(f"Key: {mask_api_key(key)}")
67
+ with LambdaCloudClient(key) as client:
68
+ client.get("/instances")
69
+ success("Key is valid.")
@@ -0,0 +1,37 @@
1
+ """Shell completion support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from ...core.errors import LambdaCloudError
8
+ from ..ui.console import console
9
+
10
+ SUPPORTED_SHELLS = ("bash", "zsh", "fish", "powershell")
11
+
12
+ _SNIPPETS = {
13
+ "bash": 'eval "$(_LAMBDA_CLOUD_COMPLETE=bash_source lambda-cloud)"',
14
+ "zsh": 'eval "$(_LAMBDA_CLOUD_COMPLETE=zsh_source lambda-cloud)"',
15
+ "fish": "_LAMBDA_CLOUD_COMPLETE=fish_source lambda-cloud | source",
16
+ "powershell": (
17
+ "$env:_LAMBDA_CLOUD_COMPLETE='powershell_source'; "
18
+ "lambda-cloud | Out-String | Invoke-Expression; "
19
+ "Remove-Item Env:_LAMBDA_CLOUD_COMPLETE"
20
+ ),
21
+ }
22
+
23
+
24
+ def handle_completion(shell: str | None) -> None:
25
+ """Print activation instructions for shell completion, then exit."""
26
+ if shell is None:
27
+ return
28
+ normalized = shell.lower()
29
+ if normalized not in _SNIPPETS:
30
+ raise LambdaCloudError(
31
+ f"Unsupported shell {shell!r}. Choose from: {', '.join(SUPPORTED_SHELLS)}."
32
+ )
33
+ console.print(
34
+ f"Run the following to enable completion for [bold]{normalized}[/bold]:\n\n"
35
+ f" {_SNIPPETS[normalized]}\n"
36
+ )
37
+ raise typer.Exit()