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.
- safeeasyapi/__init__.py +3 -0
- safeeasyapi/__main__.py +6 -0
- safeeasyapi/cli.py +526 -0
- safeeasyapi/clipboard.py +154 -0
- safeeasyapi/connect.py +566 -0
- safeeasyapi/keychain.py +365 -0
- safeeasyapi/mcp.py +412 -0
- safeeasyapi/publicapis.py +170 -0
- safeeasyapi/recipes/airtable.json +24 -0
- safeeasyapi/recipes/anthropic.json +31 -0
- safeeasyapi/recipes/assemblyai.json +25 -0
- safeeasyapi/recipes/aws.json +1 -0
- safeeasyapi/recipes/brave-search.json +33 -0
- safeeasyapi/recipes/cloudflare.json +29 -0
- safeeasyapi/recipes/cohere.json +24 -0
- safeeasyapi/recipes/deepgram.json +25 -0
- safeeasyapi/recipes/discord.json +25 -0
- safeeasyapi/recipes/ebay.json +1 -0
- safeeasyapi/recipes/elevenlabs.json +25 -0
- safeeasyapi/recipes/github.json +1 -0
- safeeasyapi/recipes/google-gemini.json +32 -0
- safeeasyapi/recipes/groq.json +24 -0
- safeeasyapi/recipes/huggingface.json +24 -0
- safeeasyapi/recipes/mailgun.json +28 -0
- safeeasyapi/recipes/mistral.json +24 -0
- safeeasyapi/recipes/netlify.json +24 -0
- safeeasyapi/recipes/notion.json +30 -0
- safeeasyapi/recipes/openai.json +24 -0
- safeeasyapi/recipes/openrouter.json +25 -0
- safeeasyapi/recipes/openweather.json +1 -0
- safeeasyapi/recipes/paypal.json +1 -0
- safeeasyapi/recipes/pinecone.json +27 -0
- safeeasyapi/recipes/postmark.json +27 -0
- safeeasyapi/recipes/replicate.json +23 -0
- safeeasyapi/recipes/resend.json +30 -0
- safeeasyapi/recipes/sendgrid.json +24 -0
- safeeasyapi/recipes/spotify.json +1 -0
- safeeasyapi/recipes/stripe.json +1 -0
- safeeasyapi/recipes/supabase.json +25 -0
- safeeasyapi/recipes/tavily.json +23 -0
- safeeasyapi/recipes/together.json +23 -0
- safeeasyapi/recipes/twilio.json +1 -0
- safeeasyapi/recipes/vercel.json +23 -0
- safeeasyapi/recipes.py +539 -0
- safeeasyapi/store.py +686 -0
- safeeasyapi/validate.py +437 -0
- safeeasyapi-0.4.1.dist-info/METADATA +666 -0
- safeeasyapi-0.4.1.dist-info/RECORD +52 -0
- safeeasyapi-0.4.1.dist-info/WHEEL +5 -0
- safeeasyapi-0.4.1.dist-info/entry_points.txt +2 -0
- safeeasyapi-0.4.1.dist-info/licenses/LICENSE +21 -0
- safeeasyapi-0.4.1.dist-info/top_level.txt +1 -0
safeeasyapi/__init__.py
ADDED
safeeasyapi/__main__.py
ADDED
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())
|