wxcli 1.2.0__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 (413) hide show
  1. wxcli/__init__.py +4 -0
  2. wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
  3. wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
  4. wxcli/_playbook/.claude/rules/cleanup.md +29 -0
  5. wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
  6. wxcli/_playbook/.claude/rules/org-health.md +14 -0
  7. wxcli/_playbook/.claude/settings.json +8 -0
  8. wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
  9. wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
  10. wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
  11. wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
  12. wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
  13. wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
  14. wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
  15. wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
  16. wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
  17. wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
  18. wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
  19. wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
  20. wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
  21. wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
  22. wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
  23. wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
  24. wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
  25. wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
  26. wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
  27. wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
  28. wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
  29. wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
  30. wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
  31. wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
  32. wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
  33. wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
  34. wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
  35. wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
  36. wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
  37. wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
  38. wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
  39. wxcli/_playbook/CLAUDE.md +373 -0
  40. wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
  41. wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
  42. wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
  43. wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
  44. wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
  45. wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
  46. wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
  47. wxcli/_playbook/docs/reference/admin-partner.md +357 -0
  48. wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
  49. wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
  50. wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
  51. wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
  52. wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
  53. wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
  54. wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
  55. wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
  56. wxcli/_playbook/docs/reference/authentication.md +965 -0
  57. wxcli/_playbook/docs/reference/call-control.md +1205 -0
  58. wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
  59. wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
  60. wxcli/_playbook/docs/reference/call-routing.md +2304 -0
  61. wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
  62. wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
  63. wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
  64. wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
  65. wxcli/_playbook/docs/reference/devices-core.md +1869 -0
  66. wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
  67. wxcli/_playbook/docs/reference/devices-platform.md +467 -0
  68. wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
  69. wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
  70. wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
  71. wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
  72. wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
  73. wxcli/_playbook/docs/reference/meetings-content.md +473 -0
  74. wxcli/_playbook/docs/reference/meetings-core.md +903 -0
  75. wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
  76. wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
  77. wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
  78. wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
  79. wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
  80. wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
  81. wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
  82. wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
  83. wxcli/_playbook/docs/reference/provisioning.md +1366 -0
  84. wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
  85. wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
  86. wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
  87. wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
  88. wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
  89. wxcli/_version.py +24 -0
  90. wxcli/auth.py +137 -0
  91. wxcli/commands/__init__.py +0 -0
  92. wxcli/commands/_registry.py +181 -0
  93. wxcli/commands/activation_email.py +103 -0
  94. wxcli/commands/admin_recordings.py +585 -0
  95. wxcli/commands/analytics.py +105 -0
  96. wxcli/commands/announcement_playlists.py +219 -0
  97. wxcli/commands/announcements.py +519 -0
  98. wxcli/commands/archive_users.py +36 -0
  99. wxcli/commands/attachment_actions.py +60 -0
  100. wxcli/commands/audit_events.py +83 -0
  101. wxcli/commands/authorizations.py +113 -0
  102. wxcli/commands/auto_attendant.py +642 -0
  103. wxcli/commands/broadworks_billing_reports.py +126 -0
  104. wxcli/commands/broadworks_enterprises.py +160 -0
  105. wxcli/commands/broadworks_subscribers.py +319 -0
  106. wxcli/commands/broadworks_workspaces.py +108 -0
  107. wxcli/commands/call_controls.py +1304 -0
  108. wxcli/commands/call_park.py +494 -0
  109. wxcli/commands/call_pickup.py +229 -0
  110. wxcli/commands/call_queue.py +1878 -0
  111. wxcli/commands/call_recording.py +707 -0
  112. wxcli/commands/call_routing.py +1610 -0
  113. wxcli/commands/call_settings_for_me_phase_5.py +212 -0
  114. wxcli/commands/caller_reputation.py +169 -0
  115. wxcli/commands/calling_service.py +305 -0
  116. wxcli/commands/cc_address_book.py +770 -0
  117. wxcli/commands/cc_agent_greetings.py +242 -0
  118. wxcli/commands/cc_agent_summaries.py +39 -0
  119. wxcli/commands/cc_agent_wellbeing.py +216 -0
  120. wxcli/commands/cc_agents.py +425 -0
  121. wxcli/commands/cc_ai_assistant.py +55 -0
  122. wxcli/commands/cc_ai_feature.py +294 -0
  123. wxcli/commands/cc_audio_files.py +149 -0
  124. wxcli/commands/cc_auto_csat.py +311 -0
  125. wxcli/commands/cc_aux_code.py +458 -0
  126. wxcli/commands/cc_business_hour.py +353 -0
  127. wxcli/commands/cc_call_monitoring.py +228 -0
  128. wxcli/commands/cc_callbacks.py +194 -0
  129. wxcli/commands/cc_campaign.py +212 -0
  130. wxcli/commands/cc_captures.py +39 -0
  131. wxcli/commands/cc_contact_list.py +169 -0
  132. wxcli/commands/cc_contact_number.py +295 -0
  133. wxcli/commands/cc_data_sources.py +235 -0
  134. wxcli/commands/cc_desktop_layout.py +380 -0
  135. wxcli/commands/cc_desktop_profile.py +322 -0
  136. wxcli/commands/cc_dial_number.py +511 -0
  137. wxcli/commands/cc_dial_plan.py +347 -0
  138. wxcli/commands/cc_dnc.py +101 -0
  139. wxcli/commands/cc_entry_point.py +545 -0
  140. wxcli/commands/cc_ewt.py +51 -0
  141. wxcli/commands/cc_flow.py +477 -0
  142. wxcli/commands/cc_global_vars.py +430 -0
  143. wxcli/commands/cc_holiday_list.py +335 -0
  144. wxcli/commands/cc_journey.py +1261 -0
  145. wxcli/commands/cc_legacy_flows.py +49 -0
  146. wxcli/commands/cc_multimedia_profile.py +436 -0
  147. wxcli/commands/cc_notification.py +51 -0
  148. wxcli/commands/cc_outdial_ani.py +604 -0
  149. wxcli/commands/cc_overrides.py +341 -0
  150. wxcli/commands/cc_queue.py +1083 -0
  151. wxcli/commands/cc_queue_stats.py +55 -0
  152. wxcli/commands/cc_realtime.py +51 -0
  153. wxcli/commands/cc_resource_collection.py +312 -0
  154. wxcli/commands/cc_search.py +46 -0
  155. wxcli/commands/cc_site.py +400 -0
  156. wxcli/commands/cc_skill.py +350 -0
  157. wxcli/commands/cc_skill_profile.py +289 -0
  158. wxcli/commands/cc_subscriptions.py +426 -0
  159. wxcli/commands/cc_summaries.py +130 -0
  160. wxcli/commands/cc_tasks.py +844 -0
  161. wxcli/commands/cc_team.py +325 -0
  162. wxcli/commands/cc_user_profiles.py +349 -0
  163. wxcli/commands/cc_users.py +701 -0
  164. wxcli/commands/cc_work_types.py +363 -0
  165. wxcli/commands/cdr.py +89 -0
  166. wxcli/commands/classifications.py +38 -0
  167. wxcli/commands/cleanup.py +1427 -0
  168. wxcli/commands/client_settings.py +72 -0
  169. wxcli/commands/conference.py +328 -0
  170. wxcli/commands/configure.py +72 -0
  171. wxcli/commands/converged_recordings.py +347 -0
  172. wxcli/commands/converged_recordings_export.py +310 -0
  173. wxcli/commands/cq_playlists.py +42 -0
  174. wxcli/commands/cucm.py +2794 -0
  175. wxcli/commands/cucm_config.py +129 -0
  176. wxcli/commands/customer_assist.py +373 -0
  177. wxcli/commands/data_sources.py +227 -0
  178. wxcli/commands/dect_devices.py +787 -0
  179. wxcli/commands/device_configurations.py +77 -0
  180. wxcli/commands/device_dynamic_settings.py +320 -0
  181. wxcli/commands/device_settings.py +1489 -0
  182. wxcli/commands/devices.py +280 -0
  183. wxcli/commands/domains.py +142 -0
  184. wxcli/commands/ecm.py +178 -0
  185. wxcli/commands/emergency_services.py +827 -0
  186. wxcli/commands/events.py +83 -0
  187. wxcli/commands/external_voicemail.py +52 -0
  188. wxcli/commands/groups.py +206 -0
  189. wxcli/commands/guest_management.py +76 -0
  190. wxcli/commands/hds.py +340 -0
  191. wxcli/commands/hot_desk.py +67 -0
  192. wxcli/commands/hot_desking_members.py +118 -0
  193. wxcli/commands/hot_desking_portal.py +124 -0
  194. wxcli/commands/hunt_group.py +577 -0
  195. wxcli/commands/hybrid_clusters.py +71 -0
  196. wxcli/commands/hybrid_connectors.py +67 -0
  197. wxcli/commands/identity_org.py +159 -0
  198. wxcli/commands/init_playbook.py +155 -0
  199. wxcli/commands/licenses.py +106 -0
  200. wxcli/commands/live_monitoring.py +40 -0
  201. wxcli/commands/location_call_handling.py +577 -0
  202. wxcli/commands/location_schedules.py +341 -0
  203. wxcli/commands/location_settings.py +1297 -0
  204. wxcli/commands/location_voicemail.py +494 -0
  205. wxcli/commands/locations.py +336 -0
  206. wxcli/commands/meeting_captions.py +110 -0
  207. wxcli/commands/meeting_chats.py +63 -0
  208. wxcli/commands/meeting_invitees.py +234 -0
  209. wxcli/commands/meeting_messages.py +28 -0
  210. wxcli/commands/meeting_participants.py +278 -0
  211. wxcli/commands/meeting_polls.py +111 -0
  212. wxcli/commands/meeting_preferences.py +515 -0
  213. wxcli/commands/meeting_qa.py +78 -0
  214. wxcli/commands/meeting_qualities.py +43 -0
  215. wxcli/commands/meeting_reports.py +101 -0
  216. wxcli/commands/meeting_session_types.py +105 -0
  217. wxcli/commands/meeting_site.py +57 -0
  218. wxcli/commands/meeting_slido.py +44 -0
  219. wxcli/commands/meeting_summaries.py +92 -0
  220. wxcli/commands/meeting_tracking_codes.py +233 -0
  221. wxcli/commands/meeting_transcripts.py +230 -0
  222. wxcli/commands/meetings.py +1997 -0
  223. wxcli/commands/memberships.py +164 -0
  224. wxcli/commands/messages.py +213 -0
  225. wxcli/commands/mode_management.py +244 -0
  226. wxcli/commands/my_call_settings.py +3577 -0
  227. wxcli/commands/numbers.py +405 -0
  228. wxcli/commands/operating_modes.py +410 -0
  229. wxcli/commands/org_contacts.py +284 -0
  230. wxcli/commands/org_health_cli.py +55 -0
  231. wxcli/commands/org_settings.py +71 -0
  232. wxcli/commands/organizations.py +83 -0
  233. wxcli/commands/paging_group.py +257 -0
  234. wxcli/commands/partner_admins.py +141 -0
  235. wxcli/commands/partner_reports.py +188 -0
  236. wxcli/commands/partner_tags.py +216 -0
  237. wxcli/commands/people.py +279 -0
  238. wxcli/commands/person_call_settings.py +71 -0
  239. wxcli/commands/pstn.py +280 -0
  240. wxcli/commands/recording_report.py +154 -0
  241. wxcli/commands/report_templates.py +38 -0
  242. wxcli/commands/reports.py +144 -0
  243. wxcli/commands/resource_group_memberships.py +160 -0
  244. wxcli/commands/resource_groups.py +67 -0
  245. wxcli/commands/roles.py +63 -0
  246. wxcli/commands/room_tabs.py +160 -0
  247. wxcli/commands/rooms.py +225 -0
  248. wxcli/commands/scim_bulk.py +46 -0
  249. wxcli/commands/scim_groups.py +246 -0
  250. wxcli/commands/scim_schemas.py +82 -0
  251. wxcli/commands/scim_users.py +283 -0
  252. wxcli/commands/security_audit.py +54 -0
  253. wxcli/commands/service_apps.py +51 -0
  254. wxcli/commands/single_number_reach.py +209 -0
  255. wxcli/commands/team_memberships.py +155 -0
  256. wxcli/commands/teams.py +153 -0
  257. wxcli/commands/ucm_profile.py +42 -0
  258. wxcli/commands/update.py +219 -0
  259. wxcli/commands/user_settings.py +5116 -0
  260. wxcli/commands/video_mesh.py +1090 -0
  261. wxcli/commands/virtual_extensions.py +502 -0
  262. wxcli/commands/virtual_line_settings.py +2072 -0
  263. wxcli/commands/webhooks.py +176 -0
  264. wxcli/commands/wholesale_billing_reports.py +147 -0
  265. wxcli/commands/wholesale_provisioning.py +523 -0
  266. wxcli/commands/workspace_locations.py +339 -0
  267. wxcli/commands/workspace_metrics.py +103 -0
  268. wxcli/commands/workspace_personalization.py +70 -0
  269. wxcli/commands/workspace_settings.py +3347 -0
  270. wxcli/commands/workspaces.py +279 -0
  271. wxcli/commands/xapi.py +114 -0
  272. wxcli/config.py +139 -0
  273. wxcli/errors.py +77 -0
  274. wxcli/main.py +210 -0
  275. wxcli/migration/CLAUDE.md +106 -0
  276. wxcli/migration/__init__.py +1 -0
  277. wxcli/migration/advisory/CLAUDE.md +196 -0
  278. wxcli/migration/advisory/__init__.py +46 -0
  279. wxcli/migration/advisory/advisor.py +107 -0
  280. wxcli/migration/advisory/advisory_patterns.py +2552 -0
  281. wxcli/migration/advisory/recommendation_rules.py +835 -0
  282. wxcli/migration/cucm/CLAUDE.md +74 -0
  283. wxcli/migration/cucm/__init__.py +5 -0
  284. wxcli/migration/cucm/connection.py +265 -0
  285. wxcli/migration/cucm/discovery.py +225 -0
  286. wxcli/migration/cucm/extractors/__init__.py +1 -0
  287. wxcli/migration/cucm/extractors/announcements.py +92 -0
  288. wxcli/migration/cucm/extractors/base.py +90 -0
  289. wxcli/migration/cucm/extractors/device_profiles.py +98 -0
  290. wxcli/migration/cucm/extractors/devices.py +193 -0
  291. wxcli/migration/cucm/extractors/e911.py +103 -0
  292. wxcli/migration/cucm/extractors/features.py +309 -0
  293. wxcli/migration/cucm/extractors/helpers.py +73 -0
  294. wxcli/migration/cucm/extractors/informational.py +295 -0
  295. wxcli/migration/cucm/extractors/locations.py +221 -0
  296. wxcli/migration/cucm/extractors/moh.py +100 -0
  297. wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
  298. wxcli/migration/cucm/extractors/routing.py +493 -0
  299. wxcli/migration/cucm/extractors/shared_lines.py +146 -0
  300. wxcli/migration/cucm/extractors/templates.py +229 -0
  301. wxcli/migration/cucm/extractors/tier4.py +173 -0
  302. wxcli/migration/cucm/extractors/users.py +240 -0
  303. wxcli/migration/cucm/extractors/voicemail.py +243 -0
  304. wxcli/migration/cucm/extractors/workspaces.py +88 -0
  305. wxcli/migration/cucm/unity_connection.py +304 -0
  306. wxcli/migration/execute/CLAUDE.md +372 -0
  307. wxcli/migration/execute/__init__.py +303 -0
  308. wxcli/migration/execute/batch.py +277 -0
  309. wxcli/migration/execute/dependency.py +483 -0
  310. wxcli/migration/execute/engine.py +964 -0
  311. wxcli/migration/execute/handlers.py +2232 -0
  312. wxcli/migration/execute/planner.py +1939 -0
  313. wxcli/migration/execute/runtime.py +571 -0
  314. wxcli/migration/export/CLAUDE.md +49 -0
  315. wxcli/migration/export/__init__.py +8 -0
  316. wxcli/migration/export/csv_export.py +126 -0
  317. wxcli/migration/export/deployment_plan.py +479 -0
  318. wxcli/migration/export/json_export.py +60 -0
  319. wxcli/migration/models.py +941 -0
  320. wxcli/migration/phone_models.py +50 -0
  321. wxcli/migration/preflight/CLAUDE.md +52 -0
  322. wxcli/migration/preflight/__init__.py +89 -0
  323. wxcli/migration/preflight/checks.py +911 -0
  324. wxcli/migration/preflight/runner.py +293 -0
  325. wxcli/migration/rate_limiter.py +144 -0
  326. wxcli/migration/report/CLAUDE.md +245 -0
  327. wxcli/migration/report/__init__.py +7 -0
  328. wxcli/migration/report/appendix.py +2437 -0
  329. wxcli/migration/report/assembler.py +183 -0
  330. wxcli/migration/report/charts.py +263 -0
  331. wxcli/migration/report/executive.py +776 -0
  332. wxcli/migration/report/explainer.py +704 -0
  333. wxcli/migration/report/helpers.py +74 -0
  334. wxcli/migration/report/ingest.py +152 -0
  335. wxcli/migration/report/notice_templates.py +192 -0
  336. wxcli/migration/report/score.py +375 -0
  337. wxcli/migration/report/styles.py +1052 -0
  338. wxcli/migration/report/user_diff.py +1052 -0
  339. wxcli/migration/report/user_notice.py +425 -0
  340. wxcli/migration/state.py +139 -0
  341. wxcli/migration/store.py +860 -0
  342. wxcli/migration/transform/CLAUDE.md +107 -0
  343. wxcli/migration/transform/__init__.py +12 -0
  344. wxcli/migration/transform/analysis_pipeline.py +453 -0
  345. wxcli/migration/transform/analyzers/__init__.py +142 -0
  346. wxcli/migration/transform/analyzers/css_permission.py +189 -0
  347. wxcli/migration/transform/analyzers/css_routing.py +335 -0
  348. wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
  349. wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
  350. wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
  351. wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
  352. wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
  353. wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
  354. wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
  355. wxcli/migration/transform/analyzers/missing_data.py +300 -0
  356. wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
  357. wxcli/migration/transform/analyzers/shared_line.py +170 -0
  358. wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
  359. wxcli/migration/transform/analyzers/workspace_license.py +212 -0
  360. wxcli/migration/transform/cross_reference.py +1136 -0
  361. wxcli/migration/transform/cucm_pattern.py +246 -0
  362. wxcli/migration/transform/decisions.py +301 -0
  363. wxcli/migration/transform/e164.py +145 -0
  364. wxcli/migration/transform/engine.py +256 -0
  365. wxcli/migration/transform/mappers/CLAUDE.md +123 -0
  366. wxcli/migration/transform/mappers/__init__.py +76 -0
  367. wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
  368. wxcli/migration/transform/mappers/base.py +190 -0
  369. wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
  370. wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
  371. wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
  372. wxcli/migration/transform/mappers/css_mapper.py +868 -0
  373. wxcli/migration/transform/mappers/dect_mapper.py +367 -0
  374. wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
  375. wxcli/migration/transform/mappers/device_mapper.py +227 -0
  376. wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
  377. wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
  378. wxcli/migration/transform/mappers/e911_mapper.py +129 -0
  379. wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
  380. wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
  381. wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
  382. wxcli/migration/transform/mappers/line_mapper.py +310 -0
  383. wxcli/migration/transform/mappers/location_mapper.py +235 -0
  384. wxcli/migration/transform/mappers/moh_mapper.py +114 -0
  385. wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
  386. wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
  387. wxcli/migration/transform/mappers/routing_mapper.py +821 -0
  388. wxcli/migration/transform/mappers/snr_mapper.py +201 -0
  389. wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
  390. wxcli/migration/transform/mappers/user_mapper.py +264 -0
  391. wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
  392. wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
  393. wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
  394. wxcli/migration/transform/normalizers.py +2008 -0
  395. wxcli/migration/transform/pattern_converter.py +106 -0
  396. wxcli/migration/transform/pipeline.py +271 -0
  397. wxcli/migration/transform/rules.py +255 -0
  398. wxcli/org_health/CLAUDE.md +63 -0
  399. wxcli/org_health/__init__.py +7 -0
  400. wxcli/org_health/analyze.py +93 -0
  401. wxcli/org_health/checks.py +458 -0
  402. wxcli/org_health/collector.py +62 -0
  403. wxcli/org_health/models.py +78 -0
  404. wxcli/org_health/report.py +300 -0
  405. wxcli/output.py +101 -0
  406. wxcli-1.2.0.dist-info/METADATA +505 -0
  407. wxcli-1.2.0.dist-info/RECORD +413 -0
  408. wxcli-1.2.0.dist-info/WHEEL +5 -0
  409. wxcli-1.2.0.dist-info/entry_points.txt +2 -0
  410. wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
  411. wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
  412. wxcli-1.2.0.dist-info/scm_version.json +8 -0
  413. wxcli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,835 @@
