authsome 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.
authsome/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """
2
+ Authsome — A portable local authentication library for AI agents and developer tools.
3
+
4
+ Provides credential management for third-party services with support for:
5
+ - OAuth2 (PKCE, Device Code, DCR + PKCE)
6
+ - API key management (prompt, env import)
7
+ - Encrypted local storage (OS keyring or local file)
8
+ - Cross-language compatible credential format
9
+
10
+ Usage:
11
+ from authsome import AuthClient
12
+
13
+ client = AuthClient()
14
+ client.init()
15
+
16
+ # Login to a provider
17
+ client.login("openai")
18
+
19
+ # Get auth headers for API calls
20
+ headers = client.get_auth_headers("openai")
21
+
22
+ # Export credentials
23
+ env_vars = client.export("openai", format=ExportFormat.SHELL)
24
+ """
25
+
26
+ from authsome.client import AuthClient
27
+ from authsome.errors import (
28
+ AuthenticationFailedError,
29
+ AuthsomeError,
30
+ ConnectionNotFoundError,
31
+ CredentialMissingError,
32
+ DiscoveryError,
33
+ EncryptionUnavailableError,
34
+ InvalidProviderSchemaError,
35
+ ProfileNotFoundError,
36
+ ProviderNotFoundError,
37
+ RefreshFailedError,
38
+ StoreUnavailableError,
39
+ TokenExpiredError,
40
+ UnsupportedAuthTypeError,
41
+ UnsupportedFlowError,
42
+ )
43
+ from authsome.models.enums import AuthType, ConnectionStatus, ExportFormat, FlowType
44
+ from authsome.models.provider import ProviderDefinition
45
+ from authsome.models.connection import ConnectionRecord, EncryptedField
46
+ from authsome.crypto.base import CryptoBackend
47
+ from authsome.crypto.keyring_crypto import KeyringCryptoBackend
48
+ from authsome.crypto.local_file_crypto import LocalFileCryptoBackend
49
+
50
+ __version__ = "0.1.0"
51
+
52
+ __all__ = [
53
+ # Core
54
+ "AuthClient",
55
+ # Models
56
+ "AuthType",
57
+ "ConnectionStatus",
58
+ "ExportFormat",
59
+ "FlowType",
60
+ "ProviderDefinition",
61
+ "ConnectionRecord",
62
+ "EncryptedField",
63
+ # Crypto backends
64
+ "CryptoBackend",
65
+ "KeyringCryptoBackend",
66
+ "LocalFileCryptoBackend",
67
+ # Errors
68
+ "AuthsomeError",
69
+ "AuthenticationFailedError",
70
+ "ConnectionNotFoundError",
71
+ "CredentialMissingError",
72
+ "DiscoveryError",
73
+ "EncryptionUnavailableError",
74
+ "InvalidProviderSchemaError",
75
+ "ProfileNotFoundError",
76
+ "ProviderNotFoundError",
77
+ "RefreshFailedError",
78
+ "StoreUnavailableError",
79
+ "TokenExpiredError",
80
+ "UnsupportedAuthTypeError",
81
+ "UnsupportedFlowError",
82
+ ]
@@ -0,0 +1 @@
1
+ """Bundled provider definitions package for authsome."""
@@ -0,0 +1,30 @@
1
+ {
2
+ "schema_version": 1,
3
+ "name": "github",
4
+ "display_name": "GitHub",
5
+ "auth_type": "oauth2",
6
+ "flow": "pkce",
7
+ "oauth": {
8
+ "authorization_url": "https://github.com/login/oauth/authorize",
9
+ "token_url": "https://github.com/login/oauth/access_token",
10
+ "revocation_url": null,
11
+ "device_authorization_url": "https://github.com/login/device/code",
12
+ "scopes": [
13
+ "repo",
14
+ "read:user"
15
+ ],
16
+ "pkce": true,
17
+ "supports_device_flow": true,
18
+ "supports_dcr": false
19
+ },
20
+ "client": {
21
+ "client_id": "env:GITHUB_CLIENT_ID",
22
+ "client_secret": "env:GITHUB_CLIENT_SECRET"
23
+ },
24
+ "export": {
25
+ "env": {
26
+ "access_token": "GITHUB_ACCESS_TOKEN",
27
+ "refresh_token": "GITHUB_REFRESH_TOKEN"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "schema_version": 1,
3
+ "name": "google",
4
+ "display_name": "Google",
5
+ "auth_type": "oauth2",
6
+ "flow": "pkce",
7
+ "oauth": {
8
+ "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth",
9
+ "token_url": "https://oauth2.googleapis.com/token",
10
+ "revocation_url": "https://oauth2.googleapis.com/revoke",
11
+ "device_authorization_url": "https://oauth2.googleapis.com/device/code",
12
+ "scopes": [
13
+ "openid",
14
+ "profile"
15
+ ],
16
+ "pkce": true,
17
+ "supports_device_flow": true,
18
+ "supports_dcr": false
19
+ },
20
+ "client": {
21
+ "client_id": "env:GOOGLE_CLIENT_ID",
22
+ "client_secret": "env:GOOGLE_CLIENT_SECRET"
23
+ },
24
+ "export": {
25
+ "env": {
26
+ "access_token": "GOOGLE_ACCESS_TOKEN",
27
+ "refresh_token": "GOOGLE_REFRESH_TOKEN"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "schema_version": 1,
3
+ "name": "linear",
4
+ "display_name": "Linear",
5
+ "auth_type": "oauth2",
6
+ "flow": "dcr_pkce",
7
+ "oauth": {
8
+ "authorization_url": "https://mcp.linear.app/authorize",
9
+ "token_url": "https://mcp.linear.app/token",
10
+ "revocation_url": "https://mcp.linear.app/token",
11
+ "device_authorization_url": null,
12
+ "scopes": [
13
+ "read",
14
+ "write"
15
+ ],
16
+ "pkce": true,
17
+ "supports_device_flow": false,
18
+ "supports_dcr": true
19
+ },
20
+ "client": {
21
+ "client_id": "env:LINEAR_CLIENT_ID",
22
+ "client_secret": "env:LINEAR_CLIENT_SECRET"
23
+ },
24
+ "export": {
25
+ "env": {
26
+ "access_token": "LINEAR_ACCESS_TOKEN",
27
+ "refresh_token": "LINEAR_REFRESH_TOKEN"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "schema_version": 1,
3
+ "name": "okta",
4
+ "display_name": "Okta",
5
+ "auth_type": "oauth2",
6
+ "flow": "pkce",
7
+ "oauth": {
8
+ "authorization_url": "https://integrator-7955628.okta.com/oauth2/default/v1/authorize",
9
+ "token_url": "https://integrator-7955628.okta.com/oauth2/default/v1/token",
10
+ "scopes": [
11
+ "openid",
12
+ "profile"
13
+ ],
14
+ "pkce": true
15
+ },
16
+ "client": {
17
+ "client_id": "env:OKTA_CLIENT_ID",
18
+ "client_secret": "env:OKTA_CLIENT_SECRET"
19
+ },
20
+ "export": {
21
+ "env": {
22
+ "access_token": "OKTA_ACCESS_TOKEN",
23
+ "refresh_token": "OKTA_REFRESH_TOKEN"
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "schema_version": 1,
3
+ "name": "openai",
4
+ "display_name": "OpenAI",
5
+ "auth_type": "api_key",
6
+ "flow": "api_key_prompt",
7
+ "api_key": {
8
+ "input_mode": "prompt",
9
+ "header_name": "Authorization",
10
+ "header_prefix": "Bearer",
11
+ "env_var": "OPENAI_API_KEY"
12
+ },
13
+ "export": {
14
+ "env": {
15
+ "api_key": "OPENAI_API_KEY"
16
+ }
17
+ }
18
+ }
authsome/cli.py ADDED
@@ -0,0 +1,453 @@
1
+ """Command-line interface for authsome.
2
+
3
+ Implements all commands defined in spec §18 using Click.
4
+ """
5
+
6
+ import functools
7
+ import json as json_lib
8
+ import logging
9
+ import sys
10
+ from typing import Any, Optional
11
+
12
+ import click
13
+
14
+ from authsome.client import AuthClient
15
+ from authsome.errors import AuthsomeError
16
+ from authsome.models.enums import ExportFormat, FlowType
17
+
18
+
19
+ class ContextObj:
20
+ """Context object passed to all commands."""
21
+
22
+ def __init__(self, profile: Optional[str], json_output: bool, quiet: bool, no_color: bool):
23
+ self.profile = profile
24
+ self.json_output = json_output
25
+ self.quiet = quiet
26
+ self.no_color = no_color
27
+ self.client: Optional[AuthClient] = None
28
+
29
+ def initialize_client(self) -> AuthClient:
30
+ if self.client is None:
31
+ self.client = AuthClient(profile=self.profile)
32
+ return self.client
33
+
34
+ def print_json(self, data: Any) -> None:
35
+ click.echo(json_lib.dumps(data, indent=2))
36
+
37
+ def echo(self, message: str, err: bool = False, color: Optional[str] = None, nl: bool = True) -> None:
38
+ if self.quiet:
39
+ return
40
+ if self.no_color:
41
+ color = None
42
+ click.secho(message, err=err, fg=color, nl=nl)
43
+
44
+
45
+ pass_ctx = click.make_pass_decorator(ContextObj)
46
+
47
+
48
+ def format_error_code(exc: Exception) -> int:
49
+ """Map exceptions to standard exit codes per spec §18.3."""
50
+ if not isinstance(exc, AuthsomeError):
51
+ return 1
52
+
53
+ exc_name = exc.__class__.__name__
54
+ if exc_name == "ProviderNotFoundError":
55
+ return 3
56
+ if exc_name == "AuthenticationFailedError":
57
+ return 4
58
+ if exc_name == "CredentialMissingError":
59
+ return 5
60
+ if exc_name == "RefreshFailedError":
61
+ return 6
62
+ if exc_name == "StoreUnavailableError":
63
+ return 7
64
+ return 1
65
+
66
+
67
+ def handle_errors(func):
68
+ """Decorator to catch exceptions and exit with proper codes."""
69
+ @functools.wraps(func)
70
+ def wrapper(ctx_obj: ContextObj, *args, **kwargs):
71
+ try:
72
+ return func(ctx_obj, *args, **kwargs)
73
+ except Exception as exc:
74
+ if ctx_obj.json_output:
75
+ ctx_obj.print_json({
76
+ "error": exc.__class__.__name__,
77
+ "message": str(exc),
78
+ })
79
+ else:
80
+ ctx_obj.echo(f"Error: {exc}", err=True, color="red")
81
+ sys.exit(format_error_code(exc))
82
+ return wrapper
83
+
84
+
85
+ @click.group()
86
+ @click.option("--profile", help="Override the active profile.")
87
+ @click.option("--json", "json_output", is_flag=True, help="Output in machine-readable JSON format.")
88
+ @click.option("--quiet", is_flag=True, help="Suppress non-essential output.")
89
+ @click.option("--no-color", is_flag=True, help="Disable ANSI colors.")
90
+ @click.pass_context
91
+ def cli(ctx: click.Context, profile: Optional[str], json_output: bool, quiet: bool, no_color: bool) -> None:
92
+ """Authsome: Portable local authentication library for AI agents and tools."""
93
+ logging.getLogger("authsome").setLevel(logging.WARNING if quiet else logging.INFO)
94
+ ctx.obj = ContextObj(profile, json_output, quiet, no_color)
95
+
96
+
97
+ @cli.command()
98
+ @pass_ctx
99
+ @handle_errors
100
+ def init(ctx_obj: ContextObj) -> None:
101
+ """Initialize the authsome root directory and default profile."""
102
+ client = ctx_obj.initialize_client()
103
+ client.init()
104
+
105
+ if ctx_obj.json_output:
106
+ ctx_obj.print_json({"status": "initialized", "home": str(client.home)})
107
+ else:
108
+ ctx_obj.echo(f"Initialized authsome at {client.home}", color="green")
109
+
110
+
111
+ @cli.command(name="list")
112
+ @pass_ctx
113
+ @handle_errors
114
+ def list_cmd(ctx_obj: ContextObj) -> None:
115
+ """List providers and connection states."""
116
+ client = ctx_obj.initialize_client()
117
+ raw_list = client.list_connections()
118
+
119
+ providers_flat = []
120
+ for provider_group in raw_list:
121
+ pname = provider_group["name"]
122
+ for conn in provider_group["connections"]:
123
+ item = {
124
+ "name": pname,
125
+ "connection": conn["connection_name"],
126
+ "auth_type": conn.get("auth_type"),
127
+ "status": conn.get("status"),
128
+ }
129
+ if conn.get("scopes"):
130
+ item["scopes"] = conn["scopes"]
131
+ if conn.get("expires_at"):
132
+ item["expires_at"] = conn["expires_at"]
133
+ providers_flat.append(item)
134
+
135
+ if ctx_obj.json_output:
136
+ ctx_obj.print_json({
137
+ "profile": client.active_profile,
138
+ "providers": providers_flat,
139
+ })
140
+ else:
141
+ ctx_obj.echo(f"Profile: {client.active_profile}")
142
+ if not providers_flat:
143
+ ctx_obj.echo("No connections found.", color="yellow")
144
+ return
145
+
146
+ for p in providers_flat:
147
+ name = p["name"]
148
+ conn_name = p["connection"]
149
+ status = p["status"]
150
+ color = "green" if status == "connected" else "red"
151
+ ctx_obj.echo(f" {name} ({conn_name}) - ", nl=False)
152
+ ctx_obj.echo(status, color=color)
153
+
154
+
155
+ @cli.command()
156
+ @click.argument("provider")
157
+ @click.option("--connection", default="default", help="Connection name.")
158
+ @click.option("--flow", help="Authentication flow override.")
159
+ @click.option("--scopes", help="Comma-separated scopes to request.")
160
+ @pass_ctx
161
+ @handle_errors
162
+ def login(ctx_obj: ContextObj, provider: str, connection: str, flow: Optional[str], scopes: Optional[str]) -> None:
163
+ """Authenticate with a provider using its configured flow."""
164
+ client = ctx_obj.initialize_client()
165
+ flow_enum = FlowType(flow) if flow else None
166
+ scope_list = [s.strip() for s in scopes.split(",")] if scopes else None
167
+
168
+ if not ctx_obj.json_output:
169
+ ctx_obj.echo(f"Starting login for {provider}...", color="cyan")
170
+
171
+ record = client.login(
172
+ provider=provider,
173
+ connection_name=connection,
174
+ scopes=scope_list,
175
+ flow_override=flow_enum,
176
+ )
177
+
178
+ if ctx_obj.json_output:
179
+ ctx_obj.print_json({
180
+ "status": "success",
181
+ "provider": provider,
182
+ "connection": connection,
183
+ "record_status": record.status.value,
184
+ })
185
+ else:
186
+ ctx_obj.echo(f"Successfully logged in to {provider} ({connection}).", color="green")
187
+
188
+
189
+ @cli.command()
190
+ @click.argument("provider")
191
+ @click.option("--connection", default="default", help="Connection name.")
192
+ @pass_ctx
193
+ @handle_errors
194
+ def revoke(ctx_obj: ContextObj, provider: str, connection: str) -> None:
195
+ """Revoke credentials remotely (if supported) and remove locally."""
196
+ client = ctx_obj.initialize_client()
197
+ client.revoke(provider, connection)
198
+
199
+ if ctx_obj.json_output:
200
+ ctx_obj.print_json({"status": "revoked", "provider": provider, "connection": connection})
201
+ else:
202
+ ctx_obj.echo(f"Revoked credentials for {provider} ({connection}).", color="green")
203
+
204
+
205
+ @cli.command()
206
+ @click.argument("provider")
207
+ @click.option("--connection", default="default", help="Connection name.")
208
+ @pass_ctx
209
+ @handle_errors
210
+ def remove(ctx_obj: ContextObj, provider: str, connection: str) -> None:
211
+ """Remove local credential state without remote revocation."""
212
+ client = ctx_obj.initialize_client()
213
+ client.remove(provider, connection)
214
+
215
+ if ctx_obj.json_output:
216
+ ctx_obj.print_json({"status": "removed", "provider": provider, "connection": connection})
217
+ else:
218
+ ctx_obj.echo(f"Removed local credentials for {provider} ({connection}).", color="green")
219
+
220
+
221
+ @cli.command()
222
+ @click.argument("provider")
223
+ @click.option("--connection", default="default", help="Connection name.")
224
+ @click.option("--field", help="Return only a specific field.")
225
+ @click.option("--show-secret", is_flag=True, help="Reveal encrypted secrets.")
226
+ @pass_ctx
227
+ @handle_errors
228
+ def get(ctx_obj: ContextObj, provider: str, connection: str, field: Optional[str], show_secret: bool) -> None:
229
+ """Return provider connection metadata by default."""
230
+ client = ctx_obj.initialize_client()
231
+ record = client.get_connection(provider, connection)
232
+
233
+ data = record.model_dump()
234
+
235
+ # Redact secrets unless requested
236
+ if not show_secret:
237
+ for secret_field in ["access_token", "refresh_token", "api_key", "client_secret"]:
238
+ if data.get(secret_field):
239
+ data[secret_field] = "***REDACTED***"
240
+ else:
241
+ for secret_field in ["access_token", "refresh_token", "api_key", "client_secret"]:
242
+ val = getattr(record, secret_field, None)
243
+ if val:
244
+ data[secret_field] = client.crypto.decrypt(val)
245
+
246
+ if field:
247
+ if field in data:
248
+ if ctx_obj.json_output:
249
+ ctx_obj.print_json({field: data[field]})
250
+ else:
251
+ ctx_obj.echo(str(data[field]))
252
+ else:
253
+ ctx_obj.echo(f"Field '{field}' not found.", err=True, color="red")
254
+ sys.exit(1)
255
+ return
256
+
257
+ if ctx_obj.json_output:
258
+ ctx_obj.print_json(data)
259
+ else:
260
+ for k, v in data.items():
261
+ ctx_obj.echo(f"{k}: {v}")
262
+
263
+
264
+ @cli.command()
265
+ @click.argument("provider")
266
+ @pass_ctx
267
+ @handle_errors
268
+ def inspect(ctx_obj: ContextObj, provider: str) -> None:
269
+ """Return provider definition and local connection summary."""
270
+ client = ctx_obj.initialize_client()
271
+ definition = client.get_provider(provider)
272
+
273
+ data = definition.model_dump()
274
+ if ctx_obj.json_output:
275
+ ctx_obj.print_json(data)
276
+ else:
277
+ ctx_obj.echo(json_lib.dumps(data, indent=2))
278
+
279
+
280
+ @cli.command()
281
+ @click.argument("provider")
282
+ @click.option("--connection", default="default", help="Connection name.")
283
+ @click.option("--format", "export_format", type=click.Choice(["env", "shell", "json"]), default="env")
284
+ @pass_ctx
285
+ @handle_errors
286
+ def export(ctx_obj: ContextObj, provider: str, connection: str, export_format: str) -> None:
287
+ """Export credential material in selected format."""
288
+ client = ctx_obj.initialize_client()
289
+ fmt = ExportFormat(export_format)
290
+ output = client.export(provider, connection, format=fmt)
291
+
292
+ # Do not apply color or structured wrapping here, just output exactly what is requested
293
+ if output:
294
+ click.echo(output)
295
+
296
+
297
+ @cli.command(context_settings=dict(ignore_unknown_options=True))
298
+ @click.option("--provider", "-p", multiple=True, help="Provider(s) to inject credentials for.")
299
+ @click.argument("command", nargs=-1, required=True)
300
+ @pass_ctx
301
+ @handle_errors
302
+ def run(ctx_obj: ContextObj, provider: list[str], command: tuple[str]) -> None:
303
+ """Run a subprocess with injected exported credentials."""
304
+ client = ctx_obj.initialize_client()
305
+ # spec states "Repeated flags for provider", so `provider` is a tuple of strings due to multiple=True
306
+ result = client.run(list(command), providers=list(provider))
307
+ sys.exit(result.returncode)
308
+
309
+
310
+ @cli.command()
311
+ @click.argument("path")
312
+ @click.option("--force", is_flag=True, help="Force overwrite if provider exists.")
313
+ @pass_ctx
314
+ @handle_errors
315
+ def register(ctx_obj: ContextObj, path: str, force: bool) -> None:
316
+ """Register a provider definition from a local JSON file path."""
317
+ import pathlib
318
+ client = ctx_obj.initialize_client()
319
+
320
+ filepath = pathlib.Path(path)
321
+ if not filepath.exists():
322
+ ctx_obj.echo(f"File not found: {path}", err=True, color="red")
323
+ sys.exit(1)
324
+
325
+ try:
326
+ data = json_lib.loads(filepath.read_text(encoding="utf-8"))
327
+ from authsome.models.provider import ProviderDefinition
328
+ definition = ProviderDefinition.model_validate(data)
329
+ client.register_provider(definition, force=force)
330
+
331
+ if ctx_obj.json_output:
332
+ ctx_obj.print_json({"status": "registered", "provider": definition.name})
333
+ else:
334
+ ctx_obj.echo(f"Provider {definition.name} registered.", color="green")
335
+ except Exception as exc:
336
+ ctx_obj.echo(f"Failed to register provider: {exc}", err=True, color="red")
337
+ sys.exit(1)
338
+
339
+
340
+ @cli.command()
341
+ @pass_ctx
342
+ @handle_errors
343
+ def whoami(ctx_obj: ContextObj) -> None:
344
+ """Show the active profile and basic local context."""
345
+ client = ctx_obj.initialize_client()
346
+ data = {
347
+ "active_profile": client.active_profile,
348
+ "home_directory": str(client.home),
349
+ "encryption_mode": client.config.encryption.mode if client.config.encryption else "local_key",
350
+ }
351
+
352
+ if ctx_obj.json_output:
353
+ ctx_obj.print_json(data)
354
+ else:
355
+ ctx_obj.echo(f"Active Profile: {data['active_profile']}")
356
+ ctx_obj.echo(f"Home Directory: {data['home_directory']}")
357
+ ctx_obj.echo(f"Encryption Mode: {data['encryption_mode']}")
358
+
359
+
360
+ @cli.command()
361
+ @pass_ctx
362
+ @handle_errors
363
+ def doctor(ctx_obj: ContextObj) -> None:
364
+ """Run health checks on directory layout and encryption."""
365
+ client = ctx_obj.initialize_client()
366
+ results = client.doctor()
367
+
368
+ if ctx_obj.json_output:
369
+ ctx_obj.print_json(results)
370
+ else:
371
+ all_ok = True
372
+ for key, val in results.items():
373
+ if key in ["issues", "providers_count", "profiles_count"]:
374
+ continue
375
+ status = "OK" if val else "FAIL"
376
+ color = "green" if val else "red"
377
+ if not val:
378
+ all_ok = False
379
+ ctx_obj.echo(f"{key}: ", nl=False)
380
+ ctx_obj.echo(status, color=color)
381
+
382
+ ctx_obj.echo(f"Providers Configured: {results.get('providers_count', 0)}")
383
+ ctx_obj.echo(f"Profiles: {results.get('profiles_count', 0)}")
384
+
385
+ issues = results.get("issues", [])
386
+ if issues:
387
+ ctx_obj.echo("\nIssues found:", color="red")
388
+ for issue in issues:
389
+ ctx_obj.echo(f" - {issue}", color="red")
390
+
391
+ if not all_ok:
392
+ sys.exit(1)
393
+
394
+
395
+ @cli.group(name="profile")
396
+ def profile_group() -> None:
397
+ """Manage local profiles."""
398
+ pass
399
+
400
+
401
+ @profile_group.command(name="list")
402
+ @pass_ctx
403
+ @handle_errors
404
+ def profile_list(ctx_obj: ContextObj) -> None:
405
+ """List local profiles."""
406
+ client = ctx_obj.initialize_client()
407
+ profiles = client.list_profiles()
408
+ active = client.active_profile
409
+
410
+ if ctx_obj.json_output:
411
+ ctx_obj.print_json({
412
+ "active": active,
413
+ "profiles": [p.model_dump(mode="json") for p in profiles]
414
+ })
415
+ else:
416
+ ctx_obj.echo("Profiles:")
417
+ for p in profiles:
418
+ mark = "*" if p.name == active else " "
419
+ ctx_obj.echo(f" {mark} {p.name} ({p.description or 'No description'})")
420
+
421
+
422
+ @profile_group.command(name="create")
423
+ @click.argument("name")
424
+ @pass_ctx
425
+ @handle_errors
426
+ def profile_create(ctx_obj: ContextObj, name: str) -> None:
427
+ """Create a profile."""
428
+ client = ctx_obj.initialize_client()
429
+ metadata = client.create_profile(name)
430
+
431
+ if ctx_obj.json_output:
432
+ ctx_obj.print_json({"status": "created", "profile": metadata.model_dump(mode="json")})
433
+ else:
434
+ ctx_obj.echo(f"Profile '{name}' created.", color="green")
435
+
436
+
437
+ @profile_group.command(name="use")
438
+ @click.argument("name")
439
+ @pass_ctx
440
+ @handle_errors
441
+ def profile_use(ctx_obj: ContextObj, name: str) -> None:
442
+ """Set the global default profile."""
443
+ client = ctx_obj.initialize_client()
444
+ client.set_default_profile(name)
445
+
446
+ if ctx_obj.json_output:
447
+ ctx_obj.print_json({"status": "default_changed", "profile": name})
448
+ else:
449
+ ctx_obj.echo(f"Default profile set to '{name}'.", color="green")
450
+
451
+
452
+ if __name__ == "__main__":
453
+ cli()