secryn-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.
secryn_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Secryn CLI — secrets management from your terminal."""
2
+
3
+ __version__ = "0.1.0"
secryn_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Secryn CLI — secrets management from your terminal."""
2
+
3
+ __version__ = "0.1.0"
secryn_cli/cli.py ADDED
@@ -0,0 +1,699 @@
1
+ """Secryn CLI — manage your secrets from the terminal.
2
+
3
+ Provides commands for authentication, project management, secret
4
+ operations, API key management, and user profile access.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ # The ``click`` package does not ship type stubs, so static checkers
13
+ # that run without ``--no-typeshed`` may report a missing import.
14
+ # pyrefly: ignore [missing-import]
15
+ import click
16
+
17
+ from . import __version__
18
+ from .client import APIError, Client
19
+ from .config import config_path, cookie_jar_path, load_config, save_config
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Display helpers
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def echo_success(text: str) -> None:
27
+ """Print a green success message to stderr."""
28
+ click.echo(f"\033[32m\u2713\033[0m {text}", err=True)
29
+
30
+
31
+ def echo_error(text: str) -> None:
32
+ """Print a red error message to stderr."""
33
+ click.echo(f"\033[31m\u2717\033[0m {text}", err=True)
34
+
35
+
36
+ def echo_info(text: str) -> None:
37
+ """Print a blue info message to stderr."""
38
+ click.echo(f"\033[34m\u2139\033[0m {text}", err=True)
39
+
40
+
41
+ def confirm_action(message: str) -> bool:
42
+ """Prompt the user for a yes/no confirmation (default no)."""
43
+ return click.confirm(f"\033[33m?\033[0m {message}", default=False)
44
+
45
+
46
+ def get_client(api_url: Optional[str] = None) -> Client:
47
+ """Create a configured API client, optionally overriding the API URL.
48
+
49
+ Args:
50
+ api_url: If provided, overrides and persists the API base URL.
51
+
52
+ Returns:
53
+ A ready-to-use ``Client`` instance.
54
+ """
55
+ cfg = load_config()
56
+ if api_url:
57
+ old_url = cfg.api_url
58
+ cfg.api_url = api_url
59
+ save_config(cfg)
60
+ if api_url != old_url:
61
+ echo_info(f"API URL set to {api_url}")
62
+ return Client(cfg)
63
+
64
+
65
+ def format_table(headers: list[str], rows: list[list[str]]) -> str:
66
+ """Render a list of rows as an aligned ASCII table.
67
+
68
+ Args:
69
+ headers: Column header labels.
70
+ rows: Row data, each inner list must have the same length as ``headers``.
71
+
72
+ Returns:
73
+ Formatted table string with headers separated from rows by a
74
+ dashed line.
75
+ """
76
+ col_widths = [len(h) for h in headers]
77
+ for row in rows:
78
+ for i, cell in enumerate(row):
79
+ col_widths[i] = max(col_widths[i], len(str(cell)))
80
+ col_widths = [w + 2 for w in col_widths]
81
+
82
+ lines: list[str] = []
83
+ hdr = "".join(h.ljust(col_widths[i]) for i, h in enumerate(headers))
84
+ lines.append(hdr)
85
+ lines.append("".join("-" * col_widths[i] for i in range(len(headers))))
86
+
87
+ for row in rows:
88
+ line = "".join(str(c).ljust(col_widths[i]) for i, c in enumerate(row))
89
+ lines.append(line)
90
+
91
+ return "\n".join(lines)
92
+
93
+
94
+ def mask_value(value: str) -> str:
95
+ """Mask a secret value, showing only the first and last 4 characters.
96
+
97
+ Args:
98
+ value: The plain-text secret value.
99
+
100
+ Returns:
101
+ Masked string with the middle characters replaced by ``*``.
102
+ Short values (<= 8 chars) are fully obscured.
103
+ """
104
+ if len(value) > 8:
105
+ return value[:4] + "*" * (len(value) - 8) + value[-4:]
106
+ if value:
107
+ return "*" * len(value)
108
+ return ""
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # CLI entry point
113
+ # ---------------------------------------------------------------------------
114
+
115
+ @click.group()
116
+ @click.option(
117
+ "--api-url",
118
+ envvar="SECRYN_API_URL",
119
+ help="API base URL (default: http://localhost:3000/api/v1)",
120
+ )
121
+ @click.pass_context
122
+ def cli(ctx: click.Context, api_url: Optional[str]) -> None:
123
+ """Secryn CLI — manage secrets, projects, and API keys."""
124
+ ctx.obj = get_client(api_url)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Auth commands
129
+ # ---------------------------------------------------------------------------
130
+
131
+ @cli.group()
132
+ def auth() -> None:
133
+ """Authenticate with Secryn."""
134
+
135
+
136
+ @auth.command("login")
137
+ @click.option("--email", help="Email address")
138
+ @click.option("--password", help="Password")
139
+ @click.pass_obj
140
+ def auth_login(client: Client, email: Optional[str], password: Optional[str]) -> None:
141
+ """Log in to Secryn.
142
+
143
+ Prompts interactively for email and password if not supplied via flags.
144
+ Supports MFA: when the account has MFA enabled you will be asked for
145
+ the TOTP token.
146
+ """
147
+ if not email:
148
+ email = click.prompt("Email", type=str)
149
+ if not password:
150
+ password = click.prompt("Password", hide_input=True, type=str)
151
+
152
+ result = client.post("/auth/login", {"email": email, "password": password})
153
+
154
+ if result.get("mfaRequired"):
155
+ echo_info("MFA is required for this account.")
156
+ token = click.prompt("Enter MFA token", type=str)
157
+ client.post(
158
+ "/auth/mfa/confirm",
159
+ {"token": token, "mfaToken": result["mfaToken"]},
160
+ )
161
+
162
+ client.config.user_email = email
163
+ client.save_config()
164
+ echo_success(f"Logged in as {email}")
165
+
166
+
167
+ @auth.command("logout")
168
+ @click.pass_obj
169
+ def auth_logout(client: Client) -> None:
170
+ """Log out and clear locally stored credentials."""
171
+ client.logout()
172
+ echo_success("Logged out successfully")
173
+
174
+
175
+ @auth.command("whoami")
176
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
177
+ @click.pass_obj
178
+ def auth_whoami(client: Client, as_json: bool) -> None:
179
+ """Show the currently logged-in user."""
180
+ user = client.get("/users/@me")
181
+
182
+ if as_json:
183
+ import json as _json
184
+
185
+ click.echo(_json.dumps(user, indent=2))
186
+ return
187
+
188
+ click.echo(
189
+ format_table(
190
+ ["ID", "EMAIL", "USERNAME"],
191
+ [[user["id"], user["email"], user.get("username", "")]],
192
+ )
193
+ )
194
+
195
+ client.config.user_id = user["id"]
196
+ client.config.user_email = user["email"]
197
+ client.save_config()
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Project commands
202
+ # ---------------------------------------------------------------------------
203
+
204
+ @cli.group()
205
+ def projects() -> None:
206
+ """Manage projects."""
207
+
208
+
209
+ @projects.command("list")
210
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
211
+ @click.pass_obj
212
+ def projects_list(client: Client, as_json: bool) -> None:
213
+ """List all projects you own or are a member of."""
214
+ result = client.get("/projects/@all")
215
+
216
+ if as_json:
217
+ import json as _json
218
+
219
+ click.echo(_json.dumps(result, indent=2))
220
+ return
221
+
222
+ if not result:
223
+ echo_info("No projects found")
224
+ return
225
+
226
+ rows = []
227
+ for p in result:
228
+ rows.append(
229
+ [
230
+ p["id"],
231
+ p["name"],
232
+ p.get("slug", ""),
233
+ p.get("description", "") or "",
234
+ p.get("createdAt", ""),
235
+ ]
236
+ )
237
+
238
+ click.echo(
239
+ format_table(
240
+ ["ID", "NAME", "SLUG", "DESCRIPTION", "CREATED"],
241
+ rows,
242
+ )
243
+ )
244
+
245
+
246
+ @projects.command("create")
247
+ @click.option("--name", required=True, help="Project name")
248
+ @click.option("--description", help="Project description")
249
+ @click.pass_obj
250
+ def projects_create(client: Client, name: str, description: Optional[str]) -> None:
251
+ """Create a new project."""
252
+ body: dict[str, str] = {"name": name}
253
+ if description:
254
+ body["description"] = description
255
+
256
+ project = client.post("/projects", body)
257
+ echo_success(f"Project created: {project['name']} ({project['id']})")
258
+
259
+
260
+ @projects.command("delete")
261
+ @click.option("--id", "project_id", required=True, help="Project ID")
262
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
263
+ @click.pass_obj
264
+ def projects_delete(client: Client, project_id: str, force: bool) -> None:
265
+ """Delete a project and all its secrets.
266
+
267
+ Asks for confirmation unless ``--force`` is passed.
268
+ """
269
+ if not force and not confirm_action(
270
+ f"Delete project {project_id}? All secrets will also be deleted."
271
+ ):
272
+ echo_info("Aborted")
273
+ return
274
+
275
+ client.delete(f"/projects/{project_id}")
276
+ echo_success(f"Project {project_id} deleted")
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Secret commands
281
+ # ---------------------------------------------------------------------------
282
+
283
+ @cli.group()
284
+ def secrets() -> None:
285
+ """Manage secrets."""
286
+
287
+
288
+ @secrets.command("list")
289
+ @click.option("--project-id", required=True, help="Project ID")
290
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
291
+ @click.pass_obj
292
+ def secrets_list(client: Client, project_id: str, as_json: bool) -> None:
293
+ """List all secrets in a project.
294
+
295
+ Secret values are masked in table output; use ``sc secrets get`` with
296
+ ``--show-value`` to reveal a single secret.
297
+ """
298
+ result = client.get(f"/projects/{project_id}/secrets")
299
+
300
+ if as_json:
301
+ import json as _json
302
+
303
+ click.echo(_json.dumps(result, indent=2))
304
+ return
305
+
306
+ if not result:
307
+ echo_info("No secrets found in this project")
308
+ return
309
+
310
+ rows = []
311
+ for s in result:
312
+ rows.append(
313
+ [
314
+ s["id"],
315
+ s["name"],
316
+ mask_value(s.get("value", "")),
317
+ s.get("notes", "") or "",
318
+ s.get("updatedAt", ""),
319
+ ]
320
+ )
321
+
322
+ click.echo(
323
+ format_table(
324
+ ["ID", "NAME", "VALUE", "NOTES", "UPDATED"],
325
+ rows,
326
+ )
327
+ )
328
+
329
+
330
+ @secrets.command("get")
331
+ @click.option("--id", "secret_id", required=True, help="Secret ID")
332
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
333
+ @click.option("--show-value", is_flag=True, help="Display the secret value in plain text")
334
+ @click.pass_obj
335
+ def secrets_get(
336
+ client: Client, secret_id: str, as_json: bool, show_value: bool
337
+ ) -> None:
338
+ """Retrieve a single secret by ID.
339
+
340
+ By default the value is masked. Pass ``--show-value`` to reveal it.
341
+ """
342
+ secret = client.get(f"/projects/secrets/{secret_id}")
343
+
344
+ if as_json:
345
+ import json as _json
346
+
347
+ if not show_value:
348
+ secret_display = dict(secret)
349
+ secret_display["value"] = mask_value(secret_display.get("value", ""))
350
+ click.echo(_json.dumps(secret_display, indent=2))
351
+ else:
352
+ click.echo(_json.dumps(secret, indent=2))
353
+ return
354
+
355
+ value_display = (
356
+ secret.get("value", "")
357
+ if show_value
358
+ else mask_value(secret.get("value", "")) + " (use --show-value to reveal)"
359
+ )
360
+ click.echo(
361
+ format_table(
362
+ ["FIELD", "VALUE"],
363
+ [
364
+ ["ID", secret["id"]],
365
+ ["Name", secret["name"]],
366
+ ["Value", value_display],
367
+ ["Notes", secret.get("notes", "") or ""],
368
+ ["Project ID", secret.get("projectId", "")],
369
+ ["Created", secret.get("createdAt", "")],
370
+ ["Updated", secret.get("updatedAt", "")],
371
+ ],
372
+ )
373
+ )
374
+
375
+
376
+ @secrets.command("create")
377
+ @click.option("--project-id", required=True, help="Project ID")
378
+ @click.option("--name", required=True, help="Secret name (e.g. DATABASE_URL)")
379
+ @click.option("--value", required=True, help="Secret value")
380
+ @click.option("--notes", help="Optional notes for this secret")
381
+ @click.pass_obj
382
+ def secrets_create(
383
+ client: Client,
384
+ project_id: str,
385
+ name: str,
386
+ value: str,
387
+ notes: Optional[str],
388
+ ) -> None:
389
+ """Create a new secret in a project."""
390
+ body: dict[str, str] = {"name": name, "value": value}
391
+ if notes:
392
+ body["notes"] = notes
393
+
394
+ secret = client.post(f"/projects/{project_id}/secrets", body)
395
+ echo_success(f"Secret created: {secret['name']} ({secret['id']})")
396
+
397
+
398
+ @secrets.command("update")
399
+ @click.option("--id", "secret_id", required=True, help="Secret ID")
400
+ @click.option("--name", help="New name for the secret")
401
+ @click.option("--value", help="New value for the secret")
402
+ @click.option("--notes", help="New notes for the secret")
403
+ @click.pass_obj
404
+ def secrets_update(
405
+ client: Client,
406
+ secret_id: str,
407
+ name: Optional[str],
408
+ value: Optional[str],
409
+ notes: Optional[str],
410
+ ) -> None:
411
+ """Update an existing secret.
412
+
413
+ Only the fields you pass will be changed; omitted fields stay
414
+ unchanged.
415
+ """
416
+ body: dict[str, str] = {}
417
+ if name:
418
+ body["name"] = name
419
+ if value:
420
+ body["value"] = value
421
+ if notes is not None:
422
+ body["notes"] = notes
423
+
424
+ if not body:
425
+ echo_info("No fields to update")
426
+ return
427
+
428
+ secret = client.put(f"/projects/secrets/{secret_id}", body)
429
+ echo_success(f"Secret {secret['id']} updated")
430
+
431
+
432
+ @secrets.command("delete")
433
+ @click.option("--id", "secret_id", required=True, help="Secret ID")
434
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
435
+ @click.pass_obj
436
+ def secrets_delete(client: Client, secret_id: str, force: bool) -> None:
437
+ """Delete a secret permanently."""
438
+ if not force and not confirm_action(f"Delete secret {secret_id}?"):
439
+ echo_info("Aborted")
440
+ return
441
+
442
+ client.delete(f"/projects/secrets/{secret_id}")
443
+ echo_success(f"Secret {secret_id} deleted")
444
+
445
+
446
+ @secrets.command("export")
447
+ @click.option("--project-id", required=True, help="Project ID")
448
+ @click.option(
449
+ "--output",
450
+ "-o",
451
+ "output_file",
452
+ help="Output file path (prints to stdout if omitted)",
453
+ )
454
+ @click.pass_obj
455
+ def secrets_export(
456
+ client: Client, project_id: str, output_file: Optional[str]
457
+ ) -> None:
458
+ """Export all project secrets as a ``.env`` file.
459
+
460
+ Writes dotenv-formatted output to stdout or to the file specified
461
+ with ``-o``. The output file is created with mode ``0600``.
462
+ """
463
+ data = client.get_raw(f"/projects/{project_id}/secrets/export")
464
+
465
+ if output_file:
466
+ Path(output_file).write_text(data)
467
+ os.chmod(output_file, 0o600)
468
+ echo_success(f"Secrets exported to {output_file}")
469
+ else:
470
+ click.echo(data)
471
+
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # API key commands
475
+ # ---------------------------------------------------------------------------
476
+
477
+ @cli.group(name="api-keys")
478
+ def api_keys() -> None:
479
+ """Manage API keys for programmatic access."""
480
+
481
+
482
+ @api_keys.command("list")
483
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
484
+ @click.pass_obj
485
+ def api_keys_list(client: Client, as_json: bool) -> None:
486
+ """List all API keys for your account."""
487
+ keys = client.get("/api-keys/@all-user")
488
+
489
+ if as_json:
490
+ import json as _json
491
+
492
+ click.echo(_json.dumps(keys, indent=2))
493
+ return
494
+
495
+ if not keys:
496
+ echo_info("No API keys found")
497
+ return
498
+
499
+ rows = []
500
+ for k in keys:
501
+ status = "active" if k.get("isActive", True) else "inactive"
502
+ perms = ", ".join(k.get("permissions", []))
503
+ rows.append(
504
+ [
505
+ k["id"],
506
+ k.get("keyName", ""),
507
+ status,
508
+ perms,
509
+ k.get("createdAt", ""),
510
+ k.get("expiresAt", ""),
511
+ ]
512
+ )
513
+
514
+ click.echo(
515
+ format_table(
516
+ ["ID", "NAME", "STATUS", "PERMISSIONS", "CREATED", "EXPIRES"],
517
+ rows,
518
+ )
519
+ )
520
+
521
+
522
+ @api_keys.command("create")
523
+ @click.option("--name", required=True, help="Human-readable label for the key")
524
+ @click.option(
525
+ "--permissions",
526
+ default="read,write",
527
+ help="Comma-separated permissions: read, write",
528
+ )
529
+ @click.pass_obj
530
+ def api_keys_create(client: Client, name: str, permissions: str) -> None:
531
+ """Create a new API key.
532
+
533
+ The key value is displayed **only once** at creation time — save it
534
+ immediately. Permissions can be ``read`` and/or ``write``.
535
+ """
536
+ perm_list = [p.strip() for p in permissions.split(",") if p.strip()]
537
+
538
+ key = client.post("/api-keys", {"name": name, "permissions": perm_list})
539
+
540
+ click.echo("")
541
+ click.echo("\u250c" + "\u2500" * 47 + "\u2510")
542
+ click.echo("\u2502 IMPORTANT: Save this key securely! \u2502")
543
+ click.echo("\u2502 It will NOT be shown again. \u2502")
544
+ click.echo("\u251c" + "\u2500" * 47 + "\u2524")
545
+ click.echo(f"\u2502 Key: {key['key']:<38} \u2502")
546
+ click.echo(f"\u2502 ID: {key['id']:<38} \u2502")
547
+ click.echo(
548
+ f"\u2502 Perm: {', '.join(key.get('permissions', [])):<38} \u2502"
549
+ )
550
+ click.echo("\u2514" + "\u2500" * 47 + "\u2518")
551
+ echo_success(f"API key created: {key.get('keyName', name)}")
552
+
553
+
554
+ @api_keys.command("delete")
555
+ @click.option("--id", "key_id", required=True, help="API key ID")
556
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
557
+ @click.pass_obj
558
+ def api_keys_delete(client: Client, key_id: str, force: bool) -> None:
559
+ """Delete an API key permanently."""
560
+ if not force and not confirm_action(f"Delete API key {key_id}?"):
561
+ echo_info("Aborted")
562
+ return
563
+
564
+ client.delete(f"/api-keys/{key_id}")
565
+ echo_success(f"API key {key_id} deleted")
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # User commands
570
+ # ---------------------------------------------------------------------------
571
+
572
+ @cli.group()
573
+ def user() -> None:
574
+ """User information and settings."""
575
+
576
+
577
+ @user.command("info")
578
+ @click.pass_obj
579
+ def user_info(client: Client) -> None:
580
+ """Show your user profile information."""
581
+ u = client.get("/users/@me")
582
+
583
+ click.echo(
584
+ format_table(
585
+ ["FIELD", "VALUE"],
586
+ [
587
+ ["ID", u["id"]],
588
+ ["Email", u["email"]],
589
+ ["Username", u.get("username", "")],
590
+ ["Role", u.get("role", "")],
591
+ ["Joined", u.get("createdAt", "")],
592
+ ],
593
+ )
594
+ )
595
+
596
+ client.config.user_id = u["id"]
597
+ client.config.user_email = u["email"]
598
+ client.save_config()
599
+
600
+
601
+ # ---------------------------------------------------------------------------
602
+ # Meta commands
603
+ # ---------------------------------------------------------------------------
604
+
605
+ @cli.command("version")
606
+ def version() -> None:
607
+ """Print the CLI version and exit."""
608
+ click.echo(f"Secryn CLI v{__version__}")
609
+
610
+
611
+ @cli.command("config")
612
+ def config_cmd() -> None:
613
+ """Show current CLI configuration paths and values."""
614
+ _cfg = load_config()
615
+
616
+ click.echo(
617
+ format_table(
618
+ ["SETTING", "VALUE"],
619
+ [
620
+ ["API URL", _cfg.api_url],
621
+ ["Config file", str(config_path())],
622
+ ["Cookie jar", str(cookie_jar_path())],
623
+ ["Logged in as", _cfg.user_email or "(not logged in)"],
624
+ ],
625
+ )
626
+ )
627
+
628
+
629
+ # ---------------------------------------------------------------------------
630
+ # Entry point
631
+ # ---------------------------------------------------------------------------
632
+
633
+ def main() -> None:
634
+ """Entry point that wraps the Click CLI group with custom error handling.
635
+
636
+ Pre-processes the ``--api-url`` flag from ``sys.argv`` so the URL is
637
+ persisted to disk even when no subcommand is given (Click does not invoke
638
+ the group callback when a subcommand is absent).
639
+
640
+ Uses ``standalone_mode=False`` so exceptions propagate to this function
641
+ instead of being caught by Click's built-in handlers. This allows a
642
+ unified exit-code strategy:
643
+
644
+ - ``SystemExit`` (including ``click.exceptions.Exit``) → exit with the
645
+ exception's code, preserving Click's own exit semantics.
646
+ - ``NoArgsIsHelpError`` → display the command list and exit 0.
647
+ - ``UsageError`` with *Missing command* / *No such command* → show
648
+ the error text followed by the command list, exit 0.
649
+ - Other ``UsageError`` / ``ClickException`` / ``APIError`` /
650
+ ``Exception`` → print the message to stderr and exit 1.
651
+
652
+ Note:
653
+ Exit codes 0 and 1 are the only codes emitted by this wrapper;
654
+ Click's own ``--help`` handling and built-in validation errors
655
+ (e.g. ``NoSuchOption``) may produce code 2 before reaching here.
656
+ """
657
+ args = sys.argv[1:]
658
+ for i, arg in enumerate(args):
659
+ if arg == "--api-url" and i + 1 < len(args):
660
+ api_url = args[i + 1]
661
+ cfg = load_config()
662
+ if api_url != cfg.api_url:
663
+ cfg.api_url = api_url
664
+ save_config(cfg)
665
+ echo_info(f"API URL set to {api_url}")
666
+ break
667
+
668
+ exit_code = 0
669
+ try:
670
+ cli.main(args=args, prog_name="sc", standalone_mode=False)
671
+ except click.exceptions.Exit as e:
672
+ exit_code = e.code if e.code is not None else 0
673
+ except click.ClickException as e:
674
+ if isinstance(e, click.exceptions.NoArgsIsHelpError):
675
+ click.echo(cli.get_help(click.Context(cli, info_name="sc")))
676
+ elif isinstance(e, click.UsageError):
677
+ msg = str(e)
678
+ if "Missing command" in msg or "No such command" in msg:
679
+ click.echo(f"Error: {msg}", err=True)
680
+ click.echo()
681
+ click.echo(cli.get_help(click.Context(cli, info_name="sc")))
682
+ else:
683
+ echo_error(msg)
684
+ exit_code = 1
685
+ else:
686
+ echo_error(str(e))
687
+ exit_code = 1
688
+ except APIError as e:
689
+ echo_error(str(e))
690
+ exit_code = 1
691
+ except Exception as e:
692
+ echo_error(str(e))
693
+ exit_code = 1
694
+
695
+ sys.exit(exit_code)
696
+
697
+
698
+ if __name__ == "__main__":
699
+ main()