banto 5.2.2__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 (98) hide show
  1. banto/__init__.py +33 -0
  2. banto/__main__.py +560 -0
  3. banto/backend.py +45 -0
  4. banto/broker.py +314 -0
  5. banto/broker_cli.py +40 -0
  6. banto/broker_client.py +69 -0
  7. banto/broker_mcp.py +54 -0
  8. banto/broker_setup.py +121 -0
  9. banto/chatgpt.py +186 -0
  10. banto/config.json +43 -0
  11. banto/ecdsa_p256.py +127 -0
  12. banto/guard.py +887 -0
  13. banto/keychain.py +260 -0
  14. banto/kimaru_evaluation.py +366 -0
  15. banto/kimaru_material.py +278 -0
  16. banto/lease.py +292 -0
  17. banto/lease_cli.py +150 -0
  18. banto/mcp_server.py +764 -0
  19. banto/pricing.json +75 -0
  20. banto/profiles.py +147 -0
  21. banto/register_popup.py +1383 -0
  22. banto/rotation_capabilities.json +361 -0
  23. banto/sealed_cli.py +70 -0
  24. banto/sealed_signer.py +468 -0
  25. banto/sync/__init__.py +13 -0
  26. banto/sync/audit.py +46 -0
  27. banto/sync/browser_batch.py +320 -0
  28. banto/sync/browser_issuer.py +831 -0
  29. banto/sync/browser_recorder.py +472 -0
  30. banto/sync/capabilities.py +173 -0
  31. banto/sync/cli.py +4076 -0
  32. banto/sync/cloudflare_token_rotator.py +397 -0
  33. banto/sync/config.py +247 -0
  34. banto/sync/drivers/__init__.py +97 -0
  35. banto/sync/drivers/alibaba.py +114 -0
  36. banto/sync/drivers/aws_sm.py +113 -0
  37. banto/sync/drivers/aws_ssm.py +96 -0
  38. banto/sync/drivers/azure.py +81 -0
  39. banto/sync/drivers/azure_devops.py +100 -0
  40. banto/sync/drivers/base.py +29 -0
  41. banto/sync/drivers/bitbucket.py +132 -0
  42. banto/sync/drivers/circleci.py +99 -0
  43. banto/sync/drivers/cloudflare.py +61 -0
  44. banto/sync/drivers/deno.py +73 -0
  45. banto/sync/drivers/digitalocean.py +75 -0
  46. banto/sync/drivers/docker.py +66 -0
  47. banto/sync/drivers/flyio.py +66 -0
  48. banto/sync/drivers/gcp.py +81 -0
  49. banto/sync/drivers/github.py +57 -0
  50. banto/sync/drivers/gitlab.py +84 -0
  51. banto/sync/drivers/hasura.py +127 -0
  52. banto/sync/drivers/heroku.py +61 -0
  53. banto/sync/drivers/huawei.py +109 -0
  54. banto/sync/drivers/jdcloud.py +95 -0
  55. banto/sync/drivers/kubernetes.py +105 -0
  56. banto/sync/drivers/laravel_forge.py +141 -0
  57. banto/sync/drivers/local.py +130 -0
  58. banto/sync/drivers/naver.py +106 -0
  59. banto/sync/drivers/netlify.py +68 -0
  60. banto/sync/drivers/nhn.py +122 -0
  61. banto/sync/drivers/railway.py +72 -0
  62. banto/sync/drivers/render.py +172 -0
  63. banto/sync/drivers/sakura.py +117 -0
  64. banto/sync/drivers/supabase.py +76 -0
  65. banto/sync/drivers/tencent.py +109 -0
  66. banto/sync/drivers/terraform.py +137 -0
  67. banto/sync/drivers/vercel.py +164 -0
  68. banto/sync/drivers/volcengine.py +97 -0
  69. banto/sync/google_rotator.py +637 -0
  70. banto/sync/history.py +208 -0
  71. banto/sync/incident_report.py +159 -0
  72. banto/sync/notifiers/__init__.py +34 -0
  73. banto/sync/notifiers/base.py +43 -0
  74. banto/sync/notifiers/datadog.py +66 -0
  75. banto/sync/notifiers/pagerduty.py +73 -0
  76. banto/sync/notifiers/slack.py +57 -0
  77. banto/sync/notifiers/teams.py +59 -0
  78. banto/sync/openai_rotator.py +526 -0
  79. banto/sync/propagation.py +269 -0
  80. banto/sync/setup.py +239 -0
  81. banto/sync/smoke_presets.py +153 -0
  82. banto/sync/stripe_webhook_rotator.py +516 -0
  83. banto/sync/sync.py +277 -0
  84. banto/sync/sync_state.py +116 -0
  85. banto/sync/validate.py +337 -0
  86. banto/sync/vercel_inventory.py +185 -0
  87. banto/sync/web.py +1881 -0
  88. banto/sync/x_oauth_pkce.py +294 -0
  89. banto/sync/x_oauth_rotator.py +284 -0
  90. banto/sync/xai_rotator.py +542 -0
  91. banto/vault.py +364 -0
  92. banto/widgets.py +566 -0
  93. banto-5.2.2.dist-info/METADATA +713 -0
  94. banto-5.2.2.dist-info/RECORD +98 -0
  95. banto-5.2.2.dist-info/WHEEL +5 -0
  96. banto-5.2.2.dist-info/entry_points.txt +3 -0
  97. banto-5.2.2.dist-info/licenses/LICENSE +93 -0
  98. banto-5.2.2.dist-info/top_level.txt +1 -0
