safeeasyapi 0.4.1__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.
Files changed (52) hide show
  1. safeeasyapi/__init__.py +3 -0
  2. safeeasyapi/__main__.py +6 -0
  3. safeeasyapi/cli.py +526 -0
  4. safeeasyapi/clipboard.py +154 -0
  5. safeeasyapi/connect.py +566 -0
  6. safeeasyapi/keychain.py +365 -0
  7. safeeasyapi/mcp.py +412 -0
  8. safeeasyapi/publicapis.py +170 -0
  9. safeeasyapi/recipes/airtable.json +24 -0
  10. safeeasyapi/recipes/anthropic.json +31 -0
  11. safeeasyapi/recipes/assemblyai.json +25 -0
  12. safeeasyapi/recipes/aws.json +1 -0
  13. safeeasyapi/recipes/brave-search.json +33 -0
  14. safeeasyapi/recipes/cloudflare.json +29 -0
  15. safeeasyapi/recipes/cohere.json +24 -0
  16. safeeasyapi/recipes/deepgram.json +25 -0
  17. safeeasyapi/recipes/discord.json +25 -0
  18. safeeasyapi/recipes/ebay.json +1 -0
  19. safeeasyapi/recipes/elevenlabs.json +25 -0
  20. safeeasyapi/recipes/github.json +1 -0
  21. safeeasyapi/recipes/google-gemini.json +32 -0
  22. safeeasyapi/recipes/groq.json +24 -0
  23. safeeasyapi/recipes/huggingface.json +24 -0
  24. safeeasyapi/recipes/mailgun.json +28 -0
  25. safeeasyapi/recipes/mistral.json +24 -0
  26. safeeasyapi/recipes/netlify.json +24 -0
  27. safeeasyapi/recipes/notion.json +30 -0
  28. safeeasyapi/recipes/openai.json +24 -0
  29. safeeasyapi/recipes/openrouter.json +25 -0
  30. safeeasyapi/recipes/openweather.json +1 -0
  31. safeeasyapi/recipes/paypal.json +1 -0
  32. safeeasyapi/recipes/pinecone.json +27 -0
  33. safeeasyapi/recipes/postmark.json +27 -0
  34. safeeasyapi/recipes/replicate.json +23 -0
  35. safeeasyapi/recipes/resend.json +30 -0
  36. safeeasyapi/recipes/sendgrid.json +24 -0
  37. safeeasyapi/recipes/spotify.json +1 -0
  38. safeeasyapi/recipes/stripe.json +1 -0
  39. safeeasyapi/recipes/supabase.json +25 -0
  40. safeeasyapi/recipes/tavily.json +23 -0
  41. safeeasyapi/recipes/together.json +23 -0
  42. safeeasyapi/recipes/twilio.json +1 -0
  43. safeeasyapi/recipes/vercel.json +23 -0
  44. safeeasyapi/recipes.py +539 -0
  45. safeeasyapi/store.py +686 -0
  46. safeeasyapi/validate.py +437 -0
  47. safeeasyapi-0.4.1.dist-info/METADATA +666 -0
  48. safeeasyapi-0.4.1.dist-info/RECORD +52 -0
  49. safeeasyapi-0.4.1.dist-info/WHEEL +5 -0
  50. safeeasyapi-0.4.1.dist-info/entry_points.txt +2 -0
  51. safeeasyapi-0.4.1.dist-info/licenses/LICENSE +21 -0
  52. safeeasyapi-0.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """SafeEasyAPI: the safe tunnel for API keys."""