1
+ """Per-decision recommendation rules.
2
+
3
+ Each function signature: (context: dict, options: list) -> tuple[str, str] | None
4
+ Returns (option_id, reasoning) or None if no recommendation can be made.
5
+
6
+ Reference: migration-advisory-design.md §5 (spec section 5).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Implemented rules — 6 simple types
14
+ # ---------------------------------------------------------------------------
15
+
16
+ _PROFESSIONAL_FEATURES = {
17
+ "callForwarding", "callerId", "monitoring", "intercept",
18
+ "callWaiting", "callRecording", "bargeIn", "pushToTalk",
19
+ }
20
+
21
+ _BASIC_FEATURES = {"musicOnHold", "doNotDisturb"}
22
+
23
+
24
+ def recommend_device_firmware_convertible(
25
+ context: dict[str, Any], options: list
26
+ ) -> tuple[str, str] | None:
27
+ """DEPRECATED 2026-04-15: no new DEVICE_FIRMWARE_CONVERTIBLE decisions
28
+ are produced — convertibility is a model classification, not a choice.
29
+ Retained so legacy stored projects still render a recommendation.
30
+
31
+ Spec §5.3: Convertible device — always recommend convert."""
32
+ model = context.get("cucm_model", "unknown")
33
+ reasoning = f"Device model {model} supports Webex firmware conversion. Convert to native MPP."
34
+ if context.get("has_srst"):
35
+ reasoning += (
36
+ " Note: Survivable Gateway (SRST) is configured. "
37
+ "Verify fallback behavior after conversion — Webex devices use Webex Edge for survivability."
38
+ )
39
+ return ("convert", reasoning)
40
+
41
+
42
+ def recommend_missing_data(
43
+ context: dict[str, Any], options: list
44
+ ) -> tuple[str, str] | None:
45
+ """Spec §5.15: Missing data decisions.
46
+
47
+ CRITICAL: Do not recommend "skip" for objects that have downstream dependents.
48
+ Skipping a location/trunk/user cascades to block all their devices/routes/features.
49
+ Return None (no recommendation) to force the LLM to ask the user.
50
+ """
51
+ dependent_count = context.get("dependent_count", 0)
52
+ missing_fields = context.get("missing_fields", [])
53
+ fields_str = ", ".join(missing_fields) if isinstance(missing_fields, list) else ""
54
+ object_type = context.get("object_type", "")
55
+
56
+ # Objects with dependents should NOT be auto-skipped — force human review
57
+ if dependent_count > 0:
58
+ return None
59
+
60
+ # Locations and trunks are infrastructure — always force review, never auto-skip
61
+ if object_type in ("location", "trunk", "route_group"):
62
+ return None
63
+
64
+ # Executive/assistant broken pair — force review unless permanently excluded
65
+ if context.get("missing_reason") == "executive_assistant_broken_pair":
66
+ if context.get("permanently_excluded"):
67
+ missing_side = context.get("missing_side", "user")
68
+ return (
69
+ "skip",
70
+ f"The missing {missing_side} is permanently excluded from migration. "
71
+ f"Executive/assistant pairing will need manual Webex configuration.",
72
+ )
73
+ return None # Force human review — missing user might be added later
74
+
75
+ # Leaf objects with no dependents can be safely skipped
76
+ if fields_str:
77
+ return (
78
+ "skip",
79
+ f"Missing required data: {fields_str}. Cannot migrate without this data.",
80
+ )
81
+ return (
82
+ "skip",
83
+ "Missing required data. Cannot migrate without the required information.",
84
+ )
85
+
86
+
87
+ def recommend_number_conflict(
88
+ context: dict[str, Any], options: list
89
+ ) -> tuple[str, str] | None:
90
+ """Spec §5.16: Number already assigned in Webex."""
91
+ if context.get("same_owner"):
92
+ return ("auto_resolve", "Same user owns this number in both systems. No conflict.")
93
+ existing_owner = context.get("existing_owner", "unknown")
94
+ cucm_owner = context.get("cucm_owner", "unknown")
95
+ return (
96
+ "keep_existing",
97
+ f"Number is already assigned to {existing_owner} in Webex. "
98
+ f"The CUCM owner ({cucm_owner}) will need a different number or "
99
+ "the existing assignment must be removed first.",
100
+ )
101
+
102
+
103
+ def recommend_duplicate_user(
104
+ context: dict[str, Any], options: list
105
+ ) -> tuple[str, str] | None:
106
+ """Spec §5.10: Duplicate user entries."""
107
+ if context.get("email_match"):
108
+ email = context.get("email", "unknown")
109
+ return ("merge", f"Both entries share email {email}. Merge into one Webex user.")
110
+ if context.get("userid_match"):
111
+ return (
112
+ "merge",
113
+ "Same CUCM userid. Merge; use the entry with the email address.",
114
+ )
115
+ return ("keep_both", "Same name but different emails. Likely different people.")
116
+
117
+
118
+ def recommend_workspace_license_tier(
119
+ context: dict[str, Any], options: list
120
+ ) -> tuple[str, str] | None:
121
+ """Spec §5.12: Workspace license tier recommendation."""
122
+ features_detected = context.get("features_detected", [])
123
+ professional_found = [f for f in features_detected if f in _PROFESSIONAL_FEATURES]
124
+ if professional_found:
125
+ features_str = ", ".join(professional_found)
126
+ return (
127
+ "professional",
128
+ f"Features detected: {features_str}. These require Professional workspace license.",
129
+ )
130
+ return (
131
+ "basic",
132
+ "Only basic features detected. Webex Calling Basic license is sufficient.",
133
+ )
134
+
135
+
136
+ def recommend_hotdesk_dn_conflict(
137
+ context: dict[str, Any], options: list
138
+ ) -> tuple[str, str] | None:
139
+ """Spec §5.14: Hotdesk DN conflict — always keep primary."""
140
+ return (
141
+ "keep_primary",
142
+ "Keep the primary DN and configure hoteling for the secondary. "
143
+ "Webex hoteling allows any user to log into the device temporarily.",
144
+ )
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Implemented rules — complex types
149
+ # ---------------------------------------------------------------------------
150
+
151
+ def recommend_feature_approximation(
152
+ context: dict[str, Any], options: list
153
+ ) -> tuple[str, str] | None:
154
+ """Spec §5.1: Feature approximation — CTI RP or Line Group → CQ/HG.
155
+
156
+ Also handles selective call handling decisions tagged with
157
+ `selective_call_handling_pattern` in context (spec
158
+ docs/superpowers/specs/2026-04-10-selective-call-forwarding.md §4c).
159
+ """
160
+ # Selective call handling — recommend accept with feature-specific reasoning.
161
+ sch_pattern = context.get("selective_call_handling_pattern")
162
+ if sch_pattern:
163
+ feature = context.get("recommended_webex_feature", "selective call handling")
164
+ confidence = (context.get("confidence") or "").upper()
165
+ if sch_pattern == "naming_convention" or confidence == "LOW":
166
+ note = (
167
+ " Note: this is a weak signal based on partition naming only — "
168
+ "verify the CUCM behaviour before configuring. Priority Alert "
169
+ "specifically requires user-only OAuth and cannot be configured "
170
+ "via admin token."
171
+ if feature.lower() == "priority alert"
172
+ else " Note: this is a weak signal based on partition naming "
173
+ "only — verify the CUCM behaviour before configuring."
174
+ )
175
+ return (
176
+ "accept",
177
+ f"CUCM CSS/partition pattern suggests caller-specific routing. "
178
+ f"Configure Webex {feature} post-migration to preserve this "
179
+ f"behaviour.{note}",
180
+ )
181
+ return (
182
+ "accept",
183
+ f"CUCM CSS/partition pattern suggests caller-specific routing. "
184
+ f"Configure Webex {feature} post-migration to preserve this "
185
+ f"behaviour. The pipeline cannot auto-create the rule because the "
186
+ f"phone-number criteria require operator review.",
187
+ )
188
+
189
+ # EM profile → hot desking: always recommend accept (no alternative exists)
190
+ if context.get("classification") == "EXTENSION_MOBILITY":
191
+ line_count = context.get("line_count", 0)
192
+ sd_count = context.get("speed_dial_count", 0)
193
+ blf_count = context.get("blf_count", 0)
194
+ has_feature_loss = line_count > 1 or sd_count > 0 or blf_count > 0
195
+ if has_feature_loss:
196
+ return (
197
+ "accept",
198
+ f"EM profile has {line_count} line(s) but Webex hot desking uses "
199
+ f"primary line only. Speed dials and BLF entries will not carry to "
200
+ f"hot desk sessions. Accept — no alternative to hot desking exists.",
201
+ )
202
+ return (
203
+ "accept",
204
+ "Simple EM profile — maps cleanly to Webex hot desking with primary line.",
205
+ )
206
+
207
+ classification = context.get("classification")
208
+ if classification == "AUTO_ATTENDANT":
209
+ if context.get("complex_script"):
210
+ return None
211
+ return (
212
+ "accept",
213
+ "Standard CTI Route Point with no complex scripting. "
214
+ "Maps directly to Webex Auto Attendant.",
215
+ )
216
+
217
+ has_queue = context.get("has_queue_features", False)
218
+ agent_count = context.get("agent_count", 0)
219
+ algorithm = context.get("algorithm") # legacy; still read below for small-group check
220
+ policy = context.get("policy") # preferred; Webex form from mapper
221
+
222
+ # Agent limit check — routing-type-aware (kb-webex-limits.md DT-LIMITS-001).
223
+ # SIMULTANEOUS caps at 50; REGULAR/CIRCULAR/UNIFORM/WEIGHTED cap at 1000.
224
+ # Prefer `policy` (Webex form set by FeatureMapper) over CUCM `algorithm` —
225
+ # the mapping is already done.
226
+ target_routing = context.get("target_routing_type") # explicit override (future scaffold)
227
+ if target_routing is not None:
228
+ is_simultaneous = target_routing.upper() == "SIMULTANEOUS"
229
+ elif policy is not None:
230
+ is_simultaneous = policy == "SIMULTANEOUS"
231
+ else:
232
+ # Fallback: legacy contexts without `policy`. Only "Broadcast" maps to
233
+ # simultaneous; "Top Down" was incorrectly treated as simultaneous in the
234
+ # pre-fix code (it actually maps to REGULAR).
235
+ is_simultaneous = algorithm in ("Broadcast", None)
236
+
237
+ # Cap from context if mapper provided it (post-fix); otherwise fall back
238
+ # to defaults for legacy/hand-constructed contexts. The mapper writes
239
+ # `agent_limit` with the policy-correct value from `_AGENT_LIMITS` —
240
+ # SIMULTANEOUS=50, WEIGHTED=100, REGULAR/CIRCULAR/UNIFORM=1000. Reading the
241
+ # context value avoids the prior bug where WEIGHTED hunt pilots exceeded
242
+ # their 100-cap but the recommender's hard-coded 1000 missed them.
243
+ context_limit = context.get("agent_limit")
244
+ simultaneous_cap = 50 # legacy default
245
+ priority_cap = 1000 # legacy default
246
+ if context_limit is not None:
247
+ if is_simultaneous:
248
+ simultaneous_cap = context_limit
249
+ else:
250
+ priority_cap = context_limit
251
+
252
+ if is_simultaneous and agent_count > simultaneous_cap:
253
+ routing_note = ""
254
+ # Only mark as "assumed" when we actually fell through the legacy
255
+ # algorithm fallback — i.e., neither `target_routing_type` nor `policy`
256
+ # was set. With `policy` populated by FeatureMapper, the SIMULTANEOUS
257
+ # detection is explicit, not assumed.
258
+ if (
259
+ target_routing is None
260
+ and policy is None
261
+ and algorithm in ("undefined", None)
262
+ ):
263
+ routing_note = " (assumed simultaneous — no algorithm detected in CUCM data)"
264
+ return (
265
+ "split",
266
+ f"Agent count ({agent_count}) exceeds Simultaneous routing cap of "
267
+ f"{simultaneous_cap}{routing_note}. Split into multiple queues with "
268
+ f"overflow chain, or switch to priority-based routing (REGULAR/"
269
+ f"CIRCULAR/UNIFORM support up to 1000).",
270
+ )
271
+ if not is_simultaneous and agent_count > priority_cap:
272
+ policy_label = policy or "priority-based"
273
+ return (
274
+ "split",
275
+ f"Agent count ({agent_count}) exceeds Call Queue limit of "
276
+ f"{priority_cap} for {policy_label} routing. Split into multiple "
277
+ f"queues with overflow chain.",
278
+ )
279
+
280
+ if has_queue:
281
+ return (
282
+ "call_queue",
283
+ f"Queue indicators detected. Call Queue preserves queuing, "
284
+ f"overflow routing, agent-level reporting, and wait-time management. "
285
+ f"Hunt Group loses all of these.",
286
+ )
287
+
288
+ if agent_count > 8:
289
+ return (
290
+ "call_queue",
291
+ f"Large agent group ({agent_count} agents). Even without explicit "
292
+ f"queue features, Call Queue provides reporting, overflow management, "
293
+ f"and wait-time handling at this scale.",
294
+ )
295
+
296
+ if agent_count <= 4 and algorithm in ("Top Down", "undefined", None):
297
+ return (
298
+ "hunt_group",
299
+ f"Small group ({agent_count} agents), no queue features, "
300
+ f"top-down ring order. This is a ring-group pattern — "
301
+ f"Hunt Group maps directly.",
302
+ )
303
+
304
+ # 5-8 agents, no queue, non-top-down → genuinely ambiguous
305
+ return None
306
+
307
+
308
+ _DEVICE_REPLACEMENT_MAP: dict[str, tuple[str, str]] = {
309
+ "7811": ("9841", "Same desk form factor, single-screen. 9841 is Webex-native (RoomOS). Note: RoomOS uses device configuration templates, not telephony device settings — different day-2 config model than MPP."),
310
+ "7821": ("9841", "2-line phone → 9841 supports more lines. Same price tier. 9841 uses RoomOS firmware (device configuration templates for day-2 config)."),
311
+ "7832": ("conference room device", "Conference phone. Consider Webex Room device (RoomOS) for both calling and meeting capability."),
312
+ "7905": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
313
+ "7906": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
314
+ "7911": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
315
+ "7912": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
316
+ "7940": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
317
+ "7941": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
318
+ "7942": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
319
+ "7945": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
320
+ "7960": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
321
+ "7961": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
322
+ "7962": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
323
+ "7965": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
324
+ "7970": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
325
+ "7971": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
326
+ "7975": ("8845 or 9851", "Legacy SCCP/SIP phone with no Webex firmware path. 8845 uses MPP firmware; 9851 uses RoomOS (larger screen). Different day-2 config models."),
327
+ "6901": ("8841 or 9841", "Cisco 69xx series. No Webex firmware. 8841 uses MPP firmware (same line count); 9841 uses RoomOS."),
328
+ "6911": ("8841 or 9841", "Cisco 69xx series. No Webex firmware. 8841 uses MPP firmware (same line count); 9841 uses RoomOS."),
329
+ "6921": ("8841 or 9841", "Cisco 69xx series. No Webex firmware. 8841 uses MPP firmware (same line count); 9841 uses RoomOS."),
330
+ "6941": ("8841 or 9841", "Cisco 69xx series. No Webex firmware. 8841 uses MPP firmware (same line count); 9841 uses RoomOS."),
331
+ "6945": ("8841 or 9841", "Cisco 69xx series. No Webex firmware. 8841 uses MPP firmware (same line count); 9841 uses RoomOS."),
332
+ "6961": ("8841 or 9841", "Cisco 69xx series. No Webex firmware. 8841 uses MPP firmware (same line count); 9841 uses RoomOS."),
333
+ "ATA 190": ("ATA 192", "Analog adapter. ATA 192 supports Webex Calling."),
334
+ "ATA 191": ("ATA 192", "Analog adapter. ATA 192 supports Webex Calling."),
335
+ "ATA190": ("ATA 192", "Analog adapter. ATA 192 supports Webex Calling."),
336
+ "ATA191": ("ATA 192", "Analog adapter. ATA 192 supports Webex Calling."),
337
+ }
338
+
339
+
340
+ def recommend_device_incompatible(
341
+ context: dict[str, Any], options: list
342
+ ) -> tuple[str, str] | None:
343
+ """Spec §5.2: Incompatible device — look up replacement recommendation."""
344
+ model = context.get("cucm_model", "")
345
+ entry = _DEVICE_REPLACEMENT_MAP.get(model)
346
+ if entry is None:
347
+ return None
348
+ replacement, base_reason = entry
349
+ reasoning = f"Device model {model} is not supported in Webex Calling. {base_reason}"
350
+ if context.get("button_count") or context.get("has_sidecar"):
351
+ reasoning += (
352
+ " Note: This device has expanded line key capacity. "
353
+ "Verify the replacement model supports equivalent line keys or sidecar."
354
+ )
355
+ return ("replace", f"Replace with {replacement}. {reasoning}")
356
+
357
+
358
+ def recommend_dn_ambiguous(
359
+ context: dict[str, Any], options: list
360
+ ) -> tuple[str, str] | None:
361
+ """Spec §5.4: Ambiguous DN ownership."""
362
+ owner_count = context.get("owner_count", 0)
363
+ if owner_count == 1:
364
+ owner_name = context.get("owner_name", "unknown")
365
+ return ("assign", f"DN has one clear owner ({owner_name}). Assign to this user.")
366
+ primary_owner = context.get("primary_owner")
367
+ if primary_owner:
368
+ return (
369
+ "assign",
370
+ f"DN is shared but {primary_owner} has it as line 1. "
371
+ "Assign to primary owner; other appearances become shared line or virtual extension.",
372
+ )
373
+ return None
374
+
375
+
376
+ def recommend_extension_conflict(
377
+ context: dict[str, Any], options: list
378
+ ) -> tuple[str, str] | None:
379
+ """Spec §5.5: Extension conflict between two owners."""
380
+ a_count = context.get("ext_a_appearances", 0)
381
+ b_count = context.get("ext_b_appearances", 0)
382
+ if a_count > b_count:
383
+ ext_a = context.get("ext_a", "unknown")
384
+ owner_a = context.get("owner_a", "unknown")
385
+ return (
386
+ "keep_a",
387
+ f"Extension {ext_a} is used on {a_count} devices for {owner_a}. "
388
+ "Reassign the conflicting extension.",
389
+ )
390
+ if b_count > a_count:
391
+ ext_b = context.get("ext_b", "unknown")
392
+ owner_b = context.get("owner_b", "unknown")
393
+ return (
394
+ "keep_b",
395
+ f"Extension {ext_b} is used on {b_count} devices for {owner_b}. "
396
+ "Reassign the conflicting extension.",
397
+ )
398
+ return None
399
+
400
+
401
+ _MONITORING_KEYWORDS = {"BLF", "Monitor", "Busy Lamp", "Speed", "DSS"}
402
+
403
+
404
+ def recommend_shared_line_complex(
405
+ context: dict[str, Any], options: list
406
+ ) -> tuple[str, str] | None:
407
+ """Spec §5.6: Shared line with complex appearance patterns."""
408
+ appearance_count = context.get("appearance_count", 0)
409
+ secondary_labels = context.get("secondary_labels", [])
410
+
411
+ # Check if ALL secondary labels contain a monitoring keyword
412
+ if secondary_labels:
413
+ all_monitoring = all(
414
+ any(kw.lower() in label.lower() for kw in _MONITORING_KEYWORDS)
415
+ for label in secondary_labels
416
+ )
417
+ if all_monitoring:
418
+ return (
419
+ "virtual_extension",
420
+ f"All {len(secondary_labels)} secondary appearances use monitoring "
421
+ f"labels ({', '.join(secondary_labels)}). Virtual extension with BLF "
422
+ f"monitoring is the correct Webex equivalent.",
423
+ )
424
+
425
+ # Low count without all-monitoring labels → shared line
426
+ if appearance_count <= 10:
427
+ return (
428
+ "shared_line",
429
+ f"Webex shared line supports up to 35 appearances. "
430
+ f"{appearance_count} appearances fits within limits.",
431
+ )
432
+
433
+ # High count with mixed usage → ambiguous
434
+ return None
435
+
436
+
437
+ def recommend_css_routing_mismatch(
438
+ context: dict[str, Any], options: list
439
+ ) -> tuple[str, str] | None:
440
+ """Spec §5.7: CSS routing mismatch — partition ordering, scope, or conflict."""
441
+ mismatch_type = context.get("mismatch_type")
442
+ if mismatch_type == "partition_ordering":
443
+ pattern = context.get("pattern", "unknown")
444
+ return (
445
+ "manual",
446
+ f"This CSS depends on partition ordering to resolve pattern '{pattern}'. "
447
+ f"Webex uses longest-match routing — partition ordering has no equivalent. "
448
+ f"Review manually.",
449
+ )
450
+ if mismatch_type == "scope_difference":
451
+ return (
452
+ "use_union",
453
+ "Union preserves all routing for all users at this location.",
454
+ )
455
+ if mismatch_type == "pattern_conflict":
456
+ pattern = context.get("pattern", "unknown")
457
+ route_a = context.get("route_a", "unknown")
458
+ route_b = context.get("route_b", "unknown")
459
+ dp_a = context.get("dp_a", "unknown")
460
+ dp_b = context.get("dp_b", "unknown")
461
+ return (
462
+ "manual",
463
+ f"Pattern '{pattern}' routes to {route_a} in {dp_a} and "
464
+ f"{route_b} in {dp_b}. The correct route depends on business intent.",
465
+ )
466
+ return None
467
+
468
+
469
+ _INTERNATIONAL_PREFIXES = ("011", "00", "+2", "+3", "+4", "+5", "+6", "+7", "+8", "+9")
470
+ _PREMIUM_PREFIXES = ("1900", "900", "976")
471
+
472
+
473
+ def recommend_calling_permission_mismatch(
474
+ context: dict[str, Any], options: list
475
+ ) -> tuple[str, str] | None:
476
+ """Spec §5.8: Calling permission mismatch — map block pattern to Webex call type."""
477
+ block_pattern = context.get("block_pattern", "")
478
+
479
+ for prefix in _INTERNATIONAL_PREFIXES:
480
+ if block_pattern.startswith(prefix):
481
+ return (
482
+ "INTERNATIONAL_CALL",
483
+ f"Block pattern '{block_pattern}' matches international dialing prefix "
484
+ f"'{prefix}'. Map to Webex outgoing call permission: INTERNATIONAL_CALL.",
485
+ )
486
+
487
+ for prefix in _PREMIUM_PREFIXES:
488
+ if block_pattern.startswith(prefix):
489
+ return (
490
+ "PREMIUM_SERVICES_NUMBER_ONE",
491
+ f"Block pattern '{block_pattern}' matches premium/toll prefix "
492
+ f"'{prefix}'. Map to Webex outgoing call permission: PREMIUM_SERVICES_NUMBER_ONE.",
493
+ )
494
+
495
+ return None
496
+
497
+
498
+ def recommend_location_ambiguous(
499
+ context: dict[str, Any], options: list
500
+ ) -> tuple[str, str] | None:
501
+ """Spec §5.9: Ambiguous location — consolidate if tz + region + site_code all match.
502
+
503
+ CRITICAL: If the location has no address and devices depend on it,
504
+ always recommend provide_address. Never recommend skip when devices
505
+ would be blocked — skip is not even offered as an option in that case.
506
+ """
507
+ dependent_count = context.get("dependent_device_count", 0)
508
+ has_address = context.get("has_address", False)
509
+
510
+ # If no address and devices depend on this location, always recommend provide_address
511
+ if not has_address and dependent_count > 0:
512
+ return (
513
+ "provide_address",
514
+ f"Location has {dependent_count} devices in its pools but no street address. "
515
+ f"Webex requires an address to create a location. Provide address1, city, state, "
516
+ f"postal_code, and country to proceed. Without an address, all {dependent_count} "
517
+ f"devices will be blocked from migration.",
518
+ )
519
+
520
+ timezone = context.get("timezone")
521
+ region = context.get("region")
522
+ site_code = context.get("site_code")
523
+ same_timezone = context.get("same_timezone")
524
+ same_region = context.get("same_region")
525
+
526
+ # Explicit same_timezone=True but same_region=False → can't consolidate
527
+ if same_timezone is True and same_region is False:
528
+ return None
529
+
530
+ if timezone and region and site_code:
531
+ return (
532
+ "consolidate",
533
+ f"All partitions share timezone ({timezone}), region ({region}), and site code ({site_code}). "
534
+ "Consolidate into a single Webex location.",
535
+ )
536
+ if timezone and region:
537
+ return (
538
+ "consolidate",
539
+ f"Partitions share timezone ({timezone}) and region ({region}). "
540
+ "Consolidate into a single Webex location.",
541
+ )
542
+
543
+ # No address info and no devices → genuinely ambiguous
544
+ return None
545
+
546
+
547
+ def recommend_voicemail_incompatible(
548
+ context: dict[str, Any], options: list
549
+ ) -> tuple[str, str] | None:
550
+ """Spec §5.11: Voicemail incompatible — map Unity settings to Webex voicemail."""
551
+ cfna_timeout = context.get("cfna_timeout")
552
+ unity_features = context.get("unity_features")
553
+
554
+ if cfna_timeout is not None:
555
+ rings = cfna_timeout // 6
556
+ return (
557
+ "webex_voicemail",
558
+ f"CUCM CFNA timeout {cfna_timeout}s maps to {rings} rings in Webex voicemail.",
559
+ )
560
+
561
+ if unity_features:
562
+ features_str = ", ".join(unity_features)
563
+ return (
564
+ "webex_voicemail",
565
+ f"Unity VM settings include {features_str}. "
566
+ f"Webex voicemail provides equivalent functionality.",
567
+ )
568
+
569
+ return (
570
+ "webex_voicemail",
571
+ "No Unity Connection data available. Recommend Webex voicemail with default settings (3 rings).",
572
+ )
573
+
574
+
575
+ _CONFERENCE_MODELS = {
576
+ "7832", "8832", "CP-7832", "CP-8832",
577
+ "7832-CE", "8832-CE",
578
+ }
579
+
580
+ _DESK_PHONE_MODELS = {
581
+ "6901", "6911", "6921", "6941", "6945", "6961",
582
+ "7811", "7821", "7832",
583
+ "7905", "7906", "7911", "7912", "7940", "7941", "7942", "7945",
584
+ "7960", "7961", "7962", "7965", "7970", "7971", "7975",
585
+ "8811", "8841", "8845", "8851", "8861", "8865",
586
+ "9811", "9821", "9841", "9851", "9861", "9871",
587
+ }
588
+
589
+
590
+ def recommend_workspace_type_uncertain(
591
+ context: dict[str, Any], options: list
592
+ ) -> tuple[str, str] | None:
593
+ """Spec §5.13: Uncertain workspace type — infer from device model and ownership."""
594
+ model = context.get("cucm_model", "")
595
+ if model in _CONFERENCE_MODELS:
596
+ return (
597
+ "conference_room",
598
+ f"Conference phone model ({model}) suggests conference room workspace.",
599
+ )
600
+ has_owner = context.get("has_owner")
601
+ if model in _DESK_PHONE_MODELS and has_owner is False:
602
+ return (
603
+ "common_area",
604
+ f"Desk phone ({model}) with no assigned user. Common area workspace.",
605
+ )
606
+ return None
607
+
608
+
609
+ def recommend_forwarding_lossy(
610
+ context: dict[str, Any], options: list,
611
+ ) -> tuple[str, str] | None:
612
+ """CUCM-only forwarding variants are rarely configured. Accept the loss."""
613
+ return ("accept", "The 7 CUCM-only variants (busyInt, noAnswerInt, noCoverage, "
614
+ "noCoverageInt, onFailure, notRegistered, notRegisteredInt) are rarely "
615
+ "configured; CFA/CFB/CFNA covers >95% of real forwarding behavior.")
616
+
617
+
618
+ def recommend_snr_lossy(
619
+ context: dict[str, Any], options: list,
620
+ ) -> tuple[str, str] | None:
621
+ """Timer controls rarely customized from defaults."""
622
+ return ("accept", "Timer controls (answerTooSoon/answerTooLate) are rarely "
623
+ "customized from defaults. Webex answerConfirmationEnabled provides "
624
+ "equivalent 'don't connect too soon' behavior.")
625
+
626
+
627
+ def recommend_audio_asset_manual(
628
+ context: dict[str, Any], options: list,
629
+ ) -> tuple[str, str] | None:
630
+ """Custom audio was intentional — always recommend manual migration."""
631
+ source_name = (
632
+ context.get("moh_source_name")
633
+ or context.get("source_name")
634
+ or context.get("name", "unknown")
635
+ )
636
+
637
+ if context.get("moh_source_name") or context.get("source_type") == "moh":
638
+ return (
639
+ "accept",
640
+ f"Custom MoH source '{source_name}' should be downloaded from CUCM TFTP "
641
+ "(/usr/local/cm/tftp/) and uploaded to Webex announcement repository. "
642
+ "Set location MoH to CUSTOM after upload.",
643
+ )
644
+
645
+ usage = context.get("usage", "")
646
+ if usage:
647
+ return (
648
+ "accept",
649
+ f"Customer-facing {usage} audio '{source_name}' should be downloaded from "
650
+ "CUCM and uploaded to Webex. Webex accepts WAV files up to 8 MB.",
651
+ )
652
+
653
+ return (
654
+ "accept",
655
+ f"Custom audio '{source_name}' should be downloaded from CUCM and uploaded "
656
+ "to Webex announcement repository for brand consistency.",
657
+ )
658
+
659
+
660
+ def recommend_button_unmappable(
661
+ context: dict[str, Any], options: list,
662
+ ) -> tuple[str, str] | None:
663
+ """Spec §6.1: CUCM-specific button types have no Webex line key equivalent."""
664
+ unmapped = context.get("unmapped_features", [])
665
+ if unmapped:
666
+ features_str = ", ".join(unmapped)
667
+ return (
668
+ "accept_loss",
669
+ f"CUCM button types ({features_str}) have no Webex equivalent. "
670
+ "These are CUCM-specific features (Service URL, Intercom, Privacy, etc.) "
671
+ "that don't exist in Webex Calling. No action is possible.",
672
+ )
673
+ return (
674
+ "accept_loss",
675
+ "CUCM-specific phone button types have no Webex line key equivalent. "
676
+ "These buttons will not be migrated. No action is possible.",
677
+ )
678
+
679
+
680
+ def recommend_e911_ecbn_ambiguous(
681
+ context: dict[str, Any], options: list
682
+ ) -> tuple[str, str] | None:
683
+ """Recommend the primary line's DID as ECBN when user has multiple DIDs.
684
+
685
+ (from 2026-04-10-e911-ecbn-execution.md §6.4)
686
+ """
687
+ primary_did = context.get("primary_did")
688
+ if not primary_did:
689
+ return None
690
+ option_id = f"did_{primary_did}"
691
+ option_ids = {o.get("id") for o in options}
692
+ if option_id not in option_ids:
693
+ return None
694
+ return (
695
+ option_id,
696
+ f"Primary line DID {primary_did} is the most common ECBN choice. "
697
+ f"The PSAP will call back this number if the emergency call is disconnected.",
698
+ )
699
+
700
+
701
+ def recommend_e911_location_mismatch(
702
+ context: dict[str, Any], options: list
703
+ ) -> tuple[str, str] | None:
704
+ """Recommend the user's assigned location for ECBN on mismatch."""
705
+ user_loc = context.get("user_location")
706
+ if not user_loc:
707
+ return None
708
+ option_ids = {o.get("id") for o in options}
709
+ if "use_user_location" not in option_ids:
710
+ return None
711
+ return (
712
+ "use_user_location",
713
+ f"The user's assigned location ({user_loc}) should determine PSAP routing "
714
+ "when user and device locations differ. If the user's phone is consistently "
715
+ "at a different physical site, update the user's assigned location first.",
716
+ )
717
+
718
+
719
+ def recommend_workspace_settings_professional_required(
720
+ context: dict[str, Any], options: list
721
+ ) -> tuple[str, str] | None:
722
+ """Workspace has settings that require Professional license but is on basic tier."""
723
+ dropped = context.get("dropped_settings", [])
724
+ if not dropped:
725
+ return None
726
+ settings_str = ", ".join(dropped)
727
+ return (
728
+ "accept_loss",
729
+ f"Settings requiring Professional Workspace license ({settings_str}) will be "
730
+ "lost with basic Workspace license. Most common-area phones don't need these — "
731
+ "upgrade only if the phone actively uses call forwarding or voicemail.",
732
+ )
733
+
734
+
735
+ def recommend_dect_network_design(
736
+ context: dict[str, Any], options: list
737
+ ) -> tuple[str, str] | None:
738
+ """Recommendation for DECT_NETWORK_DESIGN decisions.
739
+
740
+ Three sub-cases:
741
+ 1. No location resolved — no recommendation, operator must assign manually.
742
+ 2. No base station inventory — recommend accept (auto-size) unless operator has inventory.
743
+ 3. Multiple zones map to same location — recommend accept (merge).
744
+ """
745
+ option_ids = {o.get("id") for o in options}
746
+ base_stations_provided = context.get("base_stations_provided", False)
747
+ location_name = context.get("location_name")
748
+ zone_count = context.get("zone_count", 1)
749
+
750
+ # Case 1: no location resolved — force operator to assign
751
+ if location_name is None:
752
+ return None
753
+
754
+ # Case 2: no base station inventory — recommend accept (auto-size) with guidance
755
+ if not base_stations_provided and "accept" in option_ids:
756
+ pool = context.get("cucm_device_pool", "this zone")
757
+ handsets = context.get("total_handsets", 0)
758
+ return (
759
+ "accept",
760
+ f"Accept auto-sized DECT network for '{pool}' ({handsets} handset{'s' if handsets != 1 else ''}). "
761
+ f"The pipeline will create a single base station placeholder (DBS-110 for ≤30 handsets, "
762
+ f"DBS-210 otherwise). If you have base station MAC addresses, re-run discovery with "
763
+ f"--dect-inventory to enable fully automated provisioning instead.",
764
+ )
765
+
766
+ # Case 3: multiple zones same location — recommend accept (merge zones)
767
+ if zone_count > 1 and "accept" in option_ids:
768
+ loc = context.get("location_name", "this location")
769
+ return (
770
+ "accept",
771
+ f"{zone_count} CUCM device pools map to the same Webex location ({loc}). "
772
+ f"Accept to merge them into a single Webex DECT network. "
773
+ f"If the zones have independent base station coverage (e.g., separate buildings), "
774
+ f"use the 'manual' option to assign each zone to its own location.",
775
+ )
776
+
777
+ return None
778
+
779
+
780
+ def recommend_dect_handset_assignment(
781
+ context: dict[str, Any], options: list
782
+ ) -> tuple[str, str] | None:
783
+ """Recommendation for DECT_HANDSET_ASSIGNMENT decisions.
784
+
785
+ Unowned handsets (no CUCM user or device owner) default to a Webex workspace.
786
+ This is correct for common-area handsets (lobby, conference room, break room).
787
+ If the handset belongs to a specific person, the operator must supply the person.
788
+ """
789
+ option_ids = {o.get("id") for o in options}
790
+ owner_status = context.get("owner_status", "")
791
+ device_name = context.get("cucm_device_name", "this handset")
792
+
793
+ if owner_status == "unowned" and "accept" in option_ids:
794
+ return (
795
+ "accept",
796
+ f"Handset '{device_name}' has no CUCM owner. Creating a Webex workspace (PLACE) "
797
+ f"is correct for common-area handsets (lobby phones, shared DECT). "
798
+ f"If this handset belongs to a specific person, use the 'manual' option and "
799
+ f"provide their email address or Webex person ID.",
800
+ )
801
+
802
+ return None
803
+
804
+
805
+ # ---------------------------------------------------------------------------
806
+ # Dispatch table — ALL 19 DecisionType string values
807
+ # ---------------------------------------------------------------------------
808
+
809
+ RECOMMENDATION_DISPATCH: dict[str, Any] = {
810
+ "EXTENSION_CONFLICT": recommend_extension_conflict,
811
+ "DN_AMBIGUOUS": recommend_dn_ambiguous,
812
+ "DEVICE_INCOMPATIBLE": recommend_device_incompatible,
813
+ "DEVICE_FIRMWARE_CONVERTIBLE": recommend_device_firmware_convertible,
814
+ "SHARED_LINE_COMPLEX": recommend_shared_line_complex,
815
+ "CSS_ROUTING_MISMATCH": recommend_css_routing_mismatch,
816
+ "CALLING_PERMISSION_MISMATCH": recommend_calling_permission_mismatch,
817
+ "LOCATION_AMBIGUOUS": recommend_location_ambiguous,
818
+ "DUPLICATE_USER": recommend_duplicate_user,
819
+ "VOICEMAIL_INCOMPATIBLE": recommend_voicemail_incompatible,
820
+ "WORKSPACE_LICENSE_TIER": recommend_workspace_license_tier,
821
+ "WORKSPACE_TYPE_UNCERTAIN": recommend_workspace_type_uncertain,
822
+ "HOTDESK_DN_CONFLICT": recommend_hotdesk_dn_conflict,
823
+ "FEATURE_APPROXIMATION": recommend_feature_approximation,
824
+ "MISSING_DATA": recommend_missing_data,
825
+ "NUMBER_CONFLICT": recommend_number_conflict,
826
+ "FORWARDING_LOSSY": recommend_forwarding_lossy,
827
+ "SNR_LOSSY": recommend_snr_lossy,
828
+ "AUDIO_ASSET_MANUAL": recommend_audio_asset_manual,
829
+ "BUTTON_UNMAPPABLE": recommend_button_unmappable,
830
+ "E911_ECBN_AMBIGUOUS": recommend_e911_ecbn_ambiguous,
831
+ "E911_LOCATION_MISMATCH": recommend_e911_location_mismatch,
832
+ "WORKSPACE_SETTINGS_PROFESSIONAL_REQUIRED": recommend_workspace_settings_professional_required,
833
+ "DECT_NETWORK_DESIGN": recommend_dect_network_design,
834
+ "DECT_HANDSET_ASSIGNMENT": recommend_dect_handset_assignment,
835
+ }