banto/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ # Copyright 2025-2026 AllNew LLC
2
+ # Licensed under LicenseRef-Dual (see LICENSE)
3
+ """
4
+ banto - Local-first secret management for developers.
5
+
6
+ Named after the bantō (番頭), the head clerk of Edo-period merchant houses
7
+ who held the keys to the storehouse and managed the books.
8
+
9
+ Core: secure key storage via macOS Keychain with multi-platform sync.
10
+ Optional modules:
11
+ - budget: LLM cost gating with hold/settle pattern
12
+ - lease: dynamic secrets with TTL and auto-revocation
13
+
14
+ Default backend: macOS Keychain. Custom backends (1Password, env vars, etc.)
15
+ can be plugged in via the SecretBackend protocol.
16
+ """
17
+
18
+ from .backend import SecretBackend
19
+ from .guard import CostGuard, BudgetExceededError
20
+ from .keychain import KeychainStore, KeyNotFoundError
21
+ from .profiles import ProfileManager
22
+ from .vault import SecureVault
23
+
24
+ __all__ = [
25
+ "SecureVault",
26
+ "SecretBackend",
27
+ "CostGuard",
28
+ "BudgetExceededError",
29
+ "KeychainStore",
30
+ "KeyNotFoundError",
31
+ "ProfileManager",
32
+ ]
33
+ __version__ = "5.2.2"
banto/__main__.py ADDED
@@ -0,0 +1,560 @@
1
+ # Copyright 2025-2026 AllNew LLC
2
+ # Licensed under LicenseRef-Dual (see LICENSE)
3
+ """
4
+ CLI entry point: python -m banto <command>
5
+
6
+ Commands:
7
+ status Show budget status
8
+ store <provider> Store an API key in Keychain
9
+ register [provider] Open browser popup to store an API key
10
+ register-asc Open App Store Connect credential registration popup
11
+ list List stored provider keys
12
+ check <model> ... Dry-run budget check for a model
13
+ init Copy default config to ~/.config/banto/
14
+ """
15
+
16
+ import getpass
17
+ import json
18
+ import shutil
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from .guard import CostGuard, CONFIG_DIR
23
+
24
+
25
+ def _is_json(args: list[str]) -> bool:
26
+ """Check if --json flag is present."""
27
+ return "--json" in args
28
+
29
+
30
+ def _json_out(data: dict | list) -> None:
31
+ """Print JSON to stdout and exit."""
32
+ print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
33
+ from .keychain import KeychainStore, _validate_provider
34
+ from .sync.config import SyncConfig
35
+ from .vault import SecureVault
36
+
37
+
38
+ def _validate_cli_bounds(
39
+ *,
40
+ tokens: int | None = None,
41
+ seconds: int | None = None,
42
+ n: int | None = None,
43
+ budget: float | None = None,
44
+ label: str = "",
45
+ ) -> None:
46
+ """Validate CLI numeric arguments are within reasonable bounds."""
47
+ if tokens is not None and not (0 <= tokens <= 100_000_000):
48
+ print(
49
+ f"Error: {label}tokens must be between 0 and 100,000,000, got {tokens}",
50
+ file=sys.stderr,
51
+ )
52
+ sys.exit(1)
53
+ if seconds is not None and not (0 <= seconds <= 86400):
54
+ print(
55
+ f"Error: seconds must be between 0 and 86,400, got {seconds}",
56
+ file=sys.stderr,
57
+ )
58
+ sys.exit(1)
59
+ if n is not None and not (1 <= n <= 10_000):
60
+ print(
61
+ f"Error: n must be between 1 and 10,000, got {n}",
62
+ file=sys.stderr,
63
+ )
64
+ sys.exit(1)
65
+ if budget is not None and not (0 <= budget <= 1_000_000):
66
+ print(
67
+ f"Error: budget amount must be between 0 and 1,000,000, got {budget}",
68
+ file=sys.stderr,
69
+ )
70
+ sys.exit(1)
71
+
72
+
73
+ _PROFILE_DESCRIPTIONS = {
74
+ "quality": "Use premium models for best results",
75
+ "balanced": "Mix of quality and cost efficiency",
76
+ "budget": "Prioritize cost savings",
77
+ }
78
+
79
+
80
+ def cmd_status(args: list[str]) -> None:
81
+ vault = SecureVault(caller="cli")
82
+ if not vault.budget_enabled:
83
+ if _is_json(args):
84
+ _json_out({"budget_enabled": False})
85
+ return
86
+ print("Budget: disabled (no monthly_limit_usd in config)")
87
+ print("\nEnable with: banto budget 100")
88
+ return
89
+ guard = vault._guard
90
+ assert guard is not None
91
+ if _is_json(args):
92
+ s = guard.get_remaining_budget()
93
+ s["recommended_profile"] = guard.recommend_profile()
94
+ _json_out(s)
95
+ return
96
+ s = guard.get_remaining_budget()
97
+ print(f"Month: {s['month']}")
98
+ print(f"Used: ${s['used_usd']:.2f}")
99
+ print(f"Remaining: ${s['remaining_usd']:.2f}")
100
+ print(f"Limit: ${s['monthly_limit_usd']:.2f}")
101
+ print(f"Entries: {s['entry_count']}")
102
+
103
+ # Voided timeout holds
104
+ voided_count = s.get("voided_timeout_count", 0)
105
+ if voided_count > 0:
106
+ voided_usd = s.get("voided_timeout_usd", 0)
107
+ print(f"Stale holds voided: {voided_count} (${voided_usd:.2f} released)")
108
+
109
+ # Recommended profile
110
+ profile = guard.recommend_profile()
111
+ limit = s["monthly_limit_usd"]
112
+ if limit > 0:
113
+ remaining_pct = int((limit - s["used_usd"]) / limit * 100)
114
+ print(f"\nRecommended profile: {profile} "
115
+ f"({remaining_pct}% budget remaining)")
116
+ else:
117
+ print(f"\nRecommended profile: {profile} (no limit set)")
118
+ print(f" -> {_PROFILE_DESCRIPTIONS[profile]}")
119
+
120
+ # Provider breakdown
121
+ by_provider = s.get("by_provider", {})
122
+ if by_provider:
123
+ print("\nBy provider:")
124
+ for p, info in sorted(by_provider.items()):
125
+ used = info["used_usd"]
126
+ limit = info["limit_usd"]
127
+ if limit is not None:
128
+ print(f" {p:20s} ${used:.2f} / ${limit:.2f} (${info['remaining_usd']:.2f} remaining)")
129
+ else:
130
+ print(f" {p:20s} ${used:.2f}")
131
+
132
+ # Model breakdown
133
+ by_model = s.get("by_model", {})
134
+ if by_model:
135
+ print("\nBy model:")
136
+ for m, info in sorted(by_model.items()):
137
+ used = info["used_usd"]
138
+ limit = info["limit_usd"]
139
+ if limit is not None:
140
+ print(f" {m:35s} ${used:.2f} / ${limit:.2f} (${info['remaining_usd']:.2f} remaining)")
141
+ else:
142
+ print(f" {m:35s} ${used:.2f}")
143
+
144
+
145
+ def cmd_store(args: list[str]) -> None:
146
+ if not args:
147
+ print("Usage: banto store <provider>")
148
+ print("Example: banto store openai")
149
+ sys.exit(1)
150
+
151
+ try:
152
+ provider = _validate_provider(args[0])
153
+ except ValueError as e:
154
+ print(str(e), file=sys.stderr)
155
+ sys.exit(1)
156
+ config = SyncConfig.load()
157
+ keychain = KeychainStore(service_prefix=config.keychain_service)
158
+
159
+ if keychain.exists(provider):
160
+ overwrite = input(
161
+ f"Key for '{provider}' already exists. Overwrite? (y/N): "
162
+ )
163
+ if overwrite.strip().lower() != "y":
164
+ print("Cancelled.")
165
+ return
166
+
167
+ api_key = getpass.getpass(f"Enter API key for '{provider}': ")
168
+ if not api_key:
169
+ print("Empty input. Cancelled.")
170
+ return
171
+
172
+ if keychain.store(provider, api_key):
173
+ print(f"Stored '{provider}' in Keychain.")
174
+ else:
175
+ print(f"Failed to store '{provider}'.", file=sys.stderr)
176
+ sys.exit(1)
177
+
178
+
179
+ def cmd_delete(args: list[str]) -> None:
180
+ if not args:
181
+ print("Usage: banto delete <provider>")
182
+ sys.exit(1)
183
+
184
+ try:
185
+ provider = _validate_provider(args[0])
186
+ except ValueError as e:
187
+ print(str(e), file=sys.stderr)
188
+ sys.exit(1)
189
+ config = SyncConfig.load()
190
+ keychain = KeychainStore(service_prefix=config.keychain_service)
191
+
192
+ if not keychain.exists(provider):
193
+ print(f"No key found for '{provider}'.")
194
+ return
195
+
196
+ confirm = input(f"Delete key for '{provider}'? (y/N): ")
197
+ if confirm.strip().lower() != "y":
198
+ print("Cancelled.")
199
+ return
200
+
201
+ if keychain.delete(provider):
202
+ print(f"Deleted '{provider}' from Keychain.")
203
+ else:
204
+ print(f"Failed to delete '{provider}'.", file=sys.stderr)
205
+ sys.exit(1)
206
+
207
+
208
+ def cmd_list(args: list[str]) -> None:
209
+ vault = SecureVault(caller="cli")
210
+ providers = vault.list_providers()
211
+ budget = vault.get_budget_status()
212
+
213
+ if _is_json(args):
214
+ _json_out({"providers": providers, "budget": budget})
215
+ return
216
+
217
+ if budget.get("budget_enabled", True):
218
+ print(f"\nBudget: ${budget['used_usd']:.2f} / ${budget['monthly_limit_usd']:.2f} "
219
+ f"(${budget['remaining_usd']:.2f} remaining)\n")
220
+ else:
221
+ print("\nBudget: disabled\n")
222
+
223
+ if providers:
224
+ print("Stored keys:")
225
+ for p in providers:
226
+ print(f" + {p}")
227
+ else:
228
+ print("No keys stored. Use 'banto store <provider>' to add one.")
229
+ print()
230
+
231
+
232
+ def cmd_check(args: list[str]) -> None:
233
+ if not args:
234
+ print("Usage: banto check <model> [--tokens <in> <out>] [--n <count>]")
235
+ sys.exit(1)
236
+
237
+ model = args[0]
238
+ kwargs: dict = {}
239
+
240
+ i = 1
241
+ while i < len(args):
242
+ if args[i] == "--tokens" and i + 2 < len(args):
243
+ in_tok = int(args[i + 1])
244
+ out_tok = int(args[i + 2])
245
+ _validate_cli_bounds(tokens=in_tok, label="input_")
246
+ _validate_cli_bounds(tokens=out_tok, label="output_")
247
+ kwargs["input_tokens"] = in_tok
248
+ kwargs["output_tokens"] = out_tok
249
+ i += 3
250
+ elif args[i] == "--n" and i + 1 < len(args):
251
+ n_val = int(args[i + 1])
252
+ _validate_cli_bounds(n=n_val)
253
+ kwargs["n"] = n_val
254
+ i += 2
255
+ elif args[i] == "--seconds" and i + 1 < len(args):
256
+ sec_val = int(args[i + 1])
257
+ _validate_cli_bounds(seconds=sec_val)
258
+ kwargs["seconds"] = sec_val
259
+ i += 2
260
+ elif args[i] == "--quality" and i + 1 < len(args):
261
+ kwargs["quality"] = args[i + 1]
262
+ i += 2
263
+ elif args[i] == "--size" and i + 1 < len(args):
264
+ kwargs["size"] = args[i + 1]
265
+ i += 2
266
+ elif args[i] == "--json":
267
+ i += 1
268
+ else:
269
+ print(f"Unknown option: {args[i]}", file=sys.stderr)
270
+ sys.exit(1)
271
+
272
+ guard = CostGuard(caller="cli")
273
+ cost = guard.estimate_cost(model=model, **kwargs)
274
+ budget = guard.get_remaining_budget()
275
+ allowed = cost <= budget["remaining_usd"]
276
+
277
+ if _is_json(args):
278
+ _json_out({"model": model, "estimated_usd": cost,
279
+ "remaining_usd": budget["remaining_usd"], "allowed": allowed})
280
+ return
281
+
282
+ print(f"Model: {model}")
283
+ print(f"Estimated: ${cost:.4f}")
284
+ print(f"Remaining: ${budget['remaining_usd']:.2f}")
285
+ if cost <= budget["remaining_usd"]:
286
+ print("Result: ALLOWED")
287
+ else:
288
+ print("Result: BLOCKED (over budget)")
289
+
290
+
291
+ def cmd_profile(args: list[str]) -> None:
292
+ """Show or set the active model profile."""
293
+ vault = SecureVault(caller="cli")
294
+ profiles = vault.get_profiles()
295
+
296
+ if _is_json(args):
297
+ _json_out(profiles)
298
+ return
299
+
300
+ if not args or (len(args) == 1 and args[0] == "--json"):
301
+ # Show all profiles
302
+ print("Model profiles:\n")
303
+ for name, info in profiles.items():
304
+ marker = " *" if info["active"] else " "
305
+ desc = _PROFILE_DESCRIPTIONS.get(name, "")
306
+ print(f" {marker} {name:12s} {desc}")
307
+ for role, model in info["models"].items():
308
+ print(f" {role:8s} -> {model}")
309
+ print()
310
+ return
311
+
312
+ profile_name = args[0]
313
+ try:
314
+ vault.set_profile(profile_name)
315
+ print(f"Active profile set to '{profile_name}'.")
316
+ desc = _PROFILE_DESCRIPTIONS.get(profile_name, "")
317
+ if desc:
318
+ print(f" -> {desc}")
319
+ except ValueError as e:
320
+ print(str(e), file=sys.stderr)
321
+ sys.exit(1)
322
+
323
+
324
+ def cmd_budget(args: list[str]) -> None:
325
+ guard = CostGuard(caller="cli")
326
+
327
+ if not args:
328
+ # Show current budget settings
329
+ s = guard.get_remaining_budget()
330
+ print(f"Global: ${s['monthly_limit_usd']:.2f}")
331
+ if s["provider_limits"]:
332
+ print("\nProvider limits:")
333
+ for p, limit in sorted(s["provider_limits"].items()):
334
+ print(f" {p:20s} ${limit:.2f}")
335
+ if s["model_limits"]:
336
+ print("\nModel limits:")
337
+ for m, limit in sorted(s["model_limits"].items()):
338
+ print(f" {m:35s} ${limit:.2f}")
339
+ if not s["provider_limits"] and not s["model_limits"]:
340
+ print("\nNo provider or model limits set.")
341
+ print("Use 'banto budget --provider openai 30' or 'banto budget --model dall-e-3 10'")
342
+ return
343
+
344
+ # Parse: banto budget <amount>
345
+ # banto budget --provider <name> <amount>
346
+ # banto budget --model <name> <amount>
347
+ # banto budget --provider <name> --remove
348
+ # banto budget --model <name> --remove
349
+ i = 0
350
+ global_limit: float | None = None
351
+ provider: str | None = None
352
+ provider_limit: float | None = None
353
+ model: str | None = None
354
+ model_limit: float | None = None
355
+ remove = False
356
+
357
+ while i < len(args):
358
+ if args[i] == "--provider" and i + 1 < len(args):
359
+ provider = args[i + 1]
360
+ i += 2
361
+ elif args[i] == "--model" and i + 1 < len(args):
362
+ model = args[i + 1]
363
+ i += 2
364
+ elif args[i] == "--remove":
365
+ remove = True
366
+ i += 1
367
+ else:
368
+ try:
369
+ amount = float(args[i])
370
+ _validate_cli_bounds(budget=amount)
371
+ if provider is not None:
372
+ provider_limit = amount
373
+ elif model is not None:
374
+ model_limit = amount
375
+ else:
376
+ global_limit = amount
377
+ i += 1
378
+ except ValueError:
379
+ print(f"Invalid argument: {args[i]}", file=sys.stderr)
380
+ sys.exit(1)
381
+
382
+ if remove:
383
+ if provider:
384
+ provider_limit = 0.0 # 0 = remove
385
+ if model:
386
+ model_limit = 0.0
387
+
388
+ if global_limit is None and provider is None and model is None:
389
+ print("Usage: banto budget <amount>", file=sys.stderr)
390
+ print(" banto budget --provider <name> <amount>", file=sys.stderr)
391
+ print(" banto budget --model <name> <amount>", file=sys.stderr)
392
+ print(" banto budget --provider <name> --remove", file=sys.stderr)
393
+ sys.exit(1)
394
+
395
+ guard.set_budget(
396
+ global_limit=global_limit,
397
+ provider=provider,
398
+ provider_limit=provider_limit,
399
+ model=model,
400
+ model_limit=model_limit,
401
+ )
402
+
403
+ if global_limit is not None:
404
+ print(f"Global limit set to ${global_limit:.2f}")
405
+ if provider and remove:
406
+ print(f"Provider limit for '{provider}' removed.")
407
+ elif provider and provider_limit:
408
+ print(f"Provider limit for '{provider}' set to ${provider_limit:.2f}")
409
+ if model and remove:
410
+ print(f"Model limit for '{model}' removed.")
411
+ elif model and model_limit:
412
+ print(f"Model limit for '{model}' set to ${model_limit:.2f}")
413
+
414
+
415
+ def cmd_init(args: list[str]) -> None:
416
+ config_dest = CONFIG_DIR / "config.json"
417
+ pricing_dest = CONFIG_DIR / "pricing.json"
418
+
419
+ if config_dest.exists():
420
+ print(f"Config already exists: {config_dest}")
421
+ overwrite = input("Overwrite with default? (y/N): ")
422
+ if overwrite.strip().lower() != "y":
423
+ print("Cancelled.")
424
+ return
425
+
426
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
427
+
428
+ bundled = Path(__file__).parent
429
+ shutil.copy2(bundled / "config.json", config_dest)
430
+ config_dest.chmod(0o640)
431
+
432
+ shutil.copy2(bundled / "pricing.json", pricing_dest)
433
+ pricing_dest.chmod(0o640)
434
+
435
+ print(f"Config written to: {config_dest}")
436
+ print(f"Pricing written to: {pricing_dest}")
437
+ print()
438
+ print("Next steps:")
439
+ print(" 1. Set your budget: banto budget <amount>")
440
+ print(" 2. Review pricing: edit pricing.json to match current provider rates")
441
+ print(" 3. Store API keys: banto store <provider>")
442
+
443
+
444
+ def cmd_register(args: list[str]) -> None:
445
+ from .register_popup import serve_register_popup
446
+
447
+ provider_hint = args[0] if args else None
448
+ print("Opening browser for API key registration...")
449
+ url = serve_register_popup(provider_hint=provider_hint, blocking=True)
450
+ print(f"Server was at: {url}")
451
+ print("Done.")
452
+
453
+
454
+ def cmd_register_asc(args: list[str]) -> None:
455
+ from .register_popup import serve_asc_register_popup
456
+
457
+ print("Opening browser for App Store Connect credential registration...")
458
+ url = serve_asc_register_popup(blocking=True)
459
+ print(f"Server was at: {url}")
460
+ print("Done.")
461
+
462
+
463
+ def cmd_sync(args: list[str]) -> None:
464
+ from .sync.cli import cmd_sync_dispatch
465
+ cmd_sync_dispatch(args)
466
+
467
+
468
+ def cmd_lease(args: list[str]) -> None:
469
+ from .lease_cli import cmd_lease_dispatch
470
+ cmd_lease_dispatch(args)
471
+
472
+
473
+ def cmd_sealed(args: list[str]) -> None:
474
+ from .sealed_cli import cmd_sealed_dispatch
475
+ cmd_sealed_dispatch(args)
476
+
477
+
478
+ def cmd_chatgpt(args: list[str]) -> None:
479
+ if not args or args[0] in ("-h", "--help"):
480
+ print("banto chatgpt: Connect banto to ChatGPT\n")
481
+ print("Usage: banto chatgpt connect [--ngrok|--cloudflared] [--port N]")
482
+ return
483
+ if args[0] == "connect":
484
+ from .chatgpt import connect
485
+ connect(args[1:])
486
+ else:
487
+ print(f"Unknown chatgpt command: {args[0]}")
488
+
489
+
490
+ COMMANDS = {
491
+ "status": cmd_status,
492
+ "budget": cmd_budget,
493
+ "profile": cmd_profile,
494
+ "store": cmd_store,
495
+ "register": cmd_register,
496
+ "register-asc": cmd_register_asc,
497
+ "asc-register": cmd_register_asc,
498
+ "delete": cmd_delete,
499
+ "list": cmd_list,
500
+ "check": cmd_check,
501
+ "init": cmd_init,
502
+ "sync": cmd_sync,
503
+ "lease": cmd_lease,
504
+ "sealed": cmd_sealed,
505
+ "chatgpt": cmd_chatgpt,
506
+ }
507
+
508
+
509
+ def main() -> None:
510
+ if len(sys.argv) > 1 and sys.argv[1] == "broker":
511
+ from .broker import main as broker_main
512
+ sys.argv.pop(1)
513
+ broker_main()
514
+ return
515
+ if len(sys.argv) > 1:
516
+ from .broker_cli import route_if_enabled
517
+ if route_if_enabled(sys.argv[1:]):
518
+ return
519
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
520
+ print("banto: Budget-gated API key vault for LLM applications\n")
521
+ print("Usage: banto <command> [args]\n")
522
+ print("Commands:")
523
+ print(" broker <cmd> Common Keychain process: install, health, enable, call")
524
+ print(" status Show budget status (with per-provider/model breakdown)")
525
+ print(" budget [args] View or set budget limits")
526
+ print(" profile [name] Show or set the active model profile")
527
+ print(" store <provider> Store an API key in Keychain (terminal)")
528
+ print(" register [provider] Open browser popup to store an API key")
529
+ print(" delete <provider> Delete an API key from Keychain")
530
+ print(" list List stored keys and budget")
531
+ print(" check <model> ... Dry-run budget check")
532
+ print(" init Copy default config to ~/.config/banto/")
533
+ print(" sync <cmd> Multi-platform secret sync (33 platforms)")
534
+ print(" lease <cmd> Dynamic secrets with TTL and auto-revoke")
535
+ print(" sealed <cmd> Secure Enclave sealed signing (non-extractable keys)")
536
+ print(" chatgpt connect Connect banto to ChatGPT (starts tunnel)")
537
+ print()
538
+ print("Budget examples:")
539
+ print(" banto budget Show all limits")
540
+ print(" banto budget 100 Set global limit to $100")
541
+ print(" banto budget --provider openai 30 Set OpenAI limit to $30")
542
+ print(" banto budget --model dall-e-3 10 Set DALL-E 3 limit to $10")
543
+ print(" banto budget --provider openai --remove Remove provider limit")
544
+ print()
545
+ print("Profile examples:")
546
+ print(" banto profile Show all profiles")
547
+ print(" banto profile balanced Set active profile")
548
+ sys.exit(0)
549
+
550
+ cmd = sys.argv[1]
551
+ if cmd not in COMMANDS:
552
+ print(f"Unknown command: {cmd}", file=sys.stderr)
553
+ print(f"Available: {', '.join(COMMANDS)}", file=sys.stderr)
554
+ sys.exit(1)
555
+
556
+ COMMANDS[cmd](sys.argv[2:])
557
+
558
+
559
+ if __name__ == "__main__":
560
+ main()
banto/backend.py ADDED
@@ -0,0 +1,45 @@
1
+ # Copyright 2025-2026 AllNew LLC
2
+ # Licensed under LicenseRef-Dual (see LICENSE)
3
+ """
4
+ backend.py - Pluggable secret storage protocol.
5
+
6
+ Any object that implements these five methods can be used as a
7
+ secret backend for SecureVault. No inheritance required (structural typing).
8
+
9
+ Built-in backend:
10
+ KeychainStore (macOS Keychain, default)
11
+
12
+ See examples/06_custom_backend.py for 1Password, env-var, and
13
+ in-memory implementations.
14
+ """
15
+
16
+ from typing import Protocol, runtime_checkable
17
+
18
+
19
+ @runtime_checkable
20
+ class SecretBackend(Protocol):
21
+ """Protocol for secret storage backends.
22
+
23
+ Implement these methods to plug in any secret store
24
+ (1Password, HashiCorp Vault, AWS Secrets Manager, etc.).
25
+ """
26
+
27
+ def get(self, provider: str) -> str | None:
28
+ """Retrieve an API key. Returns None if not found."""
29
+ ...
30
+
31
+ def store(self, provider: str, api_key: str) -> bool:
32
+ """Store an API key. Returns True on success."""
33
+ ...
34
+
35
+ def delete(self, provider: str) -> bool:
36
+ """Delete an API key. Returns True on success."""
37
+ ...
38
+
39
+ def exists(self, provider: str) -> bool:
40
+ """Check if an API key exists for the given provider."""
41
+ ...
42
+
43
+ def list_providers(self, known_providers: list[str]) -> list[str]:
44
+ """Return which of the known providers have stored keys."""
45
+ ...