booking-agent-cli 0.1.0__tar.gz

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 (206) hide show
  1. booking_agent_cli-0.1.0/.gitignore +28 -0
  2. booking_agent_cli-0.1.0/PKG-INFO +75 -0
  3. booking_agent_cli-0.1.0/README.md +35 -0
  4. booking_agent_cli-0.1.0/pyproject.toml +64 -0
  5. booking_agent_cli-0.1.0/src/booking_agent/__init__.py +6 -0
  6. booking_agent_cli-0.1.0/src/booking_agent/authorization.py +450 -0
  7. booking_agent_cli-0.1.0/src/booking_agent/backend/__init__.py +217 -0
  8. booking_agent_cli-0.1.0/src/booking_agent/backend/live.py +494 -0
  9. booking_agent_cli-0.1.0/src/booking_agent/calendar/__init__.py +55 -0
  10. booking_agent_cli-0.1.0/src/booking_agent/calendar/filter.py +41 -0
  11. booking_agent_cli-0.1.0/src/booking_agent/calendar/google.py +582 -0
  12. booking_agent_cli-0.1.0/src/booking_agent/calendar/oauth_listener.py +287 -0
  13. booking_agent_cli-0.1.0/src/booking_agent/calendar/scope.py +97 -0
  14. booking_agent_cli-0.1.0/src/booking_agent/circuit_breaker.py +116 -0
  15. booking_agent_cli-0.1.0/src/booking_agent/config.py +51 -0
  16. booking_agent_cli-0.1.0/src/booking_agent/defaults.env +50 -0
  17. booking_agent_cli-0.1.0/src/booking_agent/discovery.py +188 -0
  18. booking_agent_cli-0.1.0/src/booking_agent/headless.py +463 -0
  19. booking_agent_cli-0.1.0/src/booking_agent/http_deadlines.py +43 -0
  20. booking_agent_cli-0.1.0/src/booking_agent/http_retry.py +178 -0
  21. booking_agent_cli-0.1.0/src/booking_agent/http_trace.py +428 -0
  22. booking_agent_cli-0.1.0/src/booking_agent/merchant_pacing.py +73 -0
  23. booking_agent_cli-0.1.0/src/booking_agent/models/__init__.py +14 -0
  24. booking_agent_cli-0.1.0/src/booking_agent/models/platform.py +200 -0
  25. booking_agent_cli-0.1.0/src/booking_agent/models/wire.py +868 -0
  26. booking_agent_cli-0.1.0/src/booking_agent/namespace.py +53 -0
  27. booking_agent_cli-0.1.0/src/booking_agent/observability.py +143 -0
  28. booking_agent_cli-0.1.0/src/booking_agent/platform_profile.py +120 -0
  29. booking_agent_cli-0.1.0/src/booking_agent/pop.py +347 -0
  30. booking_agent_cli-0.1.0/src/booking_agent/profile_cache.py +130 -0
  31. booking_agent_cli-0.1.0/src/booking_agent/registry_url.py +35 -0
  32. booking_agent_cli-0.1.0/src/booking_agent/rest_client.py +1493 -0
  33. booking_agent_cli-0.1.0/src/booking_agent/secure_file.py +52 -0
  34. booking_agent_cli-0.1.0/src/booking_agent/settings.py +110 -0
  35. booking_agent_cli-0.1.0/src/booking_agent/signals.py +130 -0
  36. booking_agent_cli-0.1.0/src/booking_agent/step_timing.py +168 -0
  37. booking_agent_cli-0.1.0/src/booking_agent/ucp_capabilities.py +148 -0
  38. booking_agent_cli-0.1.0/src/booking_agent/ucp_checkout.py +1363 -0
  39. booking_agent_cli-0.1.0/src/booking_agent/ucp_negotiation.py +184 -0
  40. booking_agent_cli-0.1.0/src/booking_agent/ucp_profile.py +410 -0
  41. booking_agent_cli-0.1.0/src/booking_agent/usp_methods.py +110 -0
  42. booking_agent_cli-0.1.0/src/booking_agent_cli/__init__.py +3 -0
  43. booking_agent_cli-0.1.0/src/booking_agent_cli/activity_log.py +106 -0
  44. booking_agent_cli-0.1.0/src/booking_agent_cli/booking_poll.py +141 -0
  45. booking_agent_cli-0.1.0/src/booking_agent_cli/booking_summary.py +151 -0
  46. booking_agent_cli-0.1.0/src/booking_agent_cli/calendar_connection.py +196 -0
  47. booking_agent_cli-0.1.0/src/booking_agent_cli/calendar_event.py +611 -0
  48. booking_agent_cli-0.1.0/src/booking_agent_cli/calendar_preference.py +176 -0
  49. booking_agent_cli-0.1.0/src/booking_agent_cli/catalog.py +864 -0
  50. booking_agent_cli-0.1.0/src/booking_agent_cli/checkout_prereq.py +67 -0
  51. booking_agent_cli-0.1.0/src/booking_agent_cli/commands/__init__.py +0 -0
  52. booking_agent_cli-0.1.0/src/booking_agent_cli/commands/config.py +106 -0
  53. booking_agent_cli-0.1.0/src/booking_agent_cli/commands/flow.py +6988 -0
  54. booking_agent_cli-0.1.0/src/booking_agent_cli/commands/lowlevel.py +163 -0
  55. booking_agent_cli-0.1.0/src/booking_agent_cli/commands/runtime.py +46 -0
  56. booking_agent_cli-0.1.0/src/booking_agent_cli/config_loader.py +108 -0
  57. booking_agent_cli-0.1.0/src/booking_agent_cli/engine.py +168 -0
  58. booking_agent_cli-0.1.0/src/booking_agent_cli/flow_timings.py +34 -0
  59. booking_agent_cli-0.1.0/src/booking_agent_cli/geo_eligibility.py +183 -0
  60. booking_agent_cli-0.1.0/src/booking_agent_cli/geocode.py +233 -0
  61. booking_agent_cli-0.1.0/src/booking_agent_cli/identity.py +104 -0
  62. booking_agent_cli-0.1.0/src/booking_agent_cli/link_cli.py +620 -0
  63. booking_agent_cli-0.1.0/src/booking_agent_cli/main.py +75 -0
  64. booking_agent_cli-0.1.0/src/booking_agent_cli/output.py +199 -0
  65. booking_agent_cli-0.1.0/src/booking_agent_cli/payment_helpers.py +884 -0
  66. booking_agent_cli-0.1.0/src/booking_agent_cli/payment_token.py +23 -0
  67. booking_agent_cli-0.1.0/src/booking_agent_cli/reschedule.py +137 -0
  68. booking_agent_cli-0.1.0/src/booking_agent_cli/runtime.py +55 -0
  69. booking_agent_cli-0.1.0/src/booking_agent_cli/runtime_prepare.py +129 -0
  70. booking_agent_cli-0.1.0/src/booking_agent_cli/search_area.py +111 -0
  71. booking_agent_cli-0.1.0/src/booking_agent_cli/session.py +837 -0
  72. booking_agent_cli-0.1.0/src/booking_agent_cli/support_bundle_schema.py +557 -0
  73. booking_agent_cli-0.1.0/src/booking_agent_cli/support_report.py +1210 -0
  74. booking_agent_cli-0.1.0/src/booking_agent_cli/timezone_context.py +314 -0
  75. booking_agent_cli-0.1.0/tests/.env.tests +22 -0
  76. booking_agent_cli-0.1.0/tests/conftest.py +203 -0
  77. booking_agent_cli-0.1.0/tests/e2e/__init__.py +1 -0
  78. booking_agent_cli-0.1.0/tests/e2e/test_cancel_reschedule_e2e.py +324 -0
  79. booking_agent_cli-0.1.0/tests/e2e/test_latency_soak.py +53 -0
  80. booking_agent_cli-0.1.0/tests/e2e/test_staging_registry_integration.py +67 -0
  81. booking_agent_cli-0.1.0/tests/e2e/test_timezone_booking_e2e.py +153 -0
  82. booking_agent_cli-0.1.0/tests/e2e/test_ucp_native_e2e.py +88 -0
  83. booking_agent_cli-0.1.0/tests/e2e/test_ucp_skill_flow_e2e.py +329 -0
  84. booking_agent_cli-0.1.0/tests/e2e/ucp_native.py +273 -0
  85. booking_agent_cli-0.1.0/tests/integration/__init__.py +0 -0
  86. booking_agent_cli-0.1.0/tests/integration/test_activity_log.py +104 -0
  87. booking_agent_cli-0.1.0/tests/integration/test_at_rest_hygiene.py +151 -0
  88. booking_agent_cli-0.1.0/tests/integration/test_booking_rejection.py +154 -0
  89. booking_agent_cli-0.1.0/tests/integration/test_bulk_cancel.py +333 -0
  90. booking_agent_cli-0.1.0/tests/integration/test_calendar_connection.py +183 -0
  91. booking_agent_cli-0.1.0/tests/integration/test_calendar_connector.py +317 -0
  92. booking_agent_cli-0.1.0/tests/integration/test_calendar_flow.py +995 -0
  93. booking_agent_cli-0.1.0/tests/integration/test_calendar_preference.py +101 -0
  94. booking_agent_cli-0.1.0/tests/integration/test_checkout_signals.py +133 -0
  95. booking_agent_cli-0.1.0/tests/integration/test_circuit_breaker.py +91 -0
  96. booking_agent_cli-0.1.0/tests/integration/test_config.py +251 -0
  97. booking_agent_cli-0.1.0/tests/integration/test_correlation_flow.py +244 -0
  98. booking_agent_cli-0.1.0/tests/integration/test_declined_spt.py +782 -0
  99. booking_agent_cli-0.1.0/tests/integration/test_discover_registry_rejection.py +43 -0
  100. booking_agent_cli-0.1.0/tests/integration/test_flow_evidence.py +33 -0
  101. booking_agent_cli-0.1.0/tests/integration/test_flow_gates.py +3666 -0
  102. booking_agent_cli-0.1.0/tests/integration/test_flow_reschedule.py +572 -0
  103. booking_agent_cli-0.1.0/tests/integration/test_flow_step_timings.py +58 -0
  104. booking_agent_cli-0.1.0/tests/integration/test_google_calendar_oauth.py +171 -0
  105. booking_agent_cli-0.1.0/tests/integration/test_home_visit_address.py +144 -0
  106. booking_agent_cli-0.1.0/tests/integration/test_http_trace.py +156 -0
  107. booking_agent_cli-0.1.0/tests/integration/test_identity_logout.py +90 -0
  108. booking_agent_cli-0.1.0/tests/integration/test_lazy_oauth.py +158 -0
  109. booking_agent_cli-0.1.0/tests/integration/test_link_auth.py +320 -0
  110. booking_agent_cli-0.1.0/tests/integration/test_link_cli_spt.py +374 -0
  111. booking_agent_cli-0.1.0/tests/integration/test_namespace.py +135 -0
  112. booking_agent_cli-0.1.0/tests/integration/test_oauth_listener.py +131 -0
  113. booking_agent_cli-0.1.0/tests/integration/test_oauth_metadata.py +39 -0
  114. booking_agent_cli-0.1.0/tests/integration/test_platform_key_pop.py +605 -0
  115. booking_agent_cli-0.1.0/tests/integration/test_platform_profile.py +155 -0
  116. booking_agent_cli-0.1.0/tests/integration/test_pop_keystore.py +163 -0
  117. booking_agent_cli-0.1.0/tests/integration/test_profile_cache.py +50 -0
  118. booking_agent_cli-0.1.0/tests/integration/test_release_hold_uses_delete.py +88 -0
  119. booking_agent_cli-0.1.0/tests/integration/test_request_retry_semantics.py +147 -0
  120. booking_agent_cli-0.1.0/tests/integration/test_runtime_prepare.py +120 -0
  121. booking_agent_cli-0.1.0/tests/integration/test_search_area.py +154 -0
  122. booking_agent_cli-0.1.0/tests/integration/test_session_retention.py +334 -0
  123. booking_agent_cli-0.1.0/tests/integration/test_skill_portability.py +37 -0
  124. booking_agent_cli-0.1.0/tests/integration/test_support_report.py +1047 -0
  125. booking_agent_cli-0.1.0/tests/integration/test_timezone_context.py +325 -0
  126. booking_agent_cli-0.1.0/tests/integration/test_ucp_cancel_result.py +781 -0
  127. booking_agent_cli-0.1.0/tests/integration/test_ucp_checkout_pop.py +284 -0
  128. booking_agent_cli-0.1.0/tests/integration/test_ucp_checkout_proof_wireup.py +346 -0
  129. booking_agent_cli-0.1.0/tests/integration/test_ucp_checkout_validate.py +1076 -0
  130. booking_agent_cli-0.1.0/tests/integration/test_ucp_profile.py +260 -0
  131. booking_agent_cli-0.1.0/tests/integration/test_wheel_contents.py +40 -0
  132. booking_agent_cli-0.1.0/tests/spec_schemas/SOURCE +4 -0
  133. booking_agent_cli-0.1.0/tests/spec_schemas/acp_booking_extension.json +46 -0
  134. booking_agent_cli-0.1.0/tests/spec_schemas/availability.json +120 -0
  135. booking_agent_cli-0.1.0/tests/spec_schemas/booking.json +603 -0
  136. booking_agent_cli-0.1.0/tests/spec_schemas/calendar_freebusy.json +76 -0
  137. booking_agent_cli-0.1.0/tests/spec_schemas/catalog.json +615 -0
  138. booking_agent_cli-0.1.0/tests/spec_schemas/paid_bookings.json +201 -0
  139. booking_agent_cli-0.1.0/tests/spec_schemas/profile.json +189 -0
  140. booking_agent_cli-0.1.0/tests/spec_schemas/registry.json +448 -0
  141. booking_agent_cli-0.1.0/tests/spec_schemas/rest_common.json +91 -0
  142. booking_agent_cli-0.1.0/tests/spec_schemas/usp.json +407 -0
  143. booking_agent_cli-0.1.0/tests/spec_schemas/waitlist.json +132 -0
  144. booking_agent_cli-0.1.0/tests/spec_schemas/webhook_event.json +132 -0
  145. booking_agent_cli-0.1.0/tests/support/__init__.py +1 -0
  146. booking_agent_cli-0.1.0/tests/support/fixtures/__init__.py +5 -0
  147. booking_agent_cli-0.1.0/tests/support/fixtures/profiles.py +58 -0
  148. booking_agent_cli-0.1.0/tests/support/fixtures/services.py +226 -0
  149. booking_agent_cli-0.1.0/tests/support/mock/__init__.py +18 -0
  150. booking_agent_cli-0.1.0/tests/support/mock/backend.py +467 -0
  151. booking_agent_cli-0.1.0/tests/support/mock/link_cli.py +85 -0
  152. booking_agent_cli-0.1.0/tests/support/mock/mock_calendar.py +72 -0
  153. booking_agent_cli-0.1.0/tests/support/mock/pop_business.py +298 -0
  154. booking_agent_cli-0.1.0/tests/support/mock/pop_transport.py +279 -0
  155. booking_agent_cli-0.1.0/tests/support/mock/ucp_checkout_store.py +202 -0
  156. booking_agent_cli-0.1.0/tests/support/mock/ucp_server.py +228 -0
  157. booking_agent_cli-0.1.0/tests/support/offline_stack/__init__.py +1 -0
  158. booking_agent_cli-0.1.0/tests/support/offline_stack/stack.py +234 -0
  159. booking_agent_cli-0.1.0/tests/unit/__init__.py +0 -0
  160. booking_agent_cli-0.1.0/tests/unit/test_authorization_policy.py +207 -0
  161. booking_agent_cli-0.1.0/tests/unit/test_booking_poll_credentials.py +69 -0
  162. booking_agent_cli-0.1.0/tests/unit/test_booking_request_error.py +115 -0
  163. booking_agent_cli-0.1.0/tests/unit/test_calendar_filter.py +47 -0
  164. booking_agent_cli-0.1.0/tests/unit/test_catalog.py +577 -0
  165. booking_agent_cli-0.1.0/tests/unit/test_conditional_writes.py +136 -0
  166. booking_agent_cli-0.1.0/tests/unit/test_delivery_address.py +125 -0
  167. booking_agent_cli-0.1.0/tests/unit/test_discovery.py +31 -0
  168. booking_agent_cli-0.1.0/tests/unit/test_geo_eligibility.py +63 -0
  169. booking_agent_cli-0.1.0/tests/unit/test_geocode.py +105 -0
  170. booking_agent_cli-0.1.0/tests/unit/test_headless.py +114 -0
  171. booking_agent_cli-0.1.0/tests/unit/test_http_deadlines.py +41 -0
  172. booking_agent_cli-0.1.0/tests/unit/test_http_retry.py +146 -0
  173. booking_agent_cli-0.1.0/tests/unit/test_live_search_registry.py +474 -0
  174. booking_agent_cli-0.1.0/tests/unit/test_merchant_pacing.py +99 -0
  175. booking_agent_cli-0.1.0/tests/unit/test_mock_support.py +55 -0
  176. booking_agent_cli-0.1.0/tests/unit/test_money_path_events.py +243 -0
  177. booking_agent_cli-0.1.0/tests/unit/test_nonce_from_pop_error_body.py +31 -0
  178. booking_agent_cli-0.1.0/tests/unit/test_observability.py +70 -0
  179. booking_agent_cli-0.1.0/tests/unit/test_outbound_member_names.py +121 -0
  180. booking_agent_cli-0.1.0/tests/unit/test_package_layering.py +19 -0
  181. booking_agent_cli-0.1.0/tests/unit/test_registry_search_filters.py +175 -0
  182. booking_agent_cli-0.1.0/tests/unit/test_registry_search_pricing.py +97 -0
  183. booking_agent_cli-0.1.0/tests/unit/test_registry_search_responses.py +246 -0
  184. booking_agent_cli-0.1.0/tests/unit/test_registry_url.py +29 -0
  185. booking_agent_cli-0.1.0/tests/unit/test_rest_client.py +233 -0
  186. booking_agent_cli-0.1.0/tests/unit/test_scoped_method_authorization.py +137 -0
  187. booking_agent_cli-0.1.0/tests/unit/test_session_credentials.py +186 -0
  188. booking_agent_cli-0.1.0/tests/unit/test_spec_conformance.py +114 -0
  189. booking_agent_cli-0.1.0/tests/unit/test_step_timing.py +92 -0
  190. booking_agent_cli-0.1.0/tests/unit/test_ucp_capabilities.py +169 -0
  191. booking_agent_cli-0.1.0/tests/unit/test_ucp_complete_outcome.py +159 -0
  192. booking_agent_cli-0.1.0/tests/unit/test_ucp_create_price_guard.py +116 -0
  193. booking_agent_cli-0.1.0/tests/unit/test_ucp_negotiation.py +169 -0
  194. booking_agent_cli-0.1.0/tests/unit/test_wire_model_conformance.py +317 -0
  195. booking_agent_cli-0.1.0/tests/vectors/pop/001-jcs-nested-buyer.json +30 -0
  196. booking_agent_cli-0.1.0/tests/vectors/pop/002-jcs-meta-only.json +22 -0
  197. booking_agent_cli-0.1.0/tests/vectors/pop/003-jcs-unicode-empty-meta.json +25 -0
  198. booking_agent_cli-0.1.0/tests/vectors/pop/004-proof-issuance-no-ath.json +27 -0
  199. booking_agent_cli-0.1.0/tests/vectors/pop/005-proof-presentation-ath-nonce.json +36 -0
  200. booking_agent_cli-0.1.0/tests/vectors/pop/006-cross-key-replay.json +39 -0
  201. booking_agent_cli-0.1.0/tests/vectors/pop/007-downgrade-cnf-without-proof.json +22 -0
  202. booking_agent_cli-0.1.0/tests/vectors/pop/008-replayed-jti.json +40 -0
  203. booking_agent_cli-0.1.0/tests/vectors/pop/009-aud-mismatch.json +38 -0
  204. booking_agent_cli-0.1.0/tests/vectors/pop/010-alg-none.json +31 -0
  205. booking_agent_cli-0.1.0/tests/vectors/pop/README.md +66 -0
  206. booking_agent_cli-0.1.0/tests/vectors/pop/SOURCE +4 -0