2
+
3
+ __version__ = "0.4.1"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
safeeasyapi/cli.py ADDED
@@ -0,0 +1,526 @@
1
+ """SafeEasyAPI CLI: `safeeasyapi` - the safe tunnel for API keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import errno
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ from . import __version__
12
+ from . import connect as connect_mod
13
+ from . import keychain as keychain_mod
14
+ from . import mcp as mcp_mod
15
+ from . import publicapis as pa_mod
16
+ from . import recipes as recipes_mod
17
+ from . import store as store_mod
18
+
19
+
20
+ def _harden_streams() -> None:
21
+ """Never crash on a console that cannot encode the check mark, the
22
+ warning sign or a non-ASCII API name (cp1252 on Windows): replace
23
+ instead of raising UnicodeEncodeError half-way through a connect."""
24
+ for stream in (sys.stdout, sys.stderr):
25
+ reconfigure = getattr(stream, "reconfigure", None)
26
+ # Both handlers raise UnicodeEncodeError on a character the code
27
+ # page lacks: "strict" (the default) and "surrogateescape" (what a
28
+ # piped stdout reports on Windows 11 / Python 3.11, with cp1252).
29
+ if reconfigure is None or getattr(stream, "errors", None) not in (
30
+ "strict", "surrogateescape"):
31
+ continue
32
+ try:
33
+ reconfigure(errors="replace")
34
+ except (OSError, ValueError):
35
+ pass
36
+
37
+
38
+ def _build_parser() -> argparse.ArgumentParser:
39
+ p = argparse.ArgumentParser(
40
+ prog="safeeasyapi",
41
+ description="Safe tunnel for API keys: guided, validated, "
42
+ "securely stored. The key is never displayed or logged.")
43
+ p.add_argument("--version", action="version",
44
+ version=f"%(prog)s {__version__}")
45
+ sub = p.add_subparsers(dest="cmd", required=True)
46
+
47
+ for name, help_text in (("connect", "Connect a service (guided key setup)."),
48
+ ("rotate", "Replace the stored key for a service.")):
49
+ c = sub.add_parser(name, help=help_text)
50
+ c.add_argument("service", help="Service slug (see `safeeasyapi list`).")
51
+ c.add_argument("--env-file", default=".env",
52
+ help="Where to store the key with --store env "
53
+ "(default: .env).")
54
+ c.add_argument("--store", choices=["env", "keychain"], default="env",
55
+ help="env: owner-only .env file (default). keychain: "
56
+ "the OS keychain (macOS Keychain, Windows "
57
+ "Credential Manager, secret-tool on Linux), "
58
+ "falling back to the .env file when none is "
59
+ "available; use `safeeasyapi run` to consume it.")
60
+ c.add_argument("--no-browser", action="store_true",
61
+ help="Don't auto-open the key page.")
62
+ c.add_argument("--clipboard", action="store_true",
63
+ help="Copy-and-go: click the vendor's Copy button and the key "
64
+ "is taken from the clipboard (then emptied) - no pasting.")
65
+ c.add_argument("--via-cli", action="store_true",
66
+ help="Get the key from the vendor's own CLI after its browser "
67
+ "sign-in (built-in recipes that support it, e.g. github).")
68
+
69
+ sub.add_parser("list", help="List services with saved recipes.")
70
+
71
+ fg = sub.add_parser("forget", help="Remove a stored key: from the OS "
72
+ "keychain and from the .env file.")
73
+ fg.add_argument("service", help="Service slug.")
74
+ fg.add_argument("--env-file", default=".env",
75
+ help="The .env file to remove the entry from (default: .env).")
76
+
77
+ a = sub.add_parser("add", help="Capture a new service recipe interactively.")
78
+ a.add_argument("--recipes-dir", default=None,
79
+ help="Write the recipe here instead of "
80
+ f"{recipes_mod.user_recipes_dir()}.")
81
+
82
+ r = sub.add_parser("run", help="Run a command with a keychain-stored key "
83
+ "in its environment (never printed).")
84
+ r.add_argument("service", help="Service slug.")
85
+ r.add_argument("command", nargs=argparse.REMAINDER,
86
+ help="command after -- : safeeasyapi run stripe -- python app.py")
87
+
88
+ sub.add_parser("mcp", help="Run the MCP server on stdio (for Claude Code).")
89
+
90
+ f = sub.add_parser("find", help="Find free public APIs for a task.")
91
+ f.add_argument("query", help="What you need, e.g. 'weather', 'crypto price'.")
92
+ f.add_argument("--auth", default=None,
93
+ help="Filter by auth, e.g. No, apiKey, OAuth.")
94
+ f.add_argument("--category", default=None, help="Filter by category.")
95
+ f.add_argument("--limit", type=int, default=10)
96
+ f.add_argument("--refresh", action="store_true",
97
+ help="Re-download the directory (cached 7 days).")
98
+ return p
99
+
100
+
101
+ def _cmd_list(catalog: dict) -> int:
102
+ if not catalog:
103
+ print("no recipes yet - add one with `safeeasyapi add`.")
104
+ return 0
105
+ print("\n services with saved key recipes:\n")
106
+ for slug in sorted(catalog):
107
+ r = catalog[slug]
108
+ # a recipe is somebody else's text: nothing in it may drive the terminal
109
+ print(f" {recipes_mod.printable(slug):14} "
110
+ f"{recipes_mod.printable(r['display'], words=True):38} -> "
111
+ f"{', '.join(recipes_mod.printable(v) for v in recipes_mod.env_vars(r))}")
112
+ print("\n `safeeasyapi connect <service>` to set one up.\n")
113
+ return 0
114
+
115
+
116
+ def _prompt(text: str, default: str = "") -> str:
117
+ try:
118
+ val = input(f" {text}" + (f" [{default}]" if default else "") + ": ")
119
+ except (EOFError, KeyboardInterrupt):
120
+ print("\ncancelled.")
121
+ sys.exit(1)
122
+ return val.strip() or default
123
+
124
+
125
+ def _parse_expect(text: str) -> list[int]:
126
+ """'200, 201' -> [200, 201]; anything that is not a 3-digit status is a
127
+ ValueError with a message meant for a human, not a traceback."""
128
+ codes = []
129
+ for part in text.split(","):
130
+ part = part.strip()
131
+ if not part.isdigit() or not 100 <= int(part) <= 599:
132
+ raise ValueError(
133
+ f"expected HTTP status codes like 200 or 200,201 - got {part!r}")
134
+ codes.append(int(part))
135
+ if not codes:
136
+ raise ValueError("at least one expected status code is required")
137
+ return codes
138
+
139
+
140
+ def _cmd_add(recipes_dir: str | None) -> int:
141
+ print("\n Capture a new service recipe - teach it once, use it forever.\n")
142
+ service = _prompt("slug (lowercase, no spaces)", "").lower().replace(" ", "-")
143
+ if not service:
144
+ print(" slug is required.")
145
+ return 1
146
+ if not recipes_mod.SLUG_RE.match(service):
147
+ # the slug becomes the file name <slug>.json: "../../evil" or
148
+ # "my service!" must never reach the filesystem
149
+ print(f" '{service}' is not a valid slug: use lowercase letters, "
150
+ "digits, '-' or '_' (1-64 chars), e.g. 'my-api'.")
151
+ return 1
152
+ reserved = recipes_mod.device_name_problem(service)
153
+ if reserved:
154
+ print(f" {reserved}.")
155
+ return 1
156
+ recipe = {
157
+ "service": service,
158
+ "display": _prompt("display name"),
159
+ "key_url": _prompt("URL of the page where keys are minted"),
160
+ "env_var": _prompt("env var name (e.g. HEADROOM_API_KEY)").upper(),
161
+ "scopes": [s.strip() for s in
162
+ _prompt("scopes to select (comma-separated, optional)").split(",")
163
+ if s.strip()],
164
+ "docs": _prompt("docs URL (optional)"),
165
+ "steps": [],
166
+ "validate": {},
167
+ }
168
+ print(" exact click-path steps (blank line to finish):")
169
+ while True:
170
+ s = _prompt(f"step {len(recipe['steps']) + 1}")
171
+ if not s:
172
+ break
173
+ recipe["steps"].append(s)
174
+ print(" validation - one API call that proves the key works:")
175
+ recipe["validate"] = {
176
+ "method": _prompt("HTTP method", "GET").upper(),
177
+ "url": _prompt("validation URL"),
178
+ "auth": {"type": _prompt("auth type (bearer/header/basic)", "bearer").lower()},
179
+ }
180
+ try:
181
+ recipe["validate"]["expect"] = _parse_expect(
182
+ _prompt("expected status codes (comma-separated)", "200"))
183
+ except ValueError as e:
184
+ print(f" {e}")
185
+ return 1
186
+ if recipe["validate"]["auth"]["type"] == "header":
187
+ recipe["validate"]["auth"]["header"] = _prompt("header name")
188
+ try:
189
+ path = recipes_mod.save_recipe(recipe, recipes_dir)
190
+ except recipes_mod.RecipeError as e:
191
+ print(f" recipe incomplete: {e}")
192
+ return 1
193
+ print(f"\n ✓ recipe saved to {path}\n")
194
+ return 0
195
+
196
+
197
+ def _cmd_find(args) -> int:
198
+ try:
199
+ text = pa_mod.fetch(refresh=args.refresh)
200
+ except Exception as e:
201
+ print(f"couldn't fetch the public API directory: {e}")
202
+ print("check your connection and try --refresh later.")
203
+ return 2
204
+ entries = pa_mod.parse(text)
205
+ hits = pa_mod.search(entries, args.query, auth=args.auth,
206
+ category=args.category, limit=args.limit)
207
+ if not hits:
208
+ print(f"no public APIs match '{args.query}'.")
209
+ return 0
210
+ print(f"\n public APIs matching '{args.query}':\n")
211
+ for e in hits:
212
+ # All directory strings are community-sourced: sanitize before
213
+ # printing so escape sequences can't execute against the terminal.
214
+ name = pa_mod.sanitize_display(e["name"])
215
+ desc = pa_mod.sanitize_display(e["description"])
216
+ url = pa_mod.sanitize_display(e["url"])
217
+ category = pa_mod.sanitize_display(e["category"])
218
+ auth_tag = "" if e["auth"] == "No" else f" [{e['auth']}]"
219
+ print(f" {name}{auth_tag}")
220
+ print(f" {desc}")
221
+ print(f" {url} ({category})")
222
+ if e["auth"] == "apiKey":
223
+ print(" needs a key - save its recipe with `safeeasyapi add`.")
224
+ print()
225
+ return 0
226
+
227
+
228
+ def keychain_store(recipe: dict, env_file: str, out=None,
229
+ backend: str | None = None, keychain=keychain_mod):
230
+ """store_fn for `--store keychain`: the OS keychain when the platform
231
+ has one, otherwise the owner-only .env file - and say which."""
232
+ out = out or sys.stdout
233
+ backend = backend or keychain.detect_backend()
234
+
235
+ def _store(env_var: str, key: str) -> str:
236
+ if backend is None:
237
+ out.write(" (no OS keychain available on this system - "
238
+ "storing in the .env file instead)\n")
239
+ return connect_mod.store_in_env_file(env_file)(env_var, key)
240
+ try:
241
+ used = keychain.store(recipe["service"], env_var, key, backend)
242
+ except keychain.KeychainError as e:
243
+ out.write(f" ({e} - storing in the .env file instead)\n")
244
+ return connect_mod.store_in_env_file(env_file)(env_var, key)
245
+ return (f"stored {env_var} in the {keychain.label(used)} "
246
+ f"(never displayed, never logged); use it with "
247
+ f"`safeeasyapi run {recipe['service']} -- <command>`")
248
+ return _store
249
+
250
+
251
+ def _describe_links(links) -> str:
252
+ """The other names of a file, for a message: the ones in its folder by
253
+ name (shown so that a hostile file name cannot drive the terminal), the
254
+ rest as a count."""
255
+ parts = [recipes_mod.printable(name) for name in links.names]
256
+ if links.elsewhere == 1:
257
+ parts.append("1 in another folder")
258
+ elif links.elsewhere:
259
+ parts.append(f"{links.elsewhere} in other folders")
260
+ return ", ".join(parts)
261
+
262
+
263
+ def _cmd_forget(recipe: dict, env_file: str, out=None,
264
+ keychain=keychain_mod, remove_env=store_mod.remove_key) -> int:
265
+ """Remove the service's key everywhere it may have been stored: the OS
266
+ keychain entry (when this platform has one) and every ENV_VAR= line in
267
+ the .env file. Says what was removed; never shows the key."""
268
+ out = out or sys.stdout
269
+ service, env_var = recipe["service"], recipe["env_var"]
270
+ removed = []
271
+ keychain_problem = None
272
+ backend = keychain.detect_backend()
273
+ if backend is not None:
274
+ try:
275
+ if keychain.delete(service, env_var, backend):
276
+ removed.append(f"the {keychain.label(backend)} entry")
277
+ except keychain.KeychainError as e:
278
+ # The keychain tool hung or would not start. The .env file is
279
+ # still ours to clean, so carry on - and say plainly that the
280
+ # keychain entry may still be there.
281
+ keychain_problem = str(e)
282
+ # Other names of the file are asked BEFORE the rewrite: afterwards
283
+ # env_file is a new file with one name, and the old one lives on, key
284
+ # and all, under the others.
285
+ links = store_mod.other_links(env_file)
286
+ env_problem = None
287
+ try:
288
+ lines = remove_env(env_var, env_file)
289
+ except OSError as e:
290
+ # A link, a file that is not plain text, no permission. What was
291
+ # removed above stays removed, and the leftover copies below are ours
292
+ # to remove whatever happens to .env: carry on, and say what could
293
+ # not be done only after saying what was.
294
+ lines, env_problem = 0, e.strerror or str(e)
295
+ if lines:
296
+ removed.append(f"{env_var} from {env_file}")
297
+ # A write that was killed halfway leaves a whole copy of the file behind.
298
+ gone, stuck = store_mod.remove_leftovers(env_var, env_file)
299
+ for path in gone:
300
+ removed.append(f"a leftover copy in {recipes_mod.printable(path)} "
301
+ f"(from an interrupted write)")
302
+ problems = [] # what is not done yet, in the order it was found
303
+ if keychain_problem:
304
+ problems.append(
305
+ f" \u2717 could not remove the {keychain.label(backend)} "
306
+ f"entry: {keychain_problem}.\n"
307
+ f" The key may still be stored there \u2014 run "
308
+ f"`safeeasyapi forget {service}` again when it answers.\n")
309
+ if env_problem:
310
+ problems.append(
311
+ f" \u2717 could not rewrite {recipes_mod.printable(env_file)}: "
312
+ f"{recipes_mod.printable(env_problem)}.\n"
313
+ f" The key may still be stored there - fix that and run "
314
+ f"`safeeasyapi forget {service}` again.\n")
315
+ for path, why in stuck:
316
+ problems.append(
317
+ f" \u2717 could not remove {recipes_mod.printable(path)}: "
318
+ f"{recipes_mod.printable(why)}.\n"
319
+ f" It is a leftover copy of {recipes_mod.printable(env_file)} "
320
+ f"from an interrupted write and still holds the key - delete it "
321
+ f"yourself.\n")
322
+ if lines and links:
323
+ problems.append(
324
+ f" \u2717 {recipes_mod.printable(env_file)} also has other names "
325
+ f"(hard links): {_describe_links(links)}.\n"
326
+ f" They still hold the old contents, {env_var} included - "
327
+ f"rewriting a file cannot change its other names.\n"
328
+ f" safeeasyapi does not delete files it did not make; delete "
329
+ f"them yourself if they are stray backups.\n")
330
+ if not removed:
331
+ for text in problems:
332
+ out.write(text)
333
+ if problems:
334
+ return 1
335
+ out.write(f" nothing stored for '{service}' (checked "
336
+ f"{keychain.label(backend)} and {env_file}).\n")
337
+ return 0
338
+ # What was done comes first, then what was not: "forgot" only when that
339
+ # is all of it.
340
+ lead = "removed" if problems else f"forgot {service}: removed"
341
+ out.write(f" \u2713 {lead} {' and '.join(removed)}.\n")
342
+ if isinstance(lines, int) and lines > 1:
343
+ # every one of them, or an older line would take over again
344
+ both = "both" if lines == 2 else f"all {lines}"
345
+ out.write(f" note: {env_var} appeared {store_mod.times(lines)} in "
346
+ f"{env_file}; I removed {both}.\n")
347
+ for text in problems:
348
+ out.write(text)
349
+ return 1 if problems else 0
350
+
351
+
352
+ def _cmd_run(recipe: dict, command: list[str]) -> int:
353
+ cmd = list(command)
354
+ if cmd and cmd[0] == "--":
355
+ cmd = cmd[1:]
356
+ if not cmd:
357
+ print("error: no command: safeeasyapi run <service> -- <command>",
358
+ file=sys.stderr)
359
+ return 2
360
+ backend = keychain_mod.detect_backend()
361
+ if backend is None:
362
+ print("error: no OS keychain on this system - the key is in your "
363
+ ".env file; load that instead.", file=sys.stderr)
364
+ return 1
365
+ try:
366
+ # before the keychain is asked for the key: a typo costs no unlock
367
+ cmd[0] = keychain_mod.locate_program(cmd[0])
368
+ except keychain_mod.CannotRun as e:
369
+ print(f"error: {e}", file=sys.stderr)
370
+ return e.code
371
+ try:
372
+ return keychain_mod.run_with_key(recipe, cmd, backend)
373
+ except keychain_mod.CannotRun as e:
374
+ print(f"error: {e}", file=sys.stderr)
375
+ return e.code
376
+ except keychain_mod.KeychainUnavailable as e:
377
+ print(f"error: the {keychain_mod.label(backend)} did not give the key "
378
+ f"for '{recipe['service']}': {e}.", file=sys.stderr)
379
+ return 1
380
+ except keychain_mod.KeychainError:
381
+ print(f"error: no key for '{recipe['service']}' in the "
382
+ f"{keychain_mod.label(backend)} - run "
383
+ f"`safeeasyapi connect {recipe['service']} --store keychain`.",
384
+ file=sys.stderr)
385
+ return 1
386
+
387
+
388
+ # True on Windows. A module-level flag (not os.name read on the spot) so a
389
+ # test can switch it and check both platforms' behaviour from either one.
390
+ _ON_WINDOWS = os.name == "nt"
391
+
392
+
393
+ def is_closed_pipe(exc: BaseException) -> bool:
394
+ """Is `exc` what writing to a pipe nobody reads any more looks like?
395
+
396
+ On POSIX that is BrokenPipeError (errno EPIPE). On Windows it is NOT:
397
+ print() of small output works because it is only buffered, and the
398
+ flush - or, under ``python -u``, the print() itself - raises a plain
399
+ OSError with errno 22 (EINVAL) and no winerror (measured on Windows 11
400
+ with Python 3.11). EINVAL is only taken for a closed pipe there: on any
401
+ other system it is a real error and must not be swallowed. And only the
402
+ plain kind: it must carry no file name (EnvFileRefused is an OSError
403
+ with errno EINVAL too, as can be any failed file operation on a path)
404
+ and no winerror (a failed Win32 call such as starting a program), while
405
+ an error from writing to a pipe has neither."""
406
+ if not isinstance(exc, OSError):
407
+ return False
408
+ if isinstance(exc, BrokenPipeError) or exc.errno == errno.EPIPE:
409
+ return True
410
+ return (_ON_WINDOWS and exc.errno == errno.EINVAL
411
+ and exc.filename is None
412
+ and getattr(exc, "winerror", None) is None)
413
+
414
+
415
+ def _after_broken_pipe() -> int:
416
+ """The reader of our output went away (``safeeasyapi list | head -1``).
417
+ That is not worth a traceback: stop quietly. Python flushes stdout again
418
+ when it exits and would fail a second time ("Exception ignored in ...",
419
+ exit status 120), so fd 1 is pointed at the null device first - the
420
+ recipe from the Python documentation; measured on Windows 11, it makes
421
+ that last flush succeed there too. The status is 1, as Python itself
422
+ uses for EPIPE."""
423
+ try:
424
+ devnull = os.open(os.devnull, os.O_WRONLY)
425
+ try:
426
+ os.dup2(devnull, sys.stdout.fileno())
427
+ finally:
428
+ os.close(devnull)
429
+ except (OSError, ValueError, AttributeError):
430
+ pass # stdout has no real descriptor (a test's StringIO) or is gone
431
+ return 1
432
+
433
+
434
+ def _flush_stdout() -> None:
435
+ flush = getattr(sys.stdout, "flush", None)
436
+ if flush is not None:
437
+ flush()
438
+
439
+
440
+ def _interrupted() -> int:
441
+ """Ctrl-C: say so in a line, not a traceback, and use the status a shell
442
+ reports for a program SIGINT stopped (128 + 2)."""
443
+ try:
444
+ sys.stderr.write("\ninterrupted.\n")
445
+ sys.stderr.flush()
446
+ except (OSError, ValueError):
447
+ pass # nobody is reading stderr either
448
+ return 130
449
+
450
+
451
+ def main(argv: list[str] | None = None) -> int:
452
+ try:
453
+ try:
454
+ code = _main(argv)
455
+ except SystemExit:
456
+ # argparse prints --version / --help and exits by exception; what
457
+ # it printed is still in the buffer
458
+ _flush_stdout()
459
+ raise
460
+ # A pipe that was closed on us shows up when the buffer is flushed -
461
+ # here, where it can be handled, not in the interpreter's own exit.
462
+ _flush_stdout()
463
+ return code
464
+ except OSError as e:
465
+ # Any command, and the MCP loop, end up here: BrokenPipeError on
466
+ # POSIX, a plain OSError(EINVAL) on Windows. Anything else is a real
467
+ # error and goes on its way.
468
+ if is_closed_pipe(e):
469
+ return _after_broken_pipe()
470
+ raise
471
+ except KeyboardInterrupt:
472
+ # Any command, the MCP loop and `run` end up here.
473
+ return _interrupted()
474
+
475
+
476
+ def _main(argv: list[str] | None = None) -> int:
477
+ _harden_streams()
478
+ parser = _build_parser()
479
+ args = parser.parse_args(argv)
480
+ catalog = recipes_mod.load_recipes()
481
+
482
+ if args.cmd == "list":
483
+ return _cmd_list(catalog)
484
+ if args.cmd == "find":
485
+ return _cmd_find(args)
486
+ if args.cmd == "add":
487
+ return _cmd_add(args.recipes_dir)
488
+ if args.cmd == "mcp":
489
+ return mcp_mod.serve(catalog)
490
+ if args.cmd in ("connect", "rotate", "run", "forget"):
491
+ recipe = catalog.get(args.service)
492
+ if not recipe:
493
+ print(f"unknown service '{args.service}'.")
494
+ _cmd_list(catalog)
495
+ return 1
496
+ if args.cmd == "run":
497
+ return _cmd_run(recipe, args.command)
498
+ if args.cmd == "forget":
499
+ # one pass per stored value: an App ID and its secret are both removed
500
+ return max(_cmd_forget(dict(recipe, env_var=var), args.env_file)
501
+ for var in recipes_mod.env_vars(recipe))
502
+ import webbrowser
503
+ opener = (lambda url: None) if args.no_browser else webbrowser.open
504
+ store_fn = None
505
+ if args.store == "keychain":
506
+ store_fn = keychain_store(recipe, args.env_file)
507
+ if args.cmd == "rotate":
508
+ print(f"\n rotating "
509
+ f"{recipes_mod.printable(recipe['display'], words=True)} - "
510
+ f"the stored key will be replaced.\n")
511
+ clip = None
512
+ if args.clipboard:
513
+ from . import clipboard as clipboard_mod
514
+ try:
515
+ clip = clipboard_mod.backend()
516
+ except clipboard_mod.Unavailable as e:
517
+ print(f" (copy-and-go unavailable: {recipes_mod.printable(e)} - "
518
+ f"using the hidden paste instead)")
519
+ return connect_mod.connect(recipe, env_file=args.env_file,
520
+ browser_open=opener, store_fn=store_fn,
521
+ clipboard=clip, via_cli=args.via_cli)
522
+ return 0
523
+
524
+
525
+ if __name__ == "__main__":
526
+ sys.exit(main())