pinecall-runtime 0.1.6__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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,626 @@
1
+ """The operator's verbs over the runtime's doors: init, orgs, keys, routes, the fleet, the fence."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import contextlib
6
+ import json
7
+ import sys
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from urllib.parse import urlsplit
11
+
12
+ import httpx
13
+
14
+ from pinecall.domain.errors import GatewayRefused, PinecallError
15
+ from pinecall.domain.names import Json, JsonObject, parse_env
16
+ from pinecall.domain.org import DEFAULT_ORG, QUOTAS
17
+ from pinecall.domain.person import ROLES, THE_FLEET, THE_RUNNER
18
+ from pinecall.fleet import roster
19
+ from pinecall.postgres.pool import open_pool
20
+ from pinecall.process.settings import Settings
21
+ from pinecall.tenancy import keys as key_table
22
+ from pinecall.wire.rest.fleet import WorkerStatus
23
+
24
+ type Verb = Callable[[httpx.Client, argparse.Namespace], int]
25
+
26
+
27
+ # The ops doors read tables; five seconds means the database is down.
28
+ TIMEOUT_S = 5.0
29
+
30
+
31
+ ORGS = "/v1/ops/orgs"
32
+
33
+
34
+ NO_OPS_KEY = (
35
+ "PINECALL_OPS_KEY is not set: the operator API is closed, and these verbs speak nothing else"
36
+ )
37
+
38
+
39
+ ALREADY = "is taken"
40
+
41
+
42
+ THE_FIRST_ROLE = "admin"
43
+
44
+
45
+ WHAT_TO_DO_NEXT = """
46
+ Open the link above to set a password. Then, in the directory of an agent:
47
+
48
+ pinecall login {url}
49
+ pinecall link
50
+ pinecall start
51
+ """
52
+
53
+
54
+ NO_BODY = 204
55
+
56
+
57
+ A_FLEET_KEY = "the {env} fleet"
58
+
59
+
60
+ A_RUNNER_KEY = "the {env} runner"
61
+
62
+
63
+ def init_group(first: argparse.ArgumentParser) -> None:
64
+ """`init`: the first org and the first person, on a fresh box."""
65
+ first.add_argument("--org", default=DEFAULT_ORG, metavar="<slug>")
66
+ first.add_argument("--email", required=True)
67
+ first.add_argument("--person", required=True, metavar="<name>")
68
+ first.add_argument("--name", default=None, help="what to call the org (default: the slug)")
69
+ first.add_argument("--role", default=THE_FIRST_ROLE, choices=sorted(ROLES))
70
+ first.set_defaults(run=_knocking(init))
71
+
72
+
73
+ def orgs_group(group: argparse.ArgumentParser) -> None:
74
+ """`orgs`: the tenants, each verb bound to the function that runs it."""
75
+ under = group.add_subparsers(required=True)
76
+ under.add_parser("list", help="every org").set_defaults(run=_knocking(orgs_list))
77
+ add = under.add_parser("add", help="a new org")
78
+ add.add_argument("slug")
79
+ add.add_argument("--name", default=None)
80
+ add.set_defaults(run=_knocking(orgs_add))
81
+ invite = under.add_parser("invite", help="a person into an org, the link once")
82
+ invite.add_argument("org")
83
+ invite.add_argument("email")
84
+ invite.add_argument("--name", required=True)
85
+ invite.add_argument("--role", default=THE_FIRST_ROLE, choices=sorted(ROLES))
86
+ invite.set_defaults(run=_knocking(orgs_invite))
87
+ runs = under.add_parser("operator", help="a member made an operator of this box")
88
+ runs.add_argument("org")
89
+ runs.add_argument("email")
90
+ runs.add_argument("--revoke", action="store_true")
91
+ runs.set_defaults(run=_knocking(orgs_operator))
92
+ out = under.add_parser("remove-member", help="a person out of the org for good")
93
+ out.add_argument("org")
94
+ out.add_argument("email")
95
+ out.set_defaults(run=_knocking(orgs_remove_member))
96
+ move = under.add_parser("move", help="an agent's logs and numbers into an org")
97
+ move.add_argument("agent")
98
+ move.add_argument("org")
99
+ move.set_defaults(run=_knocking(orgs_move))
100
+ gone = under.add_parser("rm", help="an org forgotten")
101
+ gone.add_argument("org")
102
+ gone.set_defaults(run=_knocking(orgs_rm))
103
+ _limits_verbs(
104
+ under.add_parser("quota", help="the org's limits in one world, replaced whole"),
105
+ under.add_parser("dialling", help="the org's dial guards, replaced whole"),
106
+ under.add_parser("sso", help="the org's identity provider; --off is the break-glass"),
107
+ under.add_parser("provider-key", help="the org's own vendor keys"),
108
+ )
109
+
110
+
111
+ # The fleet key is minted on the database, never over a door: the box's own units get theirs
112
+ # before any gateway answers.
113
+ def keys_group(group: argparse.ArgumentParser) -> None:
114
+ """`keys`: an org's keys minted, listed and revoked, and a world's fleet key."""
115
+ keys = group.add_subparsers(required=True)
116
+ issue = keys.add_parser("issue", help="a key of an org, printed once")
117
+ issue.add_argument("--org", default=DEFAULT_ORG)
118
+ issue.add_argument("--env", required=True, choices=("production", "sandbox"))
119
+ issue.add_argument("--label", default=None)
120
+ issue.add_argument("--scope", action="append", default=None)
121
+ issue.add_argument("--subject", default=None)
122
+ issue.add_argument("--name", default=None)
123
+ issue.set_defaults(run=_knocking(keys_issue))
124
+ listing = keys.add_parser("list", help="the org's keys by fingerprint")
125
+ listing.add_argument("--org", default=DEFAULT_ORG)
126
+ listing.set_defaults(run=_knocking(keys_list))
127
+ revoke = keys.add_parser("revoke", help="one key stopped")
128
+ revoke.add_argument("fingerprint")
129
+ revoke.set_defaults(run=_knocking(keys_revoke))
130
+ fleet = keys.add_parser("fleet", help="mint a world's fleet key, printed once")
131
+ fleet.add_argument("env", choices=("production", "sandbox"))
132
+ fleet.set_defaults(run=fleet_key)
133
+ runner = keys.add_parser("runner", help="mint a world's runner key, printed once")
134
+ runner.add_argument("env", choices=("production", "sandbox"))
135
+ runner.set_defaults(run=runner_key)
136
+
137
+
138
+ def routes_group(group: argparse.ArgumentParser) -> None:
139
+ """`routes`: which agent answers a number, in which world."""
140
+ under = group.add_subparsers(required=True)
141
+ listing = under.add_parser("list")
142
+ listing.add_argument("--org", default=DEFAULT_ORG)
143
+ listing.add_argument("--env", default="production", choices=("production", "sandbox"))
144
+ listing.set_defaults(run=_knocking(routes_list))
145
+ add = under.add_parser(
146
+ "add",
147
+ help="route a number the carrier already sends here; for a new one, the org's Numbers",
148
+ )
149
+ add.add_argument("number")
150
+ add.add_argument("agent")
151
+ add.add_argument("--channel", default="phone", choices=("phone", "whatsapp"))
152
+ add.add_argument("--org", default=DEFAULT_ORG)
153
+ add.add_argument("--env", default="production", choices=("production", "sandbox"))
154
+ add.set_defaults(run=_knocking(routes_add))
155
+ gone = under.add_parser("rm")
156
+ gone.add_argument("number")
157
+ gone.add_argument("--org", default=DEFAULT_ORG)
158
+ gone.set_defaults(run=_knocking(routes_rm))
159
+ seed = under.add_parser("seed", help="a file of routes, each added")
160
+ seed.add_argument("--file", default="infra/seed/routes.json")
161
+ seed.set_defaults(run=_knocking(routes_seed))
162
+
163
+
164
+ def fence_group(group: argparse.ArgumentParser) -> None:
165
+ """`fence export`: the networks the cloud's firewall admits to 5060, as Terraform reads them."""
166
+ under = group.add_subparsers(required=True)
167
+ under.add_parser("export", help="the list, as sip_sources").set_defaults(
168
+ run=_knocking(fence_export)
169
+ )
170
+
171
+
172
+ def sip_group(group: argparse.ArgumentParser) -> None:
173
+ """`sip repoint`: the carriers' trunks sent on to the worlds' SIP names, once."""
174
+ under = group.add_subparsers(required=True)
175
+ repoint = under.add_parser(
176
+ "repoint", help="every Twilio trunk at a world's name, to its SIP name"
177
+ )
178
+ repoint.add_argument(
179
+ "--from",
180
+ dest="former",
181
+ action="append",
182
+ default=[],
183
+ metavar="WORLD=NAME",
184
+ help="a name the world was reached at before, its trunks sent on too",
185
+ )
186
+ repoint.set_defaults(run=_knocking(sip_repoint))
187
+
188
+
189
+ def fleet_group(group: argparse.ArgumentParser) -> None:
190
+ """`fleet`: the workers heard from, and a cordon."""
191
+ under = group.add_subparsers(required=True)
192
+ listing = under.add_parser("list", help="the roster")
193
+ listing.add_argument("--fleet", default=None)
194
+ listing.set_defaults(run=_knocking(fleet_list))
195
+ for verb, runner in (("cordon", fleet_cordon), ("uncordon", fleet_uncordon)):
196
+ one_verb = under.add_parser(verb)
197
+ one_verb.add_argument("worker")
198
+ one_verb.set_defaults(run=_knocking(runner))
199
+
200
+
201
+ def sip_repoint(client: httpx.Client, args: argparse.Namespace) -> int:
202
+ """Each trunk the gateway sent on to a world's SIP name, a line each."""
203
+ former: dict[str, list[str]] = {}
204
+ for given in args.former:
205
+ world, _, name = str(given).partition("=")
206
+ former.setdefault(parse_env(world), []).append(name)
207
+ moved = _list(_answered(client.post("/v1/ops/sip/repoint", json={"former": former})))
208
+ for item in moved:
209
+ row = _object(item)
210
+ _line_out(f"{row['account']} {row['trunk']} {row['world']}: {row['was']} -> {row['now']}")
211
+ _line_out(f"{len(moved)} trunks sent on")
212
+ return 0
213
+
214
+
215
+ # The orgs' addresses: written to an ignored tfvars file, never committed (infra/README.md).
216
+ def fence_export(client: httpx.Client, _args: argparse.Namespace) -> int:
217
+ """Every network 5060 opens to, Twilio's first, as Terraform's `sip_sources`."""
218
+ fence = _object(_object(_answered(client.get("/v1/ops/carriers")))["fence"])
219
+ _line_out(json.dumps({"sip_sources": _list(fence["networks"])}, indent=2))
220
+ return 0
221
+
222
+
223
+ # Printed to stdout once, where the unit that mints it seals it: never to a terminal on a box.
224
+ def fleet_key(settings: Settings, args: argparse.Namespace) -> int:
225
+ """Mint the fleet key of a world, in the box's own org."""
226
+ env = parse_env(str(args.env))
227
+ issued = key_table.Issued(
228
+ org=DEFAULT_ORG, env=env, scopes=frozenset({THE_FLEET}), label=A_FLEET_KEY.format(env=env)
229
+ )
230
+ sys.stdout.write(asyncio.run(_minted(settings, issued)))
231
+ return 0
232
+
233
+
234
+ # It is told every org's hosted apps and handed their tokens and secrets: minted like the fleet's.
235
+ def runner_key(settings: Settings, args: argparse.Namespace) -> int:
236
+ """Mint the runner key of a world, in the box's own org."""
237
+ env = parse_env(str(args.env))
238
+ issued = key_table.Issued(
239
+ org=DEFAULT_ORG,
240
+ env=env,
241
+ scopes=frozenset({THE_RUNNER}),
242
+ label=A_RUNNER_KEY.format(env=env),
243
+ )
244
+ sys.stdout.write(asyncio.run(_minted(settings, issued)))
245
+ return 0
246
+
247
+
248
+ def init(client: httpx.Client, args: argparse.Namespace) -> int:
249
+ """The org made or found, its first person invited and made an operator, the link once."""
250
+ slug = str(args.org)
251
+ try:
252
+ org = _object(_answered(client.post(ORGS, json={"slug": slug, "name": args.name})))
253
+ _line_out(f"{org['id']} {org['slug']} {org['name']}")
254
+ named = str(org["id"])
255
+ except GatewayRefused as refused:
256
+ if ALREADY not in str(refused):
257
+ raise
258
+ _line_out(f"org {slug} is already there")
259
+ named = slug
260
+ body = {"email": args.email, "name": args.person, "role": args.role}
261
+ invited = _object(_answered(client.post(f"{ORGS}/{named}/members", json=body)))
262
+ member = _object(invited["member"])
263
+ _answered(
264
+ client.put(f"{ORGS}/{named}/members/{member['id']}/operator", json={"operator": True})
265
+ )
266
+ _line_out(f"{member['id']} {member['email']} {member['role']} runs this box")
267
+ card = str(invited.get("link") or "")
268
+ if card:
269
+ _line_out(f" {card}")
270
+ _line_out(WHAT_TO_DO_NEXT.format(url=_origin_of(card) or _base(client)))
271
+ return 0
272
+
273
+
274
+ def orgs_list(client: httpx.Client, _args: argparse.Namespace) -> int:
275
+ """Every org, oldest first."""
276
+ for org in _rows(_answered(client.get(ORGS))):
277
+ _line_out(f"{org['id']} {org['slug']} {org['name']}")
278
+ return 0
279
+
280
+
281
+ def orgs_add(client: httpx.Client, args: argparse.Namespace) -> int:
282
+ """A new org."""
283
+ org = _object(_answered(client.post(ORGS, json={"slug": args.slug, "name": args.name})))
284
+ _line_out(f"{org['id']} {org['slug']} {org['name']}")
285
+ return 0
286
+
287
+
288
+ def orgs_invite(client: httpx.Client, args: argparse.Namespace) -> int:
289
+ """A person invited into the org, the link printed once."""
290
+ body = {"email": args.email, "name": args.name, "role": args.role}
291
+ invited = _object(_answered(client.post(f"{ORGS}/{args.org}/members", json=body)))
292
+ member = _object(invited["member"])
293
+ _line_out(f"{member['id']} {member['email']} {member['role']} {member['status']}")
294
+ if invited.get("link"):
295
+ _line_out(f" {invited['link']}")
296
+ else:
297
+ _line_out(
298
+ " already a person on this box: seated, they sign in with the password they have"
299
+ )
300
+ return 0
301
+
302
+
303
+ def orgs_operator(client: httpx.Client, args: argparse.Namespace) -> int:
304
+ """A member of the org made an operator of the box, or taken back."""
305
+ member = _member_by_email(client, args.org, args.email)
306
+ path = f"{ORGS}/{args.org}/members/{member['id']}/operator"
307
+ changed = _object(_answered(client.put(path, json={"operator": not args.revoke})))
308
+ runs = "runs this box" if changed["operator"] else "no longer runs this box"
309
+ _line_out(f"{changed['id']} {changed['email']} {runs}")
310
+ return 0
311
+
312
+
313
+ def orgs_remove_member(client: httpx.Client, args: argparse.Namespace) -> int:
314
+ """A person out of the org for good."""
315
+ member = _member_by_email(client, args.org, args.email)
316
+ _answered(client.delete(f"{ORGS}/{args.org}/members/{member['id']}"))
317
+ _line_out(f"{member['email']} is out of {args.org}")
318
+ return 0
319
+
320
+
321
+ def orgs_move(client: httpx.Client, args: argparse.Namespace) -> int:
322
+ """An agent's logs and numbers moved into the org."""
323
+ moved = _object(_answered(client.put(f"{ORGS}/{args.org}/agents", json={"agent": args.agent})))
324
+ _line_out(
325
+ f"{moved['agent']} -> {moved['org']}: {moved['logs']} logs, numbers {moved['numbers']}"
326
+ )
327
+ for number in _list(moved["stayed"]):
328
+ _line_out(f" {number} stayed: the org already answers at it")
329
+ return 0
330
+
331
+
332
+ def orgs_rm(client: httpx.Client, args: argparse.Namespace) -> int:
333
+ """The org forgotten."""
334
+ _answered(client.delete(f"{ORGS}/{args.org}"))
335
+ _line_out(f"org {args.org} is gone")
336
+ return 0
337
+
338
+
339
+ def orgs_quota(client: httpx.Client, args: argparse.Namespace) -> int:
340
+ """The org's limits in one world, replaced whole."""
341
+ limits = {name: getattr(args, name) for name in QUOTAS if getattr(args, name) is not None}
342
+ lent = None if args.lends is None else str(args.lends)
343
+ lends = None if lent is None else ([] if lent == "none" else lent.split(","))
344
+ body = {
345
+ "env": args.env,
346
+ "quotas": {"limits": limits, "budget_usd": args.budget_usd, "lends": lends},
347
+ }
348
+ quotas = _object(_answered(client.put(f"{ORGS}/{args.org}/quotas", json=body)))
349
+ _line_out(json.dumps(quotas, indent=2, sort_keys=True))
350
+ return 0
351
+
352
+
353
+ def orgs_dialling(client: httpx.Client, args: argparse.Namespace) -> int:
354
+ """The org's dial guards, replaced whole."""
355
+ body: JsonObject = {
356
+ "dial_anywhere": args.dial_anywhere,
357
+ "per_minute": args.per_minute,
358
+ "per_day": args.per_day,
359
+ "max_duration_s": args.max_duration_s,
360
+ }
361
+ guards = _object(_answered(client.put(f"{ORGS}/{args.org}/dialling", json=body)))
362
+ _line_out(json.dumps(guards, sort_keys=True))
363
+ return 0
364
+
365
+
366
+ def orgs_sso(client: httpx.Client, args: argparse.Namespace) -> int:
367
+ """Which provider the org signs in with; `--off` lets a password open it again."""
368
+ path = f"{ORGS}/{args.org}/sso"
369
+ if args.off:
370
+ wired = _object(_answered(client.put(f"{path}/required", json={"required": False})))
371
+ else:
372
+ wired = _object(_answered(client.get(path)))
373
+ if not wired["configured"]:
374
+ _line_out(f"{args.org} signs in with no identity provider")
375
+ return 0
376
+ required = "required" if wired["required"] else "a password opens it too"
377
+ _line_out(f"{wired['issuer']} client {wired['client_id']} {required}")
378
+ return 0
379
+
380
+
381
+ def provider_key_set(client: httpx.Client, args: argparse.Namespace) -> int:
382
+ """The org's own key for a vendor, read from stdin: argv is what `ps` shows."""
383
+ key = sys.stdin.readline().strip()
384
+ _answered(client.put(f"{ORGS}/{args.org}/provider-keys/{args.vendor}", json={"key": key}))
385
+ _line_out(f"{args.org} runs {args.vendor} on its own key from the next call")
386
+ return 0
387
+
388
+
389
+ def provider_key_rm(client: httpx.Client, args: argparse.Namespace) -> int:
390
+ """The org's key for a vendor taken back."""
391
+ _answered(client.delete(f"{ORGS}/{args.org}/provider-keys/{args.vendor}"))
392
+ _line_out(f"{args.org} runs {args.vendor} on the box's key from the next call")
393
+ return 0
394
+
395
+
396
+ def provider_key_list(client: httpx.Client, args: argparse.Namespace) -> int:
397
+ """The vendors the org brought its own key for."""
398
+ listed = _object(_answered(client.get(f"{ORGS}/{args.org}/provider-keys")))
399
+ for vendor in _list(listed["vendors"]):
400
+ _line_out(str(vendor))
401
+ return 0
402
+
403
+
404
+ def keys_issue(client: httpx.Client, args: argparse.Namespace) -> int:
405
+ """A key of the org in a world, printed once."""
406
+ body: JsonObject = {
407
+ "env": args.env,
408
+ "label": args.label,
409
+ "scopes": args.scope or None,
410
+ "subject": args.subject,
411
+ "name": args.name,
412
+ }
413
+ minted = _object(_answered(client.post(f"{ORGS}/{args.org}/keys", json=body)))
414
+ scopes = ", ".join(str(scope) for scope in _list(minted["scopes"]))
415
+ _line_out(str(minted["key"]))
416
+ _line_out(f" org {args.org} · {minted['env']} · {minted['label'] or '-'}")
417
+ _line_out(f" {scopes}")
418
+ _line_out(" copy it now: the table keeps the fingerprint, and the key is never shown again")
419
+ return 0
420
+
421
+
422
+ def keys_list(client: httpx.Client, args: argparse.Namespace) -> int:
423
+ """The org's keys by fingerprint, the revoked ones said."""
424
+ for key in _rows(_answered(client.get(f"{ORGS}/{args.org}/keys"))):
425
+ state = "revoked" if key["revoked_at"] else "live"
426
+ _line_out(f"{key['fingerprint']} {key['env'] or 'person'} {key['label'] or '-'} {state}")
427
+ return 0
428
+
429
+
430
+ def keys_revoke(client: httpx.Client, args: argparse.Namespace) -> int:
431
+ """One key stopped from the next request on."""
432
+ _answered(client.post(f"/v1/ops/keys/{args.fingerprint}/revoke"))
433
+ _line_out(f"{args.fingerprint} opens nothing from now on")
434
+ return 0
435
+
436
+
437
+ def routes_list(client: httpx.Client, args: argparse.Namespace) -> int:
438
+ """The org's routes in a world."""
439
+ params = {"org": args.org, "env": args.env}
440
+ for route in _rows(_answered(client.get("/v1/ops/routes", params=params))):
441
+ _line_out(f"{route['number']} {route['channel']} {route['agent']} {route['env']}")
442
+ return 0
443
+
444
+
445
+ def routes_add(client: httpx.Client, args: argparse.Namespace) -> int:
446
+ """A number answered by an agent; added again, it moves."""
447
+ body = {
448
+ "org": args.org,
449
+ "number": args.number,
450
+ "agent": args.agent,
451
+ "channel": args.channel,
452
+ "env": args.env,
453
+ }
454
+ route = _object(_answered(client.post("/v1/ops/routes", json=body)))
455
+ _line_out(f"{route['number']} {route['channel']} {route['agent']} {route['env']}")
456
+ return 0
457
+
458
+
459
+ def routes_rm(client: httpx.Client, args: argparse.Namespace) -> int:
460
+ """A route forgotten."""
461
+ _answered(client.delete(f"/v1/ops/routes/{args.number}", params={"org": args.org}))
462
+ _line_out(f"{args.number} answers nowhere now")
463
+ return 0
464
+
465
+
466
+ def routes_seed(client: httpx.Client, args: argparse.Namespace) -> int:
467
+ """A file of routes, each added: how a box comes up from a checkout."""
468
+ rows = json.loads(Path(args.file).read_text(encoding="utf-8"))
469
+ for row in _list(rows):
470
+ route = _object(_answered(client.post("/v1/ops/routes", json=_object(row))))
471
+ _line_out(f"{route['number']} {route['channel']} {route['agent']} {route['env']}")
472
+ return 0
473
+
474
+
475
+ def fleet_list(client: httpx.Client, args: argparse.Namespace) -> int:
476
+ """The roster the gateway hears, one line per worker, and each fleet's totals."""
477
+ listed = _object(_answered(client.get("/v1/ops/fleet")))
478
+ now = float(str(listed["now"]))
479
+ workers = [WorkerStatus.model_validate(row) for row in _list(listed["workers"])]
480
+ wanted = [seat for seat in workers if args.fleet is None or seat.fleet == args.fleet]
481
+ _line_out(f"{'fleet':18} {'worker':18} {'held':>4} {'seats':>5} {'load':>5} standing heard")
482
+ for seat in wanted:
483
+ _line_out(
484
+ f"{seat.fleet:18} {seat.worker:18} {seat.active:>4} "
485
+ f"{seat.max_jobs if seat.max_jobs is not None else 'cpu':>5} {seat.load:>5.2f} "
486
+ f"{_state_of(seat, workers, now):10} {now - seat.seen_at:.0f}s ago"
487
+ )
488
+ for summed in _rows(listed["totals"]):
489
+ if args.fleet is not None and summed["fleet"] != args.fleet:
490
+ continue
491
+ full = " FULL" if summed["full"] else ""
492
+ waiting = f" · {summed['waiting']} waiting for a worker" if summed.get("waiting") else ""
493
+ _line_out(
494
+ f"\n{summed['fleet']}: {summed['workers']} up · {summed['active']} calls · "
495
+ f"{summed['free']} seats free · {summed['accepting']} accepting{waiting}{full}"
496
+ )
497
+ return 0
498
+
499
+
500
+ def fleet_cordon(client: httpx.Client, args: argparse.Namespace) -> int:
501
+ """A worker told, on its next heartbeat, to take no new call and leave."""
502
+ _cordon(client, args.worker, on=True)
503
+ _line_out(f"{args.worker} is cordoned: it finishes what it holds and leaves")
504
+ return 0
505
+
506
+
507
+ def fleet_uncordon(client: httpx.Client, args: argparse.Namespace) -> int:
508
+ """A worker's cordon taken back."""
509
+ _cordon(client, args.worker, on=False)
510
+ _line_out(f"{args.worker} takes calls again")
511
+ return 0
512
+
513
+
514
+ def _knocking(verb: Verb) -> Callable[[Settings, argparse.Namespace], int]:
515
+ def run(settings: Settings, args: argparse.Namespace) -> int:
516
+ if not settings.ops_key:
517
+ raise PinecallError(NO_OPS_KEY)
518
+ with httpx.Client(
519
+ base_url=settings.gateway_url,
520
+ headers={"Authorization": f"Bearer {settings.ops_key}"},
521
+ timeout=TIMEOUT_S,
522
+ ) as client:
523
+ return verb(client, args)
524
+
525
+ return run
526
+
527
+
528
+ def _answered(answer: httpx.Response) -> Json:
529
+ if answer.is_success:
530
+ return None if answer.status_code == NO_BODY else answer.json()
531
+ detail = answer.text.strip()
532
+ with contextlib.suppress(ValueError, KeyError, TypeError):
533
+ detail = str(_object(answer.json())["detail"])
534
+ raise GatewayRefused(f"{answer.status_code}: {detail}", answered=answer.status_code)
535
+
536
+
537
+ def _object(value: Json) -> JsonObject:
538
+ if not isinstance(value, dict):
539
+ raise GatewayRefused(f"the gateway answered {type(value).__name__}, not an object")
540
+ return value
541
+
542
+
543
+ def _rows(value: Json) -> list[JsonObject]:
544
+ return [_object(item) for item in _list(value)]
545
+
546
+
547
+ def _list(value: Json) -> list[Json]:
548
+ if not isinstance(value, list):
549
+ raise GatewayRefused(f"the gateway answered {type(value).__name__}, not a list")
550
+ return value
551
+
552
+
553
+ # The box's public name, read off the link the gateway made on it; the client knows loopback only.
554
+ def _origin_of(card: str) -> str:
555
+ parts = urlsplit(card)
556
+ return f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else ""
557
+
558
+
559
+ def _base(client: httpx.Client) -> str:
560
+ return str(client.base_url).rstrip("/")
561
+
562
+
563
+ def _member_by_email(client: httpx.Client, org: str, email: str) -> JsonObject:
564
+ listed = _object(_answered(client.get(f"{ORGS}/{org}/members")))
565
+ for member in _rows(listed["members"]):
566
+ if str(member["email"]).lower() == email.strip().lower():
567
+ return member
568
+ raise GatewayRefused(f"nobody in {org} has the address {email}", answered=404)
569
+
570
+
571
+ def _cordon(client: httpx.Client, worker: str, *, on: bool) -> None:
572
+ path = f"/v1/ops/fleet/{worker}/cordon"
573
+ _answered(client.post(path) if on else client.delete(path))
574
+
575
+
576
+ def _state_of(seat: WorkerStatus, workers: list[WorkerStatus], now: float) -> str:
577
+ return roster.worker_state(seat, [other for other in workers if other.fleet == seat.fleet], now)
578
+
579
+
580
+ async def _minted(settings: Settings, issued: key_table.Issued) -> str:
581
+ pool = await open_pool(settings.database_url)
582
+ try:
583
+ _, secret = await key_table.issue(pool, issued)
584
+ finally:
585
+ await pool.close()
586
+ return secret
587
+
588
+
589
+ def _line_out(text: str) -> None:
590
+ sys.stdout.write(f"{text}\n")
591
+
592
+
593
+ def _limits_verbs(
594
+ quota: argparse.ArgumentParser,
595
+ dialling: argparse.ArgumentParser,
596
+ signs: argparse.ArgumentParser,
597
+ vendor_keys: argparse.ArgumentParser,
598
+ ) -> None:
599
+ quota.add_argument("org")
600
+ quota.add_argument("--env", required=True, choices=("production", "sandbox"))
601
+ for name in QUOTAS:
602
+ quota.add_argument(f"--{name.replace('_', '-')}", dest=name, type=int, default=None)
603
+ quota.add_argument("--budget-usd", dest="budget_usd", type=int, default=None)
604
+ quota.add_argument("--lends", default=None, help="vendor[/model],… or none")
605
+ quota.set_defaults(run=_knocking(orgs_quota))
606
+ dialling.add_argument("org")
607
+ dialling.add_argument(
608
+ "--dial-anywhere", dest="dial_anywhere", action="store_true", default=None
609
+ )
610
+ dialling.add_argument("--no-dial-anywhere", dest="dial_anywhere", action="store_false")
611
+ dialling.add_argument("--per-minute", dest="per_minute", type=int, default=None)
612
+ dialling.add_argument("--per-day", dest="per_day", type=int, default=None)
613
+ dialling.add_argument("--max-duration-s", dest="max_duration_s", type=int, default=None)
614
+ dialling.set_defaults(run=_knocking(orgs_dialling))
615
+ signs.add_argument("org")
616
+ signs.add_argument("--off", action="store_true")
617
+ signs.set_defaults(run=_knocking(orgs_sso))
618
+ vendor = vendor_keys.add_subparsers(required=True)
619
+ for verb, runner in (("set", provider_key_set), ("rm", provider_key_rm)):
620
+ one_verb = vendor.add_parser(verb)
621
+ one_verb.add_argument("org")
622
+ one_verb.add_argument("vendor")
623
+ one_verb.set_defaults(run=_knocking(runner))
624
+ listing = vendor.add_parser("list")
625
+ listing.add_argument("org")
626
+ listing.set_defaults(run=_knocking(provider_key_list))