@@ -0,0 +1,28 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .venv/
7
+ .DS_Store
8
+
9
+ # Build outputs
10
+ build/
11
+ dist/
12
+ *.egg-info/
13
+ *.whl
14
+
15
+ # Local secrets and runtime state (never commit)
16
+ .env
17
+ .env.*
18
+ !.env.tests
19
+ .usp-session.json
20
+ .google-calendar-tokens/
21
+ usp_tokens.json
22
+ http_trace.log
23
+
24
+ # IDE: keep only the shared JetBrains ignore file
25
+ .idea/*
26
+ !.idea/.gitignore
27
+ .vscode/
28
+ *.iml
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: booking-agent-cli
3
+ Version: 0.1.0
4
+ Summary: The Booking Agent CLI: a stateful USP booking flow with hard gates, plus its USP protocol library.
5
+ Project-URL: Homepage, https://github.com/wix/booking-agent
6
+ Project-URL: Documentation, https://github.com/wix/booking-agent/blob/master/booking-agent/README.md
7
+ Project-URL: Source, https://github.com/wix/booking-agent/tree/master/booking-agent/packages/cli
8
+ Project-URL: Issues, https://github.com/wix/booking-agent/issues
9
+ Author: Wix.com
10
+ License-Expression: Apache-2.0
11
+ Keywords: agent,booking,cli,scheduling,ucp,usp
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Office/Business :: Scheduling
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: click>=8.2
26
+ Requires-Dist: cryptography>=42.0
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: pydantic>=2.6
29
+ Requires-Dist: python-dotenv>=1.0
30
+ Requires-Dist: rich>=13.7
31
+ Requires-Dist: starlette>=0.37
32
+ Requires-Dist: timezonefinder<7,>=6.5
33
+ Requires-Dist: typer>=0.16
34
+ Requires-Dist: uvicorn>=0.29
35
+ Provides-Extra: dev
36
+ Requires-Dist: jsonschema>=4; extra == 'dev'
37
+ Requires-Dist: pytest-asyncio>=0.23.5; extra == 'dev'
38
+ Requires-Dist: pytest>=8; extra == 'dev'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # booking-agent-cli
42
+
43
+ The `booking-agent` command books time-based services (haircuts, lessons,
44
+ repairs, classes, appointments) through the Universal Scheduling Protocol (USP),
45
+ paying from a Link wallet. It owns the booking state machine and refuses to
46
+ proceed when a human approval gate is skipped, so an AI agent can drive it safely.
47
+
48
+ The wheel ships two import packages:
49
+
50
+ - `booking_agent_cli` - the `booking-agent` command
51
+ - `booking_agent` - the USP/UCP protocol library (models, discovery, REST client)
52
+
53
+ ## Install
54
+
55
+ Requires Python 3.10 or newer.
56
+
57
+ ```bash
58
+ uv tool install booking-agent-cli
59
+ # or
60
+ pipx install booking-agent-cli
61
+
62
+ booking-agent --help
63
+ ```
64
+
65
+ Agents use the CLI through the Booking Agent skill:
66
+ https://raw.githubusercontent.com/wix/booking-agent/master/booking-agent/skills/booking-agent/SKILL.md
67
+
68
+ The skill also needs `link-cli` for buyer identity and payment approval; see the
69
+ skill for details.
70
+
71
+ ## Source and license
72
+
73
+ Source: https://github.com/wix/booking-agent (directory `booking-agent/`).
74
+ Licensed under the Apache License 2.0; see
75
+ https://github.com/wix/booking-agent/blob/master/LICENSE.
@@ -0,0 +1,35 @@
1
+ # booking-agent-cli
2
+
3
+ The `booking-agent` command books time-based services (haircuts, lessons,
4
+ repairs, classes, appointments) through the Universal Scheduling Protocol (USP),
5
+ paying from a Link wallet. It owns the booking state machine and refuses to
6
+ proceed when a human approval gate is skipped, so an AI agent can drive it safely.
7
+
8
+ The wheel ships two import packages:
9
+
10
+ - `booking_agent_cli` - the `booking-agent` command
11
+ - `booking_agent` - the USP/UCP protocol library (models, discovery, REST client)
12
+
13
+ ## Install
14
+
15
+ Requires Python 3.10 or newer.
16
+
17
+ ```bash
18
+ uv tool install booking-agent-cli
19
+ # or
20
+ pipx install booking-agent-cli
21
+
22
+ booking-agent --help
23
+ ```
24
+
25
+ Agents use the CLI through the Booking Agent skill:
26
+ https://raw.githubusercontent.com/wix/booking-agent/master/booking-agent/skills/booking-agent/SKILL.md
27
+
28
+ The skill also needs `link-cli` for buyer identity and payment approval; see the
29
+ skill for details.
30
+
31
+ ## Source and license
32
+
33
+ Source: https://github.com/wix/booking-agent (directory `booking-agent/`).
34
+ Licensed under the Apache License 2.0; see
35
+ https://github.com/wix/booking-agent/blob/master/LICENSE.
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "booking-agent-cli"
3
+ version = "0.1.0"
4
+ description = "The Booking Agent CLI: a stateful USP booking flow with hard gates, plus its USP protocol library."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ authors = [{ name = "Wix.com" }]
8
+ requires-python = ">=3.10"
9
+ keywords = ["booking", "scheduling", "usp", "ucp", "agent", "cli"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Office/Business :: Scheduling",
23
+ ]
24
+ dependencies = [
25
+ # typer's runtime; the tests only pass on click>=8.2 (separate stderr capture).
26
+ "click>=8.2",
27
+ # Ed25519 for platform_key_pop proof-of-possession (USP Section 10.1.6).
28
+ "cryptography>=42.0",
29
+ "httpx>=0.27",
30
+ "pydantic>=2.6",
31
+ "python-dotenv>=1.0",
32
+ "rich>=13.7",
33
+ "starlette>=0.37",
34
+ "timezonefinder>=6.5,<7",
35
+ # Older typer releases either cap click below 8.2 or break on current click.
36
+ "typer>=0.16",
37
+ "uvicorn>=0.29",
38
+ ]
39
+
40
+ [project.optional-dependencies]
41
+ # pytest-asyncio 0.23.0-0.23.4 crash on pytest 8 collection.
42
+ dev = ["pytest>=8", "pytest-asyncio>=0.23.5", "jsonschema>=4"]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/wix/booking-agent"
46
+ Documentation = "https://github.com/wix/booking-agent/blob/master/booking-agent/README.md"
47
+ Source = "https://github.com/wix/booking-agent/tree/master/booking-agent/packages/cli"
48
+ Issues = "https://github.com/wix/booking-agent/issues"
49
+
50
+ [project.scripts]
51
+ booking-agent = "booking_agent_cli.main:run"
52
+
53
+ [build-system]
54
+ # 1.27 is the first release that writes SPDX `license` expressions (PEP 639).
55
+ requires = ["hatchling>=1.27"]
56
+ build-backend = "hatchling.build"
57
+
58
+ # 2.4 is the newest core metadata every upload path accepts; recent hatchling defaults to 2.5.
59
+ [tool.hatch.build.targets.sdist]
60
+ core-metadata-version = "2.4"
61
+
62
+ [tool.hatch.build.targets.wheel]
63
+ packages = ["src/booking_agent", "src/booking_agent_cli"]
64
+ core-metadata-version = "2.4"
@@ -0,0 +1,6 @@
1
+ """booking_agent: USP protocol models, profile discovery, REST client, and backends.
2
+
3
+ Ships in the booking-agent-cli wheel and never imports ``booking_agent_cli``.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,450 @@
1
+ """``AuthorizationPolicy`` parsing and mechanism selection (USP Section 10.1.6).
2
+
3
+ USP splits operations into two tiers. *Public* operations -- catalog,
4
+ availability, profile discovery -- MAY remain anonymous. *Privileged*
5
+ operations -- creating, updating, confirming, cancelling or rescheduling a
6
+ booking, holds, waitlist mutations, payment-adjacent completion, registry
7
+ writes, and any response carrying buyer personal data -- MUST be authenticated.
8
+ A resource identifier is not a credential.
9
+
10
+ The *mechanism* is business-declared: the business publishes an
11
+ ``AuthorizationPolicy`` and the platform selects one it supports. Two rules
12
+ make that safe, and both are easy to get backwards:
13
+
14
+ * Unrecognized mechanisms are **ignored, never treated as accepted**. A client
15
+ that shrugged and continued anonymously against a policy it did not
16
+ understand would defeat the whole tier split.
17
+ * Where the policy is missing or nothing is mutually supported, the client
18
+ **fails closed** rather than falling back to anonymous.
19
+
20
+ Placement differs by deployment mode:
21
+
22
+ * **Standalone** -- top-level ``authorization`` in ``/.well-known/usp``.
23
+ * **UCP-Native** -- ``config.authorization`` on the ``dev.usp-protocol.services``
24
+ binding. A binding carrying ``authorization`` as a *direct member* is
25
+ malformed: ``schemas/usp.json`` gives it a never-satisfiable schema so that it
26
+ is rejected rather than silently accepted, which would let a business believe
27
+ it had published a policy no conforming consumer reads. We therefore treat
28
+ that shape as *no policy published* and fail closed -- we do not read it.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import re
34
+ from datetime import datetime, timezone
35
+ from enum import Enum
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ from pydantic import BaseModel, ConfigDict, field_validator
39
+
40
+ from . import settings
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ #: USP Section 10.1.6 mechanism identifiers this client can actually satisfy.
45
+ #: ``platform_key_pop`` is the only one implemented; the rest are recognized so
46
+ #: that diagnostics can say *why* a business is unreachable.
47
+ MECHANISM_PLATFORM_KEY_POP = "platform_key_pop"
48
+ MECHANISM_BOOKING_SCOPED_CREDENTIAL = "booking_scoped_credential"
49
+ MECHANISM_OAUTH2_BEARER = "oauth2_bearer"
50
+ MECHANISM_API_KEY = "api_key"
51
+ MECHANISM_HTTP_MESSAGE_SIGNATURE = "http_message_signature"
52
+ MECHANISM_MTLS = "mtls"
53
+
54
+ #: Ordered by preference. Only the first is implemented in this version;
55
+ #: ``oauth2_bearer`` support is dormant (see ``oauth_dormant``) and gated on #199.
56
+ SUPPORTED_MECHANISMS: tuple[str, ...] = (MECHANISM_PLATFORM_KEY_POP,)
57
+
58
+
59
+ class AuthorizationError(Exception):
60
+ """Base class for fail-closed authorization outcomes."""
61
+
62
+
63
+ #: USP Section 10.1.1 error codes relating to proofs. Note the ``pop_`` prefix: an
64
+ #: earlier draft of the client checklist called the replay code ``proof_replayed``,
65
+ #: which matches nothing on the wire.
66
+ ERR_POP_PROOF_MISSING = "pop_proof_missing"
67
+ ERR_POP_PROOF_INVALID = "pop_proof_invalid"
68
+ ERR_POP_KEY_MISMATCH = "pop_key_mismatch"
69
+ ERR_POP_PROOF_REPLAYED = "pop_proof_replayed"
70
+ ERR_PROOF_NONCE_REQUIRED = "proof_nonce_required"
71
+
72
+ #: All 401, and all terminal for the caller except the nonce challenge, which is
73
+ #: a single-round-trip negotiation rather than a failure.
74
+ POP_ERROR_CODES = frozenset({
75
+ ERR_POP_PROOF_MISSING,
76
+ ERR_POP_PROOF_INVALID,
77
+ ERR_POP_KEY_MISMATCH,
78
+ ERR_POP_PROOF_REPLAYED,
79
+ ERR_PROOF_NONCE_REQUIRED,
80
+ })
81
+
82
+
83
+ def nonce_from_pop_error_body(payload: Dict[str, Any]) -> Optional[str]:
84
+ """Return a proof nonce from a USP error object (MCP and REST 401 fallback).
85
+
86
+ MCP has no HTTP headers, so the MCP binding carries the nonce in
87
+ ``error.data.nonce`` when ``error.code`` is ``proof_nonce_required``. REST
88
+ prefers the ``DPoP-Nonce`` header; this parser is the fallback when the
89
+ header is absent on a 401.
90
+ """
91
+ if payload.get("code") != ERR_PROOF_NONCE_REQUIRED:
92
+ return None
93
+ data = payload.get("data")
94
+ nonce = data.get("nonce") if isinstance(data, dict) else None
95
+ return nonce if isinstance(nonce, str) and nonce else None
96
+
97
+
98
+ class AuthorizationRefused(AuthorizationError):
99
+ """The client refuses to perform a privileged operation against this business."""
100
+
101
+ def __init__(self, code: str, message: str):
102
+ super().__init__(message)
103
+ self.code = code
104
+ self.message = message
105
+
106
+
107
+ class AuthorizationPolicy(BaseModel):
108
+ """``schemas/profile.json#/$defs/AuthorizationPolicy``.
109
+
110
+ Both members are REQUIRED by the schema. The object is deliberately open
111
+ (``additionalProperties: true``) -- accepted proof algorithms and similar
112
+ extensions are advertised through it -- so unknown members are preserved
113
+ rather than dropped.
114
+ """
115
+
116
+ model_config = ConfigDict(extra="allow")
117
+
118
+ privileged_operations_require_authentication: bool
119
+ accepted_mechanisms: List[str]
120
+ token_endpoint: Optional[str] = None
121
+
122
+ def select_mechanism(self) -> Optional[str]:
123
+ """Return the best mutually supported mechanism, or None.
124
+
125
+ Unrecognized identifiers in ``accepted_mechanisms`` are skipped, never
126
+ assumed acceptable.
127
+ """
128
+ for candidate in SUPPORTED_MECHANISMS:
129
+ if candidate in self.accepted_mechanisms:
130
+ return candidate
131
+ return None
132
+
133
+
134
+ def parse_policy(raw: Any) -> Optional[AuthorizationPolicy]:
135
+ """Parse an ``authorization`` object, or return None when absent/malformed.
136
+
137
+ A malformed policy is reported as *absent* rather than raising, so the
138
+ caller reaches the same fail-closed path as a business that published
139
+ nothing -- the distinction matters for the log line, not for the decision.
140
+ """
141
+ if raw is None:
142
+ return None
143
+ if not isinstance(raw, dict):
144
+ logger.warning("Ignoring non-object authorization policy of type %s", type(raw).__name__)
145
+ return None
146
+ try:
147
+ return AuthorizationPolicy.model_validate(raw)
148
+ except Exception:
149
+ logger.warning("Ignoring malformed authorization policy: %r", raw, exc_info=True)
150
+ return None
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # The issued credential
155
+ # ---------------------------------------------------------------------------
156
+
157
+ class ConfirmationKey(BaseModel):
158
+ """``schemas/usp.json#/$defs/ConfirmationKey`` -- the RFC 7800 ``cnf`` claim.
159
+
160
+ Restricted to the JWK-thumbprint form; the embedded-key and key-reference
161
+ forms are not used by USP. ``extra="forbid"`` is load-bearing: ignoring an
162
+ unrecognized confirmation method would silently downgrade a
163
+ sender-constrained credential to a bearer token, so the Section 10.1.6
164
+ forward-compatibility rule deliberately does NOT extend to this object.
165
+ """
166
+
167
+ model_config = ConfigDict(extra="forbid")
168
+
169
+ jkt: str
170
+
171
+ @field_validator("jkt")
172
+ @classmethod
173
+ def _check_thumbprint(cls, v: str) -> str:
174
+ if not _JKT_RE.fullmatch(v):
175
+ raise ValueError(
176
+ "jkt must be an unpadded base64url SHA-256 thumbprint of exactly 43 characters"
177
+ )
178
+ return v
179
+
180
+
181
+ class BookingScopedCredential(BaseModel):
182
+ """``schemas/profile.json#/$defs/BookingScopedCredential``.
183
+
184
+ Returned on a creation response only, never on reads, and authorizing
185
+ continuation operations on exactly one booking or waitlist entry. Also a
186
+ **closed** object for the same reason as ``ConfirmationKey``.
187
+ """
188
+
189
+ model_config = ConfigDict(extra="forbid")
190
+
191
+ value: str
192
+ expires_at: datetime
193
+ cnf: Optional[ConfirmationKey] = None
194
+ #: USP method names this credential authorizes on its resource. Absent means
195
+ #: the full continuation set for the resource type -- never another resource.
196
+ scope: Optional[List[str]] = None
197
+
198
+ @field_validator("value")
199
+ @classmethod
200
+ def _check_value(cls, v: str) -> str:
201
+ if not v.startswith("bsc_") or len(v) < 26:
202
+ raise ValueError("credential value must start with 'bsc_' and be at least 26 characters")
203
+ return v
204
+
205
+ @property
206
+ def is_sender_constrained(self) -> bool:
207
+ return self.cnf is not None
208
+
209
+ def is_expired(self, *, now: Optional[datetime] = None) -> bool:
210
+ current = now or datetime.now(timezone.utc)
211
+ expires = self.expires_at
212
+ if expires.tzinfo is None:
213
+ expires = expires.replace(tzinfo=timezone.utc)
214
+ return expires <= current
215
+
216
+ def covers(self, usp_method: str) -> bool:
217
+ return self.scope is None or usp_method in self.scope
218
+
219
+
220
+ _JKT_RE = re.compile(r"[A-Za-z0-9_-]{43}")
221
+
222
+
223
+ class CredentialBindingError(AuthorizationError):
224
+ """The business issued a credential that is not bound to our key."""
225
+
226
+
227
+ def issued_credential_payload(data: Dict[str, Any]) -> Any:
228
+ """Locate ``booking_scoped_credential`` on a creation response.
229
+
230
+ Standalone USP returns it at the body root. UCP-Native paid bookings return
231
+ it as a sibling of ``booking`` at the checkout root, or inside the
232
+ ``dev.usp-protocol.services.paid_bookings`` extension. Creation is the only
233
+ place the value appears.
234
+ """
235
+ from .namespace import CAP_PAID_BOOKINGS
236
+
237
+ if not isinstance(data, dict):
238
+ return None
239
+ payload = data.get("booking_scoped_credential")
240
+ if payload is not None:
241
+ return payload
242
+ extension = data.get(CAP_PAID_BOOKINGS)
243
+ if isinstance(extension, dict):
244
+ return extension.get("booking_scoped_credential")
245
+ return None
246
+
247
+
248
+ def parse_issued_credential(
249
+ payload: Any, *, own_jkt: str, required: bool,
250
+ ) -> Optional[BookingScopedCredential]:
251
+ """Parse and verify a ``booking_scoped_credential`` from a creation response.
252
+
253
+ Verifies ``cnf.jkt`` against *own_jkt*: this detects a business that accepted
254
+ the proof and then issued a credential bound to something else, or to
255
+ nothing. A credential returned without ``cnf`` MUST NOT be treated as
256
+ sender-constrained -- when we authenticated with ``platform_key_pop``
257
+ (*required*), that is a protocol violation rather than a soft downgrade,
258
+ because continuing would hand a plain bearer secret to a caller that
259
+ believes it holds a bound one.
260
+ """
261
+ if payload is None:
262
+ return None
263
+ try:
264
+ credential = BookingScopedCredential.model_validate(payload)
265
+ except Exception as exc:
266
+ raise CredentialBindingError(
267
+ f"business returned a malformed booking_scoped_credential: {exc}"
268
+ ) from exc
269
+
270
+ if credential.cnf is None:
271
+ if required:
272
+ raise CredentialBindingError(
273
+ "business issued a booking_scoped_credential with no 'cnf' after a "
274
+ "platform_key_pop request; refusing to treat an unbound credential as "
275
+ "sender-constrained."
276
+ )
277
+ return credential
278
+
279
+ if credential.cnf.jkt != own_jkt:
280
+ raise CredentialBindingError(
281
+ f"business bound the credential to jkt {credential.cnf.jkt!r}, which is not "
282
+ f"this agent's key ({own_jkt!r}); the credential would be unusable."
283
+ )
284
+ return credential
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Deployment posture
289
+ # ---------------------------------------------------------------------------
290
+
291
+ class ScopedCredentialPosture(str, Enum):
292
+ """How strictly to enforce the Section 10.1.6 privileged-operation floor.
293
+
294
+ ``REQUIRED`` is both the conformant posture and the default. USP is
295
+ unambiguous that privileged operations MUST be authenticated and that a
296
+ consumer fails closed rather than falling back to anonymous, so shipping a
297
+ laxer default would be shipping a known deviation.
298
+
299
+ Under ``REQUIRED``, privileged calls to a business that publishes no
300
+ ``authorization`` policy, or declares privileged operations unauthenticated,
301
+ are refused: an unauthenticated booking is exactly what the mechanism exists
302
+ to prevent.
303
+
304
+ ``PREFERRED`` is for a deployment that chooses to transact with such a
305
+ business anyway: the same cases log a warning and proceed instead of refusing.
306
+ """
307
+
308
+ REQUIRED = "required"
309
+ PREFERRED = "preferred"
310
+
311
+
312
+ DEFAULT_POSTURE = ScopedCredentialPosture.REQUIRED
313
+
314
+ #: Precedence: explicit config -> environment -> default.
315
+ POSTURE_ENV_VAR = "BOOKING_AGENT_USP_AUTH_SCOPED_CREDENTIAL"
316
+
317
+
318
+ def resolve_posture(explicit: Optional[str] = None) -> ScopedCredentialPosture:
319
+ raw = explicit or settings.get(POSTURE_ENV_VAR)
320
+ if not raw:
321
+ return DEFAULT_POSTURE
322
+ try:
323
+ return ScopedCredentialPosture(raw.strip().lower())
324
+ except ValueError:
325
+ logger.warning(
326
+ "Unknown %s=%r; falling back to %s",
327
+ POSTURE_ENV_VAR, raw, DEFAULT_POSTURE.value,
328
+ )
329
+ return DEFAULT_POSTURE
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Decision
334
+ # ---------------------------------------------------------------------------
335
+
336
+ class AuthorizationDecision(BaseModel):
337
+ """What the client will do for privileged operations against one business."""
338
+
339
+ model_config = ConfigDict(frozen=True)
340
+
341
+ mechanism: Optional[str] = None
342
+ #: True when privileged requests should carry a proof.
343
+ authenticate: bool = False
344
+ #: Set when proceeding unauthenticated under a non-conformant posture.
345
+ warning: Optional[str] = None
346
+ #: Set when this business cannot be transacted with under the current
347
+ #: posture. Carried rather than raised so the refusal lands on the
348
+ #: privileged call that is actually forbidden -- see ``raise_if_refused``.
349
+ refusal_code: Optional[str] = None
350
+ refusal_message: Optional[str] = None
351
+
352
+ @property
353
+ def uses_pop(self) -> bool:
354
+ return self.mechanism == MECHANISM_PLATFORM_KEY_POP
355
+
356
+ @property
357
+ def refused(self) -> bool:
358
+ return self.refusal_code is not None
359
+
360
+ def raise_if_refused(self) -> None:
361
+ """Enforce the refusal. Call this on privileged operations only.
362
+
363
+ Public operations -- catalog, availability, profile discovery -- MAY
364
+ remain unauthenticated under USP Section 10.1.6, so a business that publishes
365
+ no usable policy is still browsable. Refusing at client construction
366
+ would block those too, which is both non-conformant and needlessly
367
+ unhelpful: the agent could not even show the user what it cannot book.
368
+ """
369
+ if self.refusal_code is not None:
370
+ raise AuthorizationRefused(self.refusal_code, self.refusal_message or "")
371
+
372
+
373
+ def evaluate(
374
+ policy: Optional[AuthorizationPolicy],
375
+ *,
376
+ posture: ScopedCredentialPosture,
377
+ exposes_privileged_capabilities: bool = True,
378
+ business_label: str = "business",
379
+ defer: bool = False,
380
+ ) -> AuthorizationDecision:
381
+ """Decide how to authenticate privileged operations against one business.
382
+
383
+ Raises ``AuthorizationRefused`` on every fail-closed path, unless *defer* is
384
+ set -- in which case the refusal is returned on the decision and enforced
385
+ later by ``AuthorizationDecision.raise_if_refused``. Callers that build a
386
+ client used for public operations too must defer, or browsing a business
387
+ would fail for want of an authorization policy it does not need.
388
+ """
389
+ if policy is None:
390
+ if not exposes_privileged_capabilities:
391
+ return AuthorizationDecision(mechanism=None, authenticate=False)
392
+ return _refuse_or_warn(
393
+ posture,
394
+ defer=defer,
395
+ code="authorization_policy_absent",
396
+ message=(
397
+ f"{business_label} exposes privileged capabilities but publishes no "
398
+ f"authorization policy, so there is no mechanism to negotiate."
399
+ ),
400
+ )
401
+
402
+ if not policy.privileged_operations_require_authentication:
403
+ # Section 10.1.6: publishing false declares the deployment OUT OF CONFORMANCE
404
+ # for privileged operations. The platform MUST refuse to transact real
405
+ # bookings or transmit buyer personal data -- refuse, not warn and continue.
406
+ return _refuse_or_warn(
407
+ posture,
408
+ defer=defer,
409
+ code="privileged_operations_unauthenticated",
410
+ message=(
411
+ f"{business_label} publishes "
412
+ f"privileged_operations_require_authentication=false, declaring itself "
413
+ f"out of conformance for privileged operations. Refusing to transact "
414
+ f"real bookings or send buyer personal data."
415
+ ),
416
+ )
417
+
418
+ mechanism = policy.select_mechanism()
419
+ if mechanism is None:
420
+ return _refuse_or_warn(
421
+ posture,
422
+ defer=defer,
423
+ code="no_supported_mechanism",
424
+ message=(
425
+ f"{business_label} accepts {policy.accepted_mechanisms!r}, none of which "
426
+ f"this client supports (supported: {list(SUPPORTED_MECHANISMS)!r})."
427
+ ),
428
+ )
429
+
430
+ return AuthorizationDecision(mechanism=mechanism, authenticate=True)
431
+
432
+
433
+ def _refuse_or_warn(
434
+ posture: ScopedCredentialPosture, *, code: str, message: str, defer: bool = False,
435
+ ) -> AuthorizationDecision:
436
+ if posture is ScopedCredentialPosture.REQUIRED:
437
+ if defer:
438
+ return AuthorizationDecision(
439
+ mechanism=None,
440
+ authenticate=False,
441
+ refusal_code=code,
442
+ refusal_message=message,
443
+ )
444
+ raise AuthorizationRefused(code, message)
445
+ logger.warning(
446
+ "%s Proceeding unauthenticated because %s=%s; this is not a conformant "
447
+ "configuration and privileged calls may be rejected.",
448
+ message, POSTURE_ENV_VAR, posture.value,
449
+ )
450
+ return AuthorizationDecision(mechanism=None, authenticate=False, warning=message)