@ngocsangairvds/vsaf 3.1.27 → 3.2.1

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 (303) hide show
  1. package/package.json +2 -2
  2. package/src/global.js +70 -10
  3. package/tools/skills/vds-scripts-skill/.openskills.json +6 -0
  4. package/tools/skills/vds-scripts-skill/QUALITY.md +44 -0
  5. package/tools/skills/vds-scripts-skill/SKILL.md +135 -0
  6. package/tools/skills/vds-scripts-skill/references/audit-commands.md +171 -0
  7. package/tools/skills/vds-scripts-skill/references/capability-index.md +34 -0
  8. package/tools/skills/vds-scripts-skill/references/development-commands.md +12 -0
  9. package/tools/skills/vds-scripts-skill/references/google-sheets.md +73 -0
  10. package/tools/skills/vds-scripts-skill/references/integration-commands.md +17 -0
  11. package/tools/skills/vds-scripts-skill/references/platform-bootstrap.md +31 -0
  12. package/tools/skills/vds-scripts-skill/references/specialist-routing.md +14 -0
  13. package/tools/skills/vds-scripts-skill/references/validation-commands.md +15 -0
  14. package/tools/skills/vsaf-build/SKILL.md +32 -2
  15. package/tools/skills/vsaf-ship/SKILL.md +41 -10
  16. package/tools/skills/vsaf-test/SKILL.md +8 -0
  17. package/tools/vds-scripts/.mcp.json +11 -0
  18. package/tools/vds-scripts/.secrets.baseline +133 -0
  19. package/tools/vds-scripts/AGENTS.md +152 -0
  20. package/tools/vds-scripts/CLAUDE.md +101 -0
  21. package/tools/vds-scripts/CLI_COMMAND_OPTIMIZATION.md +156 -0
  22. package/tools/vds-scripts/PACKAGE_P125B_IMPLEMENTATION_SUMMARY.md +131 -0
  23. package/tools/vds-scripts/PROJECT_COMPLETION_SUMMARY.md +45 -0
  24. package/tools/vds-scripts/README.md +97 -0
  25. package/tools/vds-scripts/bitbucket_manifest_mapping.toml +34 -0
  26. package/tools/vds-scripts/bitbucket_orchestrator/ARCHITECTURE_ANALYSIS.md +258 -0
  27. package/tools/vds-scripts/bitbucket_orchestrator/BITBUCKET_API_PRACTICES.md +393 -0
  28. package/tools/vds-scripts/bitbucket_orchestrator/EVALUATION_REPORT.md +61 -0
  29. package/tools/vds-scripts/bitbucket_orchestrator/FEATURES.md +908 -0
  30. package/tools/vds-scripts/bitbucket_orchestrator/README.md +687 -0
  31. package/tools/vds-scripts/bitbucket_orchestrator/pyproject.toml +40 -0
  32. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/__init__.py +20 -0
  33. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/async_client.py +657 -0
  34. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/cli.py +2108 -0
  35. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/client.py +2534 -0
  36. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/config.py +171 -0
  37. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/errors.py +67 -0
  38. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/factory.py +185 -0
  39. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/protocols.py +244 -0
  40. package/tools/vds-scripts/bitbucket_orchestrator/tests/__init__.py +8 -0
  41. package/tools/vds-scripts/bitbucket_orchestrator/tests/conftest.py +65 -0
  42. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_advanced_search.py +151 -0
  43. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_async_client.py +546 -0
  44. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_branch_permissions.py +145 -0
  45. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_cli.py +115 -0
  46. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client.py +157 -0
  47. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_branch_conditions.py +79 -0
  48. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_code_advanced.py +163 -0
  49. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_code_file.py +32 -0
  50. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_deployment_environments.py +194 -0
  51. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_issues.py +164 -0
  52. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_pipelines_advanced.py +179 -0
  53. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_pr_blockers.py +119 -0
  54. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_repository_variables.py +156 -0
  55. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code.py +98 -0
  56. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code_advanced.py +282 -0
  57. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code_insights.py +335 -0
  58. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_conditions.py +147 -0
  59. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_config.py +131 -0
  60. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_deployment_env.py +352 -0
  61. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_factory.py +371 -0
  62. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_fork_operations.py +204 -0
  63. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_issue_cli.py +261 -0
  64. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_pipeline_advanced.py +270 -0
  65. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_pr_blocker.py +204 -0
  66. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_protocols.py +334 -0
  67. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_repo_settings.py +343 -0
  68. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_repo_variables.py +270 -0
  69. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_webhooks.py +189 -0
  70. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_workspace.py +233 -0
  71. package/tools/vds-scripts/bitbucket_orchestrator/uv.lock +742 -0
  72. package/tools/vds-scripts/confluence_orchestrator/Dockerfile +19 -0
  73. package/tools/vds-scripts/confluence_orchestrator/README.md +412 -0
  74. package/tools/vds-scripts/confluence_orchestrator/SYNC_SCRIPTS.md +127 -0
  75. package/tools/vds-scripts/confluence_orchestrator/SYNC_STANDARDIZATION.md +108 -0
  76. package/tools/vds-scripts/confluence_orchestrator/pyproject.toml +48 -0
  77. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/__init__.py +20 -0
  78. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/cli.py +2532 -0
  79. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/config.py +175 -0
  80. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/content.py +290 -0
  81. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/content_v2.py +94 -0
  82. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/crawl_tree.py +1835 -0
  83. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/errors.py +80 -0
  84. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/eventing.py +109 -0
  85. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/http.py +1114 -0
  86. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/orchestration.py +165 -0
  87. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/reporting.py +78 -0
  88. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/tree.py +121 -0
  89. package/tools/vds-scripts/confluence_orchestrator/sync_pdfs_from_markdown.py +213 -0
  90. package/tools/vds-scripts/confluence_orchestrator/sync_pdfs_to_confluence.py +305 -0
  91. package/tools/vds-scripts/confluence_orchestrator/sync_png_attachments.py +305 -0
  92. package/tools/vds-scripts/confluence_orchestrator/tests/__init__.py +0 -0
  93. package/tools/vds-scripts/confluence_orchestrator/tests/conftest.py +8 -0
  94. package/tools/vds-scripts/confluence_orchestrator/tests/test_advanced_content.py +224 -0
  95. package/tools/vds-scripts/confluence_orchestrator/tests/test_advanced_search.py +188 -0
  96. package/tools/vds-scripts/confluence_orchestrator/tests/test_cache_management.py +247 -0
  97. package/tools/vds-scripts/confluence_orchestrator/tests/test_cli.py +499 -0
  98. package/tools/vds-scripts/confluence_orchestrator/tests/test_config.py +83 -0
  99. package/tools/vds-scripts/confluence_orchestrator/tests/test_content.py +186 -0
  100. package/tools/vds-scripts/confluence_orchestrator/tests/test_content_flags.py +27 -0
  101. package/tools/vds-scripts/confluence_orchestrator/tests/test_crawl_tree.py +2250 -0
  102. package/tools/vds-scripts/confluence_orchestrator/tests/test_draft_management.py +223 -0
  103. package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing.py +71 -0
  104. package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_chaos.py +37 -0
  105. package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_rate_limit.py +44 -0
  106. package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_timeout.py +49 -0
  107. package/tools/vds-scripts/confluence_orchestrator/tests/test_export.py +230 -0
  108. package/tools/vds-scripts/confluence_orchestrator/tests/test_history.py +204 -0
  109. package/tools/vds-scripts/confluence_orchestrator/tests/test_http.py +117 -0
  110. package/tools/vds-scripts/confluence_orchestrator/tests/test_orchestration.py +91 -0
  111. package/tools/vds-scripts/confluence_orchestrator/tests/test_reporting.py +24 -0
  112. package/tools/vds-scripts/confluence_orchestrator/tests/test_search_cql.py +34 -0
  113. package/tools/vds-scripts/confluence_orchestrator/tests/test_space_management.py +237 -0
  114. package/tools/vds-scripts/confluence_orchestrator/tests/test_space_permissions.py +332 -0
  115. package/tools/vds-scripts/confluence_orchestrator/tests/test_user_group_management.py +388 -0
  116. package/tools/vds-scripts/confluence_orchestrator/uv.lock +1023 -0
  117. package/tools/vds-scripts/git_orchestrator/ENHANCEMENT_SUMMARY.md +119 -0
  118. package/tools/vds-scripts/git_orchestrator/README.md +280 -0
  119. package/tools/vds-scripts/git_orchestrator/VERIFICATION_REPORT.md +152 -0
  120. package/tools/vds-scripts/git_orchestrator/pyproject.toml +35 -0
  121. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/__init__.py +7 -0
  122. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/__main__.py +4 -0
  123. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/cli.py +847 -0
  124. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/logging_config.py +63 -0
  125. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/manifest.py +129 -0
  126. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/orchestrator.py +819 -0
  127. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/reporting.py +53 -0
  128. package/tools/vds-scripts/git_orchestrator/tests/__init__.py +0 -0
  129. package/tools/vds-scripts/git_orchestrator/tests/test_cli_settings.py +21 -0
  130. package/tools/vds-scripts/git_orchestrator/tests/test_integration.py +74 -0
  131. package/tools/vds-scripts/git_orchestrator/tests/test_manifest.py +79 -0
  132. package/tools/vds-scripts/git_orchestrator/tests/test_orchestrator.py +204 -0
  133. package/tools/vds-scripts/git_orchestrator/tests/test_public_api.py +236 -0
  134. package/tools/vds-scripts/git_orchestrator/tests/test_resilience.py +345 -0
  135. package/tools/vds-scripts/git_orchestrator/uv.lock +271 -0
  136. package/tools/vds-scripts/jira_orchestrator/README.md +770 -0
  137. package/tools/vds-scripts/jira_orchestrator/pyproject.toml +39 -0
  138. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/__init__.py +1 -0
  139. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/adapter.py +1320 -0
  140. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/cli.py +2271 -0
  141. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/config.py +138 -0
  142. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/errors.py +67 -0
  143. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/reporting.py +65 -0
  144. package/tools/vds-scripts/jira_orchestrator/tests/__init__.py +1 -0
  145. package/tools/vds-scripts/jira_orchestrator/tests/conftest.py +86 -0
  146. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_agile_list_payloads.py +54 -0
  147. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_bulk_operations.py +69 -0
  148. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_components.py +57 -0
  149. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_createmeta.py +45 -0
  150. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_dashboard.py +117 -0
  151. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_issue_properties.py +54 -0
  152. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_permissions_compat.py +42 -0
  153. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_reindex.py +42 -0
  154. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_remote_links.py +76 -0
  155. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_transitions.py +91 -0
  156. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_user_management.py +110 -0
  157. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_version_management.py +133 -0
  158. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_watchers.py +41 -0
  159. package/tools/vds-scripts/jira_orchestrator/tests/test_advanced_search.py +164 -0
  160. package/tools/vds-scripts/jira_orchestrator/tests/test_agile.py +256 -0
  161. package/tools/vds-scripts/jira_orchestrator/tests/test_application_properties.py +193 -0
  162. package/tools/vds-scripts/jira_orchestrator/tests/test_backlog.py +91 -0
  163. package/tools/vds-scripts/jira_orchestrator/tests/test_bulk_operations.py +277 -0
  164. package/tools/vds-scripts/jira_orchestrator/tests/test_cli.py +106 -0
  165. package/tools/vds-scripts/jira_orchestrator/tests/test_components.py +106 -0
  166. package/tools/vds-scripts/jira_orchestrator/tests/test_config.py +164 -0
  167. package/tools/vds-scripts/jira_orchestrator/tests/test_dashboard.py +122 -0
  168. package/tools/vds-scripts/jira_orchestrator/tests/test_discover_fields.py +207 -0
  169. package/tools/vds-scripts/jira_orchestrator/tests/test_filter_management.py +333 -0
  170. package/tools/vds-scripts/jira_orchestrator/tests/test_issue_archiving.py +164 -0
  171. package/tools/vds-scripts/jira_orchestrator/tests/test_issue_links.py +257 -0
  172. package/tools/vds-scripts/jira_orchestrator/tests/test_issue_properties.py +171 -0
  173. package/tools/vds-scripts/jira_orchestrator/tests/test_link_types.py +314 -0
  174. package/tools/vds-scripts/jira_orchestrator/tests/test_parse_set.py +37 -0
  175. package/tools/vds-scripts/jira_orchestrator/tests/test_permissions.py +273 -0
  176. package/tools/vds-scripts/jira_orchestrator/tests/test_reindex.py +81 -0
  177. package/tools/vds-scripts/jira_orchestrator/tests/test_remote_links.py +254 -0
  178. package/tools/vds-scripts/jira_orchestrator/tests/test_security_schemes.py +170 -0
  179. package/tools/vds-scripts/jira_orchestrator/tests/test_transitions_changelog.py +114 -0
  180. package/tools/vds-scripts/jira_orchestrator/tests/test_user_management.py +226 -0
  181. package/tools/vds-scripts/jira_orchestrator/tests/test_version_management.py +339 -0
  182. package/tools/vds-scripts/jira_orchestrator/tests/test_watchers.py +101 -0
  183. package/tools/vds-scripts/jira_orchestrator/tests/test_worklog.py +223 -0
  184. package/tools/vds-scripts/jira_orchestrator/uv.lock +738 -0
  185. package/tools/vds-scripts/mcp_server/Dockerfile +34 -0
  186. package/tools/vds-scripts/mcp_server/README.md +140 -0
  187. package/tools/vds-scripts/mcp_server/pyproject.toml +42 -0
  188. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/__init__.py +4 -0
  189. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/config.py +36 -0
  190. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/server.py +66 -0
  191. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/__init__.py +14 -0
  192. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/bitbucket_tools.py +47 -0
  193. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/confluence_tools.py +59 -0
  194. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/git_tools.py +71 -0
  195. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/jira_tools.py +63 -0
  196. package/tools/vds-scripts/mcp_server/tests/__init__.py +2 -0
  197. package/tools/vds-scripts/mcp_server/tests/conftest.py +29 -0
  198. package/tools/vds-scripts/mcp_server/tests/unit/__init__.py +2 -0
  199. package/tools/vds-scripts/mcp_server/tests/unit/test_bitbucket_tools.py +25 -0
  200. package/tools/vds-scripts/mcp_server/tests/unit/test_confluence_tools.py +25 -0
  201. package/tools/vds-scripts/mcp_server/tests/unit/test_git_tools.py +32 -0
  202. package/tools/vds-scripts/mcp_server/tests/unit/test_jira_tools.py +32 -0
  203. package/tools/vds-scripts/mcp_server/tests/verification/__init__.py +2 -0
  204. package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_confluence_tools.py +40 -0
  205. package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_jira_tools.py +37 -0
  206. package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_tool_registration.py +47 -0
  207. package/tools/vds-scripts/mcp_server/uv.lock +1032 -0
  208. package/tools/vds-scripts/mypy.ini +5 -0
  209. package/tools/vds-scripts/pyproject.toml +29 -0
  210. package/tools/vds-scripts/repo-manifest.yaml +273 -0
  211. package/tools/vds-scripts/repo-manifest.yaml.example +25 -0
  212. package/tools/vds-scripts/scripts/BRD-Validation-API.postman_collection.json +706 -0
  213. package/tools/vds-scripts/scripts/BRD-Validation-README.md +308 -0
  214. package/tools/vds-scripts/scripts/README.md +162 -0
  215. package/tools/vds-scripts/scripts/bootstrap_uv.sh +30 -0
  216. package/tools/vds-scripts/scripts/brd-validation-environment.json +51 -0
  217. package/tools/vds-scripts/scripts/brd-validation-test-results.json +13023 -0
  218. package/tools/vds-scripts/scripts/brd_coverage_report.json +276 -0
  219. package/tools/vds-scripts/scripts/create_memory_session.py +35 -0
  220. package/tools/vds-scripts/scripts/deployment/load_docker_images_offline.sh +90 -0
  221. package/tools/vds-scripts/scripts/final_completion_report.md +139 -0
  222. package/tools/vds-scripts/scripts/folder_structure_report.json +321 -0
  223. package/tools/vds-scripts/scripts/generate_completion_report.py +125 -0
  224. package/tools/vds-scripts/scripts/generate_intellij_modules.py +150 -0
  225. package/tools/vds-scripts/scripts/link_integrity_report.json +807 -0
  226. package/tools/vds-scripts/scripts/move_audit_artifact_pages.py +255 -0
  227. package/tools/vds-scripts/scripts/move_audit_artifact_pages_rest.py +165 -0
  228. package/tools/vds-scripts/scripts/move_wrong_dept_pages.py +216 -0
  229. package/tools/vds-scripts/scripts/save_intellij_memories.py +120 -0
  230. package/tools/vds-scripts/scripts/save_memories_to_vds_ai.py +83 -0
  231. package/tools/vds-scripts/scripts/save_memories_vds_style.py +129 -0
  232. package/tools/vds-scripts/scripts/search_intellij_memories.py +50 -0
  233. package/tools/vds-scripts/scripts/setup_intellij_workspace.py +65 -0
  234. package/tools/vds-scripts/scripts/target-state-automation/README.md +89 -0
  235. package/tools/vds-scripts/scripts/target-state-automation/confluence_sync_coordinator.sh +27 -0
  236. package/tools/vds-scripts/scripts/target-state-automation/coordination.sh +114 -0
  237. package/tools/vds-scripts/scripts/target-state-automation/diagram_coordinator.sh +25 -0
  238. package/tools/vds-scripts/scripts/target-state-automation/docs_root.sh +22 -0
  239. package/tools/vds-scripts/scripts/target-state-automation/generate_diagrams.sh +22 -0
  240. package/tools/vds-scripts/scripts/target-state-automation/markdown_coordinator.sh +25 -0
  241. package/tools/vds-scripts/scripts/target-state-automation/progress_dashboard.sh +17 -0
  242. package/tools/vds-scripts/scripts/target-state-automation/schema_coordinator.sh +25 -0
  243. package/tools/vds-scripts/scripts/target-state-automation/sync_confluence.sh +30 -0
  244. package/tools/vds-scripts/scripts/target-state-automation/update_dependencies.sh +19 -0
  245. package/tools/vds-scripts/scripts/target-state-automation/validate_links.sh +86 -0
  246. package/tools/vds-scripts/scripts/target-state-automation/validate_markdown.sh +52 -0
  247. package/tools/vds-scripts/scripts/target-state-automation/validate_schemas.sh +26 -0
  248. package/tools/vds-scripts/scripts/target-state-automation/validate_structure.sh +98 -0
  249. package/tools/vds-scripts/scripts/update_modules_xml.py +190 -0
  250. package/tools/vds-scripts/scripts/uv-workspace-alignment-verification-2026-03-25.md +128 -0
  251. package/tools/vds-scripts/scripts/validate_brd_coverage.py +179 -0
  252. package/tools/vds-scripts/scripts/validate_folder_structure.py +240 -0
  253. package/tools/vds-scripts/scripts/validate_link_integrity.py +272 -0
  254. package/tools/vds-scripts/scripts/vds_sh_helpers.sh +180 -0
  255. package/tools/vds-scripts/scripts/verification/phase2_portable_paths_ubuntu_docker.sh +26 -0
  256. package/tools/vds-scripts/scripts/worktree_uv.sh +48 -0
  257. package/tools/vds-scripts/uv.lock +8 -0
  258. package/tools/vds-scripts/vds_cli/README.md +126 -0
  259. package/tools/vds-scripts/vds_cli/VERIFICATION_REPORT.md +41 -0
  260. package/tools/vds-scripts/vds_cli/pyproject.toml +38 -0
  261. package/tools/vds-scripts/vds_cli/src/vds_cli/__init__.py +3 -0
  262. package/tools/vds-scripts/vds_cli/src/vds_cli/cli.py +173 -0
  263. package/tools/vds-scripts/vds_cli/src/vds_cli/docs_sync.py +1203 -0
  264. package/tools/vds-scripts/vds_cli/src/vds_cli/env.py +41 -0
  265. package/tools/vds-scripts/vds_cli/src/vds_cli/google_sheets_orchestrator/__init__.py +3 -0
  266. package/tools/vds-scripts/vds_cli/src/vds_cli/google_sheets_orchestrator/google_sheets_orchestrator.py +198 -0
  267. package/tools/vds-scripts/vds_cli/src/vds_cli/router.py +93 -0
  268. package/tools/vds-scripts/vds_cli/src/vds_cli/sync_api.py +647 -0
  269. package/tools/vds-scripts/vds_cli/src/vds_cli/sync_service.py +266 -0
  270. package/tools/vds-scripts/vds_cli/tests/__init__.py +2 -0
  271. package/tools/vds-scripts/vds_cli/tests/conftest.py +49 -0
  272. package/tools/vds-scripts/vds_cli/tests/unit/__init__.py +2 -0
  273. package/tools/vds-scripts/vds_cli/tests/unit/test_cli.py +143 -0
  274. package/tools/vds-scripts/vds_cli/tests/unit/test_docs_sync.py +422 -0
  275. package/tools/vds-scripts/vds_cli/tests/unit/test_env.py +51 -0
  276. package/tools/vds-scripts/vds_cli/tests/unit/test_router.py +72 -0
  277. package/tools/vds-scripts/vds_cli/tests/unit/test_sync_api.py +357 -0
  278. package/tools/vds-scripts/vds_cli/tests/unit/test_sync_service.py +160 -0
  279. package/tools/vds-scripts/vds_cli/tests/verification/__init__.py +2 -0
  280. package/tools/vds-scripts/vds_cli/tests/verification/test_bitbucket_real.py +33 -0
  281. package/tools/vds-scripts/vds_cli/tests/verification/test_confluence_real.py +35 -0
  282. package/tools/vds-scripts/vds_cli/tests/verification/test_jira_real.py +41 -0
  283. package/tools/vds-scripts/vds_cli/uv.lock +524 -0
  284. package/tools/vds-scripts/vds_cli_common/README.md +190 -0
  285. package/tools/vds-scripts/vds_cli_common/pyproject.toml +92 -0
  286. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/__init__.py +34 -0
  287. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/completers.py +139 -0
  288. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/context.py +201 -0
  289. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/env.py +119 -0
  290. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/errors.py +318 -0
  291. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/output.py +284 -0
  292. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/paths.py +78 -0
  293. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/testing.py +213 -0
  294. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/version.py +85 -0
  295. package/tools/vds-scripts/vds_cli_common/tests/__init__.py +1 -0
  296. package/tools/vds-scripts/vds_cli_common/tests/test_completers.py +148 -0
  297. package/tools/vds-scripts/vds_cli_common/tests/test_context.py +192 -0
  298. package/tools/vds-scripts/vds_cli_common/tests/test_env.py +102 -0
  299. package/tools/vds-scripts/vds_cli_common/tests/test_errors.py +186 -0
  300. package/tools/vds-scripts/vds_cli_common/tests/test_output.py +229 -0
  301. package/tools/vds-scripts/vds_cli_common/tests/test_paths.py +61 -0
  302. package/tools/vds-scripts/vds_cli_common/tests/test_testing.py +138 -0
  303. package/tools/vds-scripts/vds_cli_common/tests/test_version.py +64 -0
@@ -0,0 +1,2532 @@
1
+ """Typer CLI wrapper for Confluence operations during the native Python implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import structlog
15
+ import typer
16
+
17
+ from .config import ConfluenceSettings, load_settings
18
+ from .content import ContentClient
19
+ from .content_v2 import ContentClientV2
20
+ from .errors import ConfluenceClientError
21
+ from .eventing import Poller
22
+ from .http import ConfluenceClient
23
+ from .orchestration import BatchRunner, WebhookClient
24
+ from .reporting import RunSummary, write_reports
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Typer setup & logging
28
+ # ---------------------------------------------------------------------------
29
+
30
+ app = typer.Typer(add_completion=False, help="Confluence operations orchestrator")
31
+ content_app = typer.Typer(help="Native Confluence API commands")
32
+ batch_app = typer.Typer(help="Batch polling utilities (REST v1)")
33
+ webhook_app = typer.Typer(help="Webhook management (REST v1)")
34
+ app.add_typer(content_app, name="content")
35
+ app.add_typer(batch_app, name="batch-native")
36
+ app.add_typer(webhook_app, name="webhook-native")
37
+
38
+ # Logger will be configured in main() callback
39
+ logger = logging.getLogger("confluence_cli")
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Paths and legacy script mapping
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _SCRIPTS_DIR = Path(__file__).resolve().parents[3]
46
+ _DEFAULT_REPORT_DIR = (_SCRIPTS_DIR / "reports" / "confluence_runs").resolve()
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class CLIContext:
51
+ report_dir: Path
52
+ markdown: bool
53
+ api_version: str
54
+ reports: bool
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Helper functions
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def _normalise_server(server: str | None, settings: ConfluenceSettings) -> str:
63
+ return server.lower() if server else settings.default_server.lower()
64
+
65
+
66
+ def _build_env(server: str, settings: ConfluenceSettings) -> dict:
67
+ token = settings.token_for(server)
68
+ if not token:
69
+ raise ConfluenceClientError(
70
+ f"Token for server '{server}' is missing. Run 'uv run --project vds_cli vds-cli env load'.",
71
+ context={"server": server},
72
+ )
73
+ env = os.environ.copy()
74
+ env.update(
75
+ {
76
+ "CONFLUENCE_SERVER": server,
77
+ "CONFLUENCE_TOKEN": token,
78
+ "CONFLUENCE_URL": str(settings.url_for(server)),
79
+ "INTERNAL_CONFLUENCE_TOKEN": settings.internal_token or "",
80
+ "EXTERNAL_CONFLUENCE_TOKEN": settings.external_token or "",
81
+ }
82
+ )
83
+ return env
84
+
85
+
86
+ def _build_http_client(settings: ConfluenceSettings, server: str) -> ConfluenceClient:
87
+ return ConfluenceClient(settings, server=server)
88
+
89
+
90
+ def _get_cli_ctx(ctx: typer.Context) -> CLIContext:
91
+ cli_ctx = ctx.obj
92
+ if not isinstance(cli_ctx, CLIContext):
93
+ raise RuntimeError("CLI context not initialised")
94
+ return cli_ctx
95
+
96
+
97
+ def _ensure_v1_api(ctx: typer.Context, command: str) -> None:
98
+ """Legacy command guard - redirects to native implementations."""
99
+ settings = load_settings(strict=False)
100
+ if settings is None:
101
+ typer.echo("Error: configuration not found", err=True)
102
+ raise typer.Exit(code=1) from None
103
+
104
+
105
+ def _build_content_client(
106
+ settings: ConfluenceSettings,
107
+ server: str,
108
+ api_version: str,
109
+ ):
110
+ http_client = _build_http_client(settings, server)
111
+ if not http_client.supports_api_version(api_version):
112
+ raise ConfluenceClientError(
113
+ f"API version '{api_version}' is not supported for server '{server}'.",
114
+ context={"server": server, "api_version": api_version},
115
+ )
116
+ if api_version == "v1":
117
+ return ContentClient(http_client)
118
+ if api_version == "v2":
119
+ return ContentClientV2(http_client)
120
+ raise ConfluenceClientError(f"Unsupported API version '{api_version}'")
121
+
122
+
123
+ def _record_summary(
124
+ ctx: typer.Context,
125
+ *,
126
+ command: str,
127
+ args: list[str],
128
+ server: str,
129
+ exit_code: int,
130
+ started_at: datetime,
131
+ finished_at: datetime,
132
+ ) -> None:
133
+ cli_ctx = _get_cli_ctx(ctx)
134
+ summary = RunSummary(
135
+ command=command,
136
+ args=args,
137
+ server=server,
138
+ exit_code=exit_code,
139
+ duration_ms=int((finished_at - started_at).total_seconds() * 1000),
140
+ started_at=started_at,
141
+ finished_at=finished_at,
142
+ )
143
+ if cli_ctx.reports:
144
+ write_reports(summary, base_dir=cli_ctx.report_dir, include_markdown=cli_ctx.markdown)
145
+
146
+
147
+ def _emit_json(payload: object) -> None:
148
+ text = json.dumps(payload, indent=2, sort_keys=True)
149
+ if sys.stdout.isatty():
150
+ typer.echo(text)
151
+ else:
152
+ sys.stdout.write(text + "\n")
153
+
154
+
155
+ def _expand_option(value: str | None) -> list[str] | None:
156
+ if not value:
157
+ return None
158
+ return [part.strip() for part in value.split(",") if part.strip()]
159
+
160
+
161
+ def _content_summary_args(
162
+ *, cql: str | None = None, limit: int | None = None, start: int | None = None, space: str | None = None
163
+ ) -> list[str]:
164
+ parts: list[str] = []
165
+ if cql is not None:
166
+ parts.append(cql)
167
+ if limit is not None:
168
+ parts.append(f"limit={limit}")
169
+ if start is not None:
170
+ parts.append(f"start={start}")
171
+ if space is not None:
172
+ parts.append(f"space={space}")
173
+ return parts
174
+
175
+
176
+ def _read_text_file(path: Path) -> str:
177
+ if not path.exists():
178
+ raise typer.BadParameter(f"File not found: {path}")
179
+ if path.is_dir():
180
+ raise typer.BadParameter(f"Expected file but found directory: {path}")
181
+ try:
182
+ return path.read_text(encoding="utf-8")
183
+ except OSError as exc:
184
+ raise typer.BadParameter(f"Unable to read file {path}: {exc}") from exc
185
+
186
+
187
+ def _parse_json_option(value: str | None, option_name: str) -> dict[str, Any] | None:
188
+ if value is None:
189
+ return None
190
+ try:
191
+ parsed = json.loads(value)
192
+ except json.JSONDecodeError as exc:
193
+ raise typer.BadParameter(f"Invalid JSON for {option_name}: {exc}") from exc
194
+ if not isinstance(parsed, dict):
195
+ raise typer.BadParameter(f"{option_name} must decode to a JSON object")
196
+ return parsed
197
+
198
+
199
+ def _parent_context(ctx: typer.Context) -> typer.Context:
200
+ return ctx.parent or ctx
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Typer callbacks & native commands
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ @app.callback()
209
+ def main(
210
+ ctx: typer.Context,
211
+ report_dir: Path | None = typer.Option(None, help=f"Directory for run reports (default: {_DEFAULT_REPORT_DIR})"),
212
+ markdown: bool = typer.Option(True, help="Emit Markdown summaries alongside JSON reports"),
213
+ reports: bool = typer.Option(True, "--reports/--no-reports", help="Persist run reports to disk"),
214
+ json_only: bool = typer.Option(
215
+ False,
216
+ "--json-only",
217
+ help="Emit JSON to stdout only (disables reports and markdown)",
218
+ ),
219
+ api_version: str = typer.Option("v1", "--api-version", "-A", help="Select REST API version (v1 or v2)"),
220
+ structured_logs: bool = typer.Option(
221
+ False, "--structured-logs/--no-structured-logs", help="Emit structured JSON logs to stderr"
222
+ ),
223
+ ) -> None:
224
+ resolved = report_dir.resolve() if report_dir else _DEFAULT_REPORT_DIR
225
+ version = api_version.lower()
226
+ if version not in {"v1", "v2"}:
227
+ raise typer.BadParameter("api-version must be one of: v1, v2")
228
+ if json_only:
229
+ markdown = False
230
+ reports = False
231
+
232
+ # Configure logging/structlog
233
+ if structured_logs:
234
+ structlog.configure(
235
+ processors=[
236
+ structlog.processors.TimeStamper(fmt="iso"),
237
+ structlog.stdlib.add_log_level,
238
+ structlog.processors.StackInfoRenderer(),
239
+ structlog.processors.format_exc_info,
240
+ structlog.processors.JSONRenderer(),
241
+ ],
242
+ logger_factory=structlog.stdlib.LoggerFactory(),
243
+ wrapper_class=structlog.stdlib.BoundLogger,
244
+ cache_logger_on_first_use=True,
245
+ )
246
+ global logger # noqa: PLW0603
247
+ logger = structlog.get_logger("confluence_cli") # type: ignore[assignment]
248
+ else:
249
+ if json_only:
250
+ logging.basicConfig(level=logging.CRITICAL, stream=sys.stderr)
251
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(logging.CRITICAL))
252
+ else:
253
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(levelname)s %(name)s: %(message)s")
254
+
255
+ ctx.obj = CLIContext(report_dir=resolved, markdown=markdown, api_version=version, reports=reports)
256
+
257
+
258
+ @app.command("templates")
259
+ def cmd_templates(
260
+ ctx: typer.Context,
261
+ args: list[str] | None = typer.Argument(
262
+ None, help="Additional arguments for template operations", show_default=False, metavar="ARGS"
263
+ ),
264
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
265
+ limit: int = typer.Option(20, "--limit", "-l", help="Number of templates to list"),
266
+ space_key: str | None = typer.Option(None, "--space", "-k", help="Space key filter"),
267
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
268
+ ) -> None:
269
+ """Native Python template management"""
270
+ parent_ctx = _parent_context(ctx)
271
+ started_at = datetime.now(UTC)
272
+
273
+ try:
274
+ settings = load_settings(strict=True)
275
+ server_key = _normalise_server(server, settings)
276
+ client = _build_content_client(settings, server_key, parent_ctx.obj.api_version)
277
+
278
+ # Use native Python implementation
279
+ if hasattr(client, "list_templates"):
280
+ result = client.list_templates(space_key=space_key)
281
+ else:
282
+ result = {"templates": [], "message": "Templates API not available for this server"}
283
+
284
+ if json_output:
285
+ _emit_json(result)
286
+ else:
287
+ templates_payload = []
288
+ if isinstance(result, dict):
289
+ templates_payload = (
290
+ result.get("blueprints")
291
+ or result.get("templates")
292
+ or result.get("results")
293
+ or result.get("contentTemplates")
294
+ or []
295
+ )
296
+ elif isinstance(result, list):
297
+ templates_payload = result
298
+
299
+ count = len(templates_payload)
300
+ typer.echo(f"Found {count} templates:")
301
+ for template in templates_payload[:limit]:
302
+ if isinstance(template, dict):
303
+ name = template.get("name", "Unknown")
304
+ template_id = template.get("templateId") or template.get("id") or template.get("uuid") or "Unknown"
305
+ else:
306
+ name = str(template)
307
+ template_id = "Unknown"
308
+ typer.echo(f" - {name} (ID: {template_id})")
309
+
310
+ exit_code = 0
311
+ finished_at = datetime.now(UTC)
312
+ _record_summary(
313
+ parent_ctx,
314
+ command="templates",
315
+ args=args or [],
316
+ server=server_key,
317
+ exit_code=exit_code,
318
+ started_at=started_at,
319
+ finished_at=finished_at,
320
+ )
321
+
322
+ if exit_code != 0:
323
+ raise typer.Exit(code=exit_code) from None
324
+
325
+ except Exception as exc:
326
+ logger.error("templates_command_failed: %s", exc)
327
+ typer.echo(f"Error: {exc}", err=True)
328
+ raise typer.Exit(code=1) from None
329
+
330
+
331
+ @app.command("batch")
332
+ def cmd_batch(
333
+ ctx: typer.Context,
334
+ args: list[str] | None = typer.Argument(
335
+ None, help="Use 'confluence content batch --help' for available subcommands", show_default=False, metavar="ARGS"
336
+ ),
337
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
338
+ ) -> None:
339
+ """Legacy batch command - use 'confluence content batch' subcommands instead."""
340
+ typer.echo("Error: The 'batch' command is deprecated. Use 'confluence content batch' subcommands:", err=True)
341
+ typer.echo(" confluence content batch scan --cql '<query>' [options]", err=True)
342
+ typer.echo(" confluence content batch snapshot --cql '<query>' [options]", err=True)
343
+ raise typer.Exit(code=1) from None
344
+
345
+
346
+ @app.command("webhooks")
347
+ def cmd_webhooks(
348
+ ctx: typer.Context,
349
+ args: list[str] | None = typer.Argument(
350
+ None, help="Additional arguments for webhook operations", show_default=False, metavar="ARGS"
351
+ ),
352
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
353
+ list_webhooks: bool = typer.Option(False, "--list", "-l", help="List existing webhooks"),
354
+ create_name: str | None = typer.Option(None, "--name", "-n", help="Webhook name"),
355
+ create_url: str | None = typer.Option(None, "--url", "-u", help="Webhook URL"),
356
+ create_event: str | None = typer.Option(None, "--event", "-e", help="Event type"),
357
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
358
+ ) -> None:
359
+ """Native Python webhook management"""
360
+ parent_ctx = _parent_context(ctx)
361
+ started_at = datetime.now(UTC)
362
+
363
+ try:
364
+ settings = load_settings(strict=True)
365
+ server_key = _normalise_server(server, settings)
366
+ webhook_client = WebhookClient(_build_http_client(settings, server_key))
367
+
368
+ if list_webhooks or (not create_name and not create_url and not create_event):
369
+ # List webhooks
370
+ result = webhook_client.list()
371
+ if json_output or not sys.stdout.isatty():
372
+ _emit_json(result)
373
+ else:
374
+ webhooks = result.get("webhooks", [])
375
+ count = len(webhooks)
376
+ typer.echo(f"Found {count} webhooks:")
377
+ for webhook in webhooks:
378
+ name = webhook.get("name", "Unknown")
379
+ url = webhook.get("url", "Unknown")
380
+ events = webhook.get("events", [])
381
+ typer.echo(f" - {name}: {url} (events: {', '.join(events)})")
382
+
383
+ elif create_name and create_url and create_event:
384
+ # Create webhook
385
+ result = webhook_client.create(name=create_name, url=create_url, events=[create_event])
386
+ if json_output or not sys.stdout.isatty():
387
+ _emit_json(result)
388
+ else:
389
+ webhook_id = result.get("id", "Unknown")
390
+ typer.echo(f"Created webhook '{create_name}' with ID: {webhook_id}")
391
+
392
+ else:
393
+ typer.echo(
394
+ "Error: Either use --list to list webhooks or provide --name, --url, and --event to create", err=True
395
+ )
396
+ raise typer.Exit(code=1) from None
397
+
398
+ exit_code = 0
399
+ finished_at = datetime.now(UTC)
400
+ _record_summary(
401
+ parent_ctx,
402
+ command="webhooks",
403
+ args=args or [],
404
+ server=server_key,
405
+ exit_code=exit_code,
406
+ started_at=started_at,
407
+ finished_at=finished_at,
408
+ )
409
+
410
+ if exit_code != 0:
411
+ raise typer.Exit(code=exit_code) from None
412
+
413
+ except Exception as exc:
414
+ logger.error("webhooks_command_failed: %s", exc)
415
+ typer.echo(f"Error: {exc}", err=True)
416
+ raise typer.Exit(code=1) from None
417
+
418
+
419
+ @app.command("search")
420
+ def cmd_search(
421
+ ctx: typer.Context,
422
+ args: list[str] | None = typer.Argument(
423
+ None, help="Additional arguments for CQL search", show_default=False, metavar="ARGS"
424
+ ),
425
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
426
+ cql: str = typer.Option(..., "--cql", "-q", help="CQL query string"),
427
+ limit: int = typer.Option(25, "--limit", "-l", help="Number of results"),
428
+ start: int = typer.Option(0, "--start", help="Pagination start"),
429
+ expand: str | None = typer.Option(None, "--expand", help="Comma separated expansions"),
430
+ excerpt: str | None = typer.Option(None, "--excerpt", help="Excerpt strategy (e.g., 'highlighted', 'indexed')"),
431
+ advanced: bool = typer.Option(False, "--advanced", help="Use advanced CQL search with additional options"),
432
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
433
+ ) -> None:
434
+ """Native Python CQL search. Use --advanced for additional options like excerpt."""
435
+ parent_ctx = _parent_context(ctx)
436
+ started_at = datetime.now(UTC)
437
+
438
+ try:
439
+ settings = load_settings(strict=True)
440
+ server_key = _normalise_server(server, settings)
441
+ client = _build_content_client(settings, server_key, parent_ctx.obj.api_version)
442
+
443
+ # Use native Python implementation
444
+ expand_list = _expand_option(expand) if expand else None
445
+ if advanced or excerpt:
446
+ result = client.cql_advanced(cql, limit=limit, start=start, expand=expand_list, excerpt=excerpt)
447
+ else:
448
+ result = client.search_cql(cql, limit=limit, start=start, expand=expand_list)
449
+
450
+ if json_output or not sys.stdout.isatty():
451
+ _emit_json(result)
452
+ else:
453
+ results = result.get("results", [])
454
+ count = len(results)
455
+ total = result.get("size", 0)
456
+ typer.echo(f"Found {count} of {total} results for CQL: {cql}")
457
+ for item in results:
458
+ title = item.get("title", "Unknown")
459
+ page_id = item.get("id", "Unknown")
460
+ space = item.get("space", {}).get("key", "Unknown")
461
+ typer.echo(f" - {title} (ID: {page_id}, Space: {space})")
462
+
463
+ exit_code = 0
464
+ finished_at = datetime.now(UTC)
465
+ _record_summary(
466
+ parent_ctx,
467
+ command="search",
468
+ args=args or [],
469
+ server=server_key,
470
+ exit_code=exit_code,
471
+ started_at=started_at,
472
+ finished_at=finished_at,
473
+ )
474
+
475
+ if exit_code != 0:
476
+ raise typer.Exit(code=exit_code) from None
477
+
478
+ except Exception as exc:
479
+ logger.error("search_command_failed: %s", exc)
480
+
481
+
482
+ @app.command("search-by-space-type")
483
+ def cmd_search_by_space_type(
484
+ ctx: typer.Context,
485
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
486
+ space_key: str | None = typer.Option(None, "--space", help="Space key to filter by"),
487
+ content_type: str | None = typer.Option(
488
+ None, "--type", help="Content type (e.g., 'page', 'blogpost', 'comment')"
489
+ ),
490
+ limit: int = typer.Option(25, "--limit", "-l", help="Number of results"),
491
+ start: int = typer.Option(0, "--start", help="Pagination start"),
492
+ expand: str | None = typer.Option(None, "--expand", help="Comma separated expansions"),
493
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
494
+ ) -> None:
495
+ """Search by space and/or content type (helper that builds CQL automatically)."""
496
+ parent_ctx = _parent_context(ctx)
497
+ started_at = datetime.now(UTC)
498
+ exit_code = 0
499
+
500
+ try:
501
+ if not space_key and not content_type:
502
+ raise typer.BadParameter("At least one of --space or --type must be provided")
503
+
504
+ settings = load_settings(strict=True)
505
+ server_key = _normalise_server(server, settings)
506
+ client = _build_content_client(settings, server_key, parent_ctx.obj.api_version)
507
+
508
+ expand_list = _expand_option(expand) if expand else None
509
+ result = client.search_by_space_and_type(
510
+ space_key=space_key,
511
+ content_type=content_type,
512
+ limit=limit,
513
+ start=start,
514
+ expand=expand_list,
515
+ )
516
+
517
+ if json_output or not sys.stdout.isatty():
518
+ _emit_json(result)
519
+ else:
520
+ results = result.get("results", [])
521
+ count = len(results)
522
+ total = result.get("size", 0)
523
+ typer.echo(f"Found {count} of {total} results")
524
+ for item in results:
525
+ title = item.get("title", "Unknown")
526
+ page_id = item.get("id", "Unknown")
527
+ space = item.get("space", {}).get("key", "Unknown")
528
+ item_type = item.get("type", "Unknown")
529
+ typer.echo(f" - {title} (ID: {page_id}, Space: {space}, Type: {item_type})")
530
+
531
+ exit_code = 0
532
+ except (typer.BadParameter, Exception) as exc:
533
+ exit_code = 1
534
+ logger.error("search_by_space_type_failed: %s", exc)
535
+ typer.echo(f"Error: {exc}", err=True)
536
+ finally:
537
+ finished_at = datetime.now(UTC)
538
+ args_list: list[str] = []
539
+ if space_key:
540
+ args_list.append(f"space={space_key}")
541
+ if content_type:
542
+ args_list.append(f"type={content_type}")
543
+ _record_summary(
544
+ parent_ctx,
545
+ command="search-by-space-type",
546
+ args=args_list,
547
+ server=server_key if "server_key" in locals() else "internal",
548
+ exit_code=exit_code,
549
+ started_at=started_at,
550
+ finished_at=finished_at,
551
+ )
552
+ if exit_code != 0:
553
+ raise typer.Exit(code=exit_code) from None
554
+
555
+
556
+ @app.command("attachments")
557
+ def cmd_attachments(
558
+ ctx: typer.Context,
559
+ args: list[str] | None = typer.Argument(
560
+ None, help="Additional arguments for attachment operations", show_default=False, metavar="ARGS"
561
+ ),
562
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
563
+ page_id: str | None = typer.Option(None, "--page", "-p", help="Page ID for attachments"),
564
+ list_attachments: bool = typer.Option(False, "--list", "-l", help="List attachments for page"),
565
+ upload_file: str | None = typer.Option(None, "--upload", "-f", help="File to upload"),
566
+ comment: str | None = typer.Option(None, "--comment", "-c", help="Upload comment"),
567
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
568
+ ) -> None:
569
+ """Native Python attachment management"""
570
+ parent_ctx = _parent_context(ctx)
571
+ started_at = datetime.now(UTC)
572
+
573
+ try:
574
+ settings = load_settings(strict=True)
575
+ server_key = _normalise_server(server, settings)
576
+ client = _build_content_client(settings, server_key, parent_ctx.obj.api_version)
577
+
578
+ if list_attachments or (page_id and not upload_file):
579
+ # List attachments
580
+ if not page_id:
581
+ typer.echo("Error: --page ID required for listing attachments", err=True)
582
+ raise typer.Exit(code=1) from None
583
+
584
+ result = client.list_attachments(page_id)
585
+ if json_output or not sys.stdout.isatty():
586
+ _emit_json(result)
587
+ else:
588
+ attachments = result.get("results", [])
589
+ count = len(attachments)
590
+ typer.echo(f"Found {count} attachments for page {page_id}:")
591
+ for attachment in attachments:
592
+ name = attachment.get("title", "Unknown")
593
+ size = attachment.get("size", 0)
594
+ typer.echo(f" - {name} ({size} bytes)")
595
+
596
+ elif upload_file and page_id:
597
+ # Upload attachment
598
+ file_path = Path(upload_file)
599
+ if not file_path.exists():
600
+ typer.echo(f"Error: File not found: {upload_file}", err=True)
601
+ raise typer.Exit(code=1) from None
602
+
603
+ result = client.upload_attachment(page_id, file_path, comment=comment)
604
+ if json_output or not sys.stdout.isatty():
605
+ _emit_json(result)
606
+ else:
607
+ attachment_id = result.get("id", "Unknown")
608
+ typer.echo(f"Uploaded attachment '{file_path.name}' with ID: {attachment_id}")
609
+
610
+ else:
611
+ typer.echo("Error: Either use --list with --page or provide --page and --upload", err=True)
612
+ raise typer.Exit(code=1) from None
613
+
614
+ exit_code = 0
615
+ finished_at = datetime.now(UTC)
616
+ _record_summary(
617
+ parent_ctx,
618
+ command="attachments",
619
+ args=args or [],
620
+ server=server_key,
621
+ exit_code=exit_code,
622
+ started_at=started_at,
623
+ finished_at=finished_at,
624
+ )
625
+
626
+ if exit_code != 0:
627
+ raise typer.Exit(code=exit_code) from None
628
+
629
+ except Exception as exc:
630
+ logger.error("attachments_command_failed: %s", exc)
631
+ typer.echo(f"Error: {exc}", err=True)
632
+ raise typer.Exit(code=1) from None
633
+
634
+
635
+ @app.command("space-permissions")
636
+ def cmd_space_permissions(
637
+ ctx: typer.Context,
638
+ action: str = typer.Argument(..., help="Action: get, set, remove"),
639
+ space_key: str = typer.Option(..., "--space-key", "-k", help="Confluence space key"),
640
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
641
+ user_key: str | None = typer.Option(None, "--user", "-u", help="User key for user-targeted operations"),
642
+ group_name: str | None = typer.Option(None, "--group", "-g", help="Group name for group-targeted operations"),
643
+ anonymous: bool = typer.Option(False, "--anonymous", help="Target anonymous permissions"),
644
+ operations: list[str] = typer.Option(
645
+ [],
646
+ "--operation",
647
+ "-o",
648
+ help="Space permission operation key (repeatable, e.g., administer, read)",
649
+ ),
650
+ permissions_file: Path | None = typer.Option(
651
+ None,
652
+ "--permissions-file",
653
+ help="JSON file with bulk permission definitions (uses set_permissions_to_multiple_items_for_space)",
654
+ ),
655
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations"),
656
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
657
+ ) -> None:
658
+ """Manage Confluence space permissions."""
659
+
660
+ parent_ctx = _parent_context(ctx)
661
+ started_at = datetime.now(UTC)
662
+ exit_code = 0
663
+ result_payload: dict[str, Any] = {}
664
+ args_summary: list[str] = []
665
+ server_key: str | None = None
666
+ settings: ConfluenceSettings | None = None
667
+
668
+ try:
669
+ settings = load_settings(strict=True)
670
+ server_key = _normalise_server(server, settings)
671
+ client = _build_http_client(settings, server_key)
672
+
673
+ resolved_operations = operations or None
674
+ args_summary = [
675
+ action,
676
+ f"space={space_key}",
677
+ f"user={user_key}",
678
+ f"group={group_name}",
679
+ f"anonymous={anonymous}",
680
+ f"operations={operations}",
681
+ f"permissions_file={permissions_file}",
682
+ ]
683
+
684
+ if action == "get":
685
+ result = client.get_space_permissions(space_key)
686
+ result_payload = {"space": space_key, "permissions": result}
687
+
688
+ elif action == "set":
689
+ if not yes:
690
+ raise typer.BadParameter("--yes required for write operations")
691
+
692
+ targets = [bool(user_key), bool(group_name), anonymous, bool(permissions_file)]
693
+ if sum(1 for flag in targets if flag) != 1:
694
+ raise typer.BadParameter(
695
+ "Provide exactly one of --user, --group, --anonymous, or --permissions-file for set action"
696
+ )
697
+
698
+ if permissions_file:
699
+ file_content = _read_text_file(permissions_file)
700
+ try:
701
+ parsed = json.loads(file_content)
702
+ except json.JSONDecodeError as exc:
703
+ raise typer.BadParameter(f"Invalid JSON in permissions file: {exc}") from exc
704
+
705
+ if isinstance(parsed, dict):
706
+ items: list[dict[str, Any]] = [parsed]
707
+ elif isinstance(parsed, list):
708
+ if not all(isinstance(item, dict) for item in parsed):
709
+ raise typer.BadParameter("--permissions-file list entries must be JSON objects")
710
+ items = parsed # type: ignore[assignment]
711
+ else:
712
+ raise typer.BadParameter("--permissions-file must contain a JSON object or array")
713
+
714
+ result = client.set_permissions_to_multiple_items_for_space(space_key, items)
715
+ result_payload = {"space": space_key, "bulk": True, "items_processed": len(items), "result": result}
716
+
717
+ elif user_key:
718
+ if not resolved_operations:
719
+ raise typer.BadParameter("At least one --operation required when setting user permissions")
720
+ result = client.set_permissions_to_user_for_space(
721
+ space_key,
722
+ user_key,
723
+ operations=resolved_operations,
724
+ )
725
+ result_payload = {
726
+ "space": space_key,
727
+ "target": {"type": "user", "key": user_key},
728
+ "operations": resolved_operations,
729
+ "result": result,
730
+ }
731
+
732
+ elif group_name:
733
+ if not resolved_operations:
734
+ raise typer.BadParameter("At least one --operation required when setting group permissions")
735
+ result = client.set_permissions_to_group_for_space(
736
+ space_key,
737
+ group_name,
738
+ operations=resolved_operations,
739
+ )
740
+ result_payload = {
741
+ "space": space_key,
742
+ "target": {"type": "group", "name": group_name},
743
+ "operations": resolved_operations,
744
+ "result": result,
745
+ }
746
+
747
+ elif anonymous:
748
+ if not resolved_operations:
749
+ raise typer.BadParameter("At least one --operation required when setting anonymous permissions")
750
+ result = client.set_permissions_to_anonymous_for_space(
751
+ space_key,
752
+ operations=resolved_operations,
753
+ )
754
+ result_payload = {
755
+ "space": space_key,
756
+ "target": {"type": "anonymous"},
757
+ "operations": resolved_operations,
758
+ "result": result,
759
+ }
760
+
761
+ elif action == "remove":
762
+ if not yes:
763
+ raise typer.BadParameter("--yes required for write operations")
764
+
765
+ targets = [bool(user_key), bool(group_name), anonymous]
766
+ if sum(1 for flag in targets if flag) != 1:
767
+ raise typer.BadParameter("Provide exactly one of --user, --group, or --anonymous for remove action")
768
+
769
+ if user_key:
770
+ result = client.remove_permissions_from_user_for_space(space_key, user_key)
771
+ result_payload = {
772
+ "space": space_key,
773
+ "target": {"type": "user", "key": user_key},
774
+ "result": result,
775
+ }
776
+
777
+ elif group_name:
778
+ result = client.remove_permissions_from_group_for_space(space_key, group_name)
779
+ result_payload = {
780
+ "space": space_key,
781
+ "target": {"type": "group", "name": group_name},
782
+ "result": result,
783
+ }
784
+
785
+ elif anonymous:
786
+ result = client.remove_permissions_from_anonymous_for_space(space_key)
787
+ result_payload = {
788
+ "space": space_key,
789
+ "target": {"type": "anonymous"},
790
+ "result": result,
791
+ }
792
+
793
+ else:
794
+ raise typer.BadParameter(f"Unknown action: {action}")
795
+
796
+ if json_output or not sys.stdout.isatty():
797
+ _emit_json(result_payload)
798
+ else:
799
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
800
+
801
+ except (ConfluenceClientError, typer.BadParameter) as exc:
802
+ exit_code = 1
803
+ logger.error("space_permissions_command_failed: %s", exc)
804
+ typer.echo(f"Error: {exc}", err=True)
805
+ except Exception as exc: # pragma: no cover - unexpected errors
806
+ exit_code = 1
807
+ logger.exception("space_permissions_command_unexpected")
808
+ typer.echo(f"Error: {exc}", err=True)
809
+ finally:
810
+ finished_at = datetime.now(UTC)
811
+ server_label = server_key or (server.lower() if server else "internal")
812
+ _record_summary(
813
+ parent_ctx,
814
+ command="space-permissions",
815
+ args=args_summary,
816
+ server=server_label,
817
+ exit_code=exit_code,
818
+ started_at=started_at,
819
+ finished_at=finished_at,
820
+ )
821
+ if exit_code != 0:
822
+ raise typer.Exit(code=exit_code) from None
823
+
824
+
825
+ @app.command("space-management")
826
+ def cmd_space_management(
827
+ ctx: typer.Context,
828
+ action: str = typer.Argument(..., help="Action: archive, trash-list, trash-remove"),
829
+ space_key: str = typer.Option(..., "--space-key", "-k", help="Confluence space key"),
830
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
831
+ cursor: str | None = typer.Option(None, "--cursor", help="Pagination cursor for trash-list"),
832
+ limit: int = typer.Option(50, "--limit", help="Limit for trash-list (max 100)"),
833
+ expand: str | None = typer.Option(None, "--expand", help="Comma separated expansions for trash-list"),
834
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations"),
835
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
836
+ ) -> None:
837
+ """Manage Confluence space lifecycle operations."""
838
+
839
+ parent_ctx = _parent_context(ctx)
840
+ started_at = datetime.now(UTC)
841
+ exit_code = 0
842
+ result_payload: dict[str, Any] = {}
843
+ args_summary: list[str] = []
844
+ server_key: str | None = None
845
+ settings: ConfluenceSettings | None = None
846
+
847
+ try:
848
+ if limit < 1 or limit > 100:
849
+ raise typer.BadParameter("--limit must be between 1 and 100")
850
+
851
+ settings = load_settings(strict=True)
852
+ server_key = _normalise_server(server, settings)
853
+ client = _build_http_client(settings, server_key)
854
+
855
+ expand_value = expand if expand else None
856
+ args_summary = [
857
+ action,
858
+ f"space={space_key}",
859
+ f"cursor={cursor}",
860
+ f"limit={limit}",
861
+ f"expand={expand_value}",
862
+ ]
863
+
864
+ if action == "archive":
865
+ if not yes:
866
+ raise typer.BadParameter("--yes required for archive")
867
+ result = client.archive_space(space_key)
868
+ result_payload = {"space": space_key, "action": "archive", "result": result}
869
+
870
+ elif action == "trash-list":
871
+ result = client.get_trashed_contents_by_space(
872
+ space_key,
873
+ cursor=cursor,
874
+ expand=expand_value,
875
+ limit=limit,
876
+ )
877
+ result_payload = {
878
+ "space": space_key,
879
+ "action": "trash-list",
880
+ "result": result,
881
+ }
882
+
883
+ elif action == "trash-remove":
884
+ if not yes:
885
+ raise typer.BadParameter("--yes required for trash-remove")
886
+ result = client.remove_trashed_contents_by_space(space_key)
887
+ result_payload = {"space": space_key, "action": "trash-remove", "result": result}
888
+
889
+ else:
890
+ raise typer.BadParameter(f"Unknown action: {action}")
891
+
892
+ if json_output or not sys.stdout.isatty():
893
+ _emit_json(result_payload)
894
+ else:
895
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
896
+
897
+ except (ConfluenceClientError, typer.BadParameter) as exc:
898
+ exit_code = 1
899
+ logger.error("space_management_failed: %s", exc)
900
+ typer.echo(f"Error: {exc}", err=True)
901
+ except Exception as exc: # pragma: no cover - unexpected errors
902
+ exit_code = 1
903
+ logger.exception("space_management_unexpected_error")
904
+ typer.echo(f"Error: {exc}", err=True)
905
+ finally:
906
+ finished_at = datetime.now(UTC)
907
+ server_label = server_key or (server.lower() if server else "internal")
908
+ _record_summary(
909
+ parent_ctx,
910
+ command="space-management",
911
+ args=args_summary,
912
+ server=server_label,
913
+ exit_code=exit_code,
914
+ started_at=started_at,
915
+ finished_at=finished_at,
916
+ )
917
+ if exit_code != 0:
918
+ raise typer.Exit(code=exit_code) from None
919
+
920
+
921
+ @app.command("group")
922
+ def cmd_group(
923
+ ctx: typer.Context,
924
+ action: str = typer.Argument(..., help="Action: list, members"),
925
+ group_name: str | None = typer.Option(None, "--group", "-g", help="Group name (for members)"),
926
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
927
+ start: int = typer.Option(0, "--start", help="Start index for pagination"),
928
+ limit: int = typer.Option(1000, "--limit", help="Limit for pagination (max 1000)"),
929
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
930
+ ) -> None:
931
+ """Manage Confluence groups."""
932
+ parent_ctx = _parent_context(ctx)
933
+ started_at = datetime.now(UTC)
934
+ exit_code = 0
935
+ result_payload: dict[str, Any] = {}
936
+ args_summary: list[str] = []
937
+ server_key: str | None = None
938
+ settings: ConfluenceSettings | None = None
939
+
940
+ try:
941
+ if limit < 1 or limit > 1000:
942
+ raise typer.BadParameter("--limit must be between 1 and 1000")
943
+
944
+ settings = load_settings(strict=True)
945
+ server_key = _normalise_server(server, settings)
946
+ client = _build_http_client(settings, server_key)
947
+
948
+ args_summary = [action, f"start={start}", f"limit={limit}"]
949
+
950
+ if action == "list":
951
+ result = client.get_all_groups(start=start, limit=limit)
952
+ result_payload = {"count": len(result), "groups": result}
953
+
954
+ elif action == "members":
955
+ if not group_name:
956
+ raise typer.BadParameter("--group required for members action")
957
+ args_summary.append(f"group={group_name}")
958
+ result = client.get_group_members(group_name, start=start, limit=limit)
959
+ result_payload = {"group": group_name, "count": len(result), "members": result}
960
+
961
+ else:
962
+ raise typer.BadParameter(f"Unknown action: {action}")
963
+
964
+ if json_output or not sys.stdout.isatty():
965
+ _emit_json(result_payload)
966
+ else:
967
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
968
+
969
+ except (ConfluenceClientError, typer.BadParameter) as exc:
970
+ exit_code = 1
971
+ logger.error("group_failed: %s", exc)
972
+ typer.echo(f"Error: {exc}", err=True)
973
+ except Exception as exc: # pragma: no cover - unexpected errors
974
+ exit_code = 1
975
+ logger.exception("group_unexpected_error")
976
+ typer.echo(f"Error: {exc}", err=True)
977
+ finally:
978
+ finished_at = datetime.now(UTC)
979
+ server_label = server_key or (server.lower() if server else "internal")
980
+ _record_summary(
981
+ parent_ctx,
982
+ command="group",
983
+ args=args_summary,
984
+ server=server_label,
985
+ exit_code=exit_code,
986
+ started_at=started_at,
987
+ finished_at=finished_at,
988
+ )
989
+ if exit_code != 0:
990
+ raise typer.Exit(code=exit_code) from None
991
+
992
+
993
+ @app.command("user")
994
+ def cmd_user(
995
+ ctx: typer.Context,
996
+ action: str = typer.Argument(..., help="Action: get, password, group-add, group-remove"),
997
+ username: str | None = typer.Option(None, "--username", "-u", help="Username"),
998
+ userkey: str | None = typer.Option(None, "--userkey", help="User key"),
999
+ group_name: str | None = typer.Option(None, "--group", "-g", help="Group name (for group-add/group-remove)"),
1000
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1001
+ expand: str | None = typer.Option(None, "--expand", help="Comma-separated fields to expand (for get)"),
1002
+ new_password: str | None = typer.Option(None, "--new-password", help="New password (for password)"),
1003
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations"),
1004
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
1005
+ ) -> None:
1006
+ """Manage Confluence users."""
1007
+ parent_ctx = _parent_context(ctx)
1008
+ started_at = datetime.now(UTC)
1009
+ exit_code = 0
1010
+ result_payload: dict[str, Any] = {}
1011
+ args_summary: list[str] = []
1012
+ server_key: str | None = None
1013
+ settings: ConfluenceSettings | None = None
1014
+
1015
+ try:
1016
+ settings = load_settings(strict=True)
1017
+ server_key = _normalise_server(server, settings)
1018
+ client = _build_http_client(settings, server_key)
1019
+
1020
+ args_summary = [action]
1021
+
1022
+ if action == "get":
1023
+ if not username and not userkey:
1024
+ raise typer.BadParameter("Provide --username or --userkey for get action")
1025
+ if username and userkey:
1026
+ raise typer.BadParameter("Provide either --username or --userkey, not both")
1027
+ expand_value = expand if expand else None
1028
+ args_summary.append(f"username={username}" if username else f"userkey={userkey}")
1029
+ if expand_value:
1030
+ args_summary.append(f"expand={expand_value}")
1031
+
1032
+ if username:
1033
+ result = client.get_user_details_by_username(username, expand=expand_value)
1034
+ else:
1035
+ result = client.get_user_details_by_userkey(userkey, expand=expand_value)
1036
+ result_payload = {"user": result}
1037
+
1038
+ elif action == "password":
1039
+ if not yes:
1040
+ raise typer.BadParameter("--yes required for password change")
1041
+ if not username:
1042
+ raise typer.BadParameter("--username required for password action")
1043
+ if not new_password:
1044
+ raise typer.BadParameter("--new-password required for password action")
1045
+ args_summary.append(f"username={username}")
1046
+ client.change_user_password(username, new_password)
1047
+ result_payload = {"username": username, "status": "password_changed"}
1048
+
1049
+ elif action == "group-add":
1050
+ if not yes:
1051
+ raise typer.BadParameter("--yes required for group-add")
1052
+ if not username:
1053
+ raise typer.BadParameter("--username required for group-add")
1054
+ if not group_name:
1055
+ raise typer.BadParameter("--group required for group-add")
1056
+ args_summary.append(f"username={username}")
1057
+ args_summary.append(f"group={group_name}")
1058
+ client.add_user_to_group(username, group_name)
1059
+ result_payload = {"username": username, "group": group_name, "status": "added"}
1060
+
1061
+ elif action == "group-remove":
1062
+ if not yes:
1063
+ raise typer.BadParameter("--yes required for group-remove")
1064
+ if not username:
1065
+ raise typer.BadParameter("--username required for group-remove")
1066
+ if not group_name:
1067
+ raise typer.BadParameter("--group required for group-remove")
1068
+ args_summary.append(f"username={username}")
1069
+ args_summary.append(f"group={group_name}")
1070
+ client.remove_user_from_group(username, group_name)
1071
+ result_payload = {"username": username, "group": group_name, "status": "removed"}
1072
+
1073
+ else:
1074
+ raise typer.BadParameter(f"Unknown action: {action}")
1075
+
1076
+ if json_output or not sys.stdout.isatty():
1077
+ _emit_json(result_payload)
1078
+ else:
1079
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
1080
+
1081
+ except (ConfluenceClientError, typer.BadParameter) as exc:
1082
+ exit_code = 1
1083
+ logger.error("user_failed: %s", exc)
1084
+ typer.echo(f"Error: {exc}", err=True)
1085
+ except Exception as exc: # pragma: no cover - unexpected errors
1086
+ exit_code = 1
1087
+ logger.exception("user_unexpected_error")
1088
+ typer.echo(f"Error: {exc}", err=True)
1089
+ finally:
1090
+ finished_at = datetime.now(UTC)
1091
+ server_label = server_key or (server.lower() if server else "internal")
1092
+ _record_summary(
1093
+ parent_ctx,
1094
+ command="user",
1095
+ args=args_summary,
1096
+ server=server_label,
1097
+ exit_code=exit_code,
1098
+ started_at=started_at,
1099
+ finished_at=finished_at,
1100
+ )
1101
+ if exit_code != 0:
1102
+ raise typer.Exit(code=exit_code) from None
1103
+
1104
+
1105
+ @app.command("export")
1106
+ def cmd_export(
1107
+ ctx: typer.Context,
1108
+ action: str = typer.Argument(..., help="Action: page, space"),
1109
+ page_id: str | None = typer.Option(None, "--page-id", help="Page ID (for page)"),
1110
+ space_key: str | None = typer.Option(None, "--space-key", "-k", help="Space key (for space)"),
1111
+ export_type: str | None = typer.Option(None, "--export-type", help="Export type: pdf, html, xml (for space)"),
1112
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1113
+ output: Path | None = typer.Option(None, "--output", "-o", help="Output file path (for page PDF)"),
1114
+ api_version: str | None = typer.Option(None, "--api-version", help="API version: cloud, server (for page, auto-detected if not specified)"),
1115
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
1116
+ ) -> None:
1117
+ """Export Confluence pages or spaces."""
1118
+ parent_ctx = _parent_context(ctx)
1119
+ started_at = datetime.now(UTC)
1120
+ exit_code = 0
1121
+ result_payload: dict[str, Any] = {}
1122
+ args_summary: list[str] = []
1123
+ server_key: str | None = None
1124
+ settings: ConfluenceSettings | None = None
1125
+
1126
+ try:
1127
+ settings = load_settings(strict=True)
1128
+ server_key = _normalise_server(server, settings)
1129
+ client = _build_http_client(settings, server_key)
1130
+
1131
+ args_summary = [action]
1132
+
1133
+ if action == "page":
1134
+ if not page_id:
1135
+ raise typer.BadParameter("--page-id required for page action")
1136
+ args_summary.append(f"page_id={page_id}")
1137
+ if api_version:
1138
+ args_summary.append(f"api_version={api_version}")
1139
+
1140
+ pdf_bytes = client.export_page(page_id, api_version=api_version)
1141
+
1142
+ if output:
1143
+ output.write_bytes(pdf_bytes)
1144
+ result_payload = {"page_id": page_id, "output_file": str(output), "size_bytes": len(pdf_bytes)}
1145
+ typer.echo(f"Exported page {page_id} to {output} ({len(pdf_bytes)} bytes)")
1146
+ else:
1147
+ # Write to stdout as binary
1148
+ sys.stdout.buffer.write(pdf_bytes)
1149
+ result_payload = {"page_id": page_id, "size_bytes": len(pdf_bytes), "format": "pdf"}
1150
+
1151
+ elif action == "space":
1152
+ if not space_key:
1153
+ raise typer.BadParameter("--space-key required for space action")
1154
+ if not export_type:
1155
+ raise typer.BadParameter("--export-type required for space action")
1156
+ if export_type not in ("pdf", "html", "xml"):
1157
+ raise typer.BadParameter("--export-type must be one of: pdf, html, xml")
1158
+ args_summary.append(f"space_key={space_key}")
1159
+ args_summary.append(f"export_type={export_type}")
1160
+
1161
+ download_url = client.get_space_export(space_key, export_type)
1162
+ result_payload = {"space_key": space_key, "export_type": export_type, "download_url": download_url}
1163
+
1164
+ if json_output or not sys.stdout.isatty():
1165
+ _emit_json(result_payload)
1166
+ else:
1167
+ typer.echo(f"Space export URL: {download_url}")
1168
+
1169
+ else:
1170
+ raise typer.BadParameter(f"Unknown action: {action}")
1171
+
1172
+ # For page action, JSON output only if explicitly requested or output file specified
1173
+ if action == "page" and (json_output or output):
1174
+ if json_output:
1175
+ _emit_json(result_payload)
1176
+
1177
+ except (ConfluenceClientError, typer.BadParameter) as exc:
1178
+ exit_code = 1
1179
+ logger.error("export_failed: %s", exc)
1180
+ typer.echo(f"Error: {exc}", err=True)
1181
+ except Exception as exc: # pragma: no cover - unexpected errors
1182
+ exit_code = 1
1183
+ logger.exception("export_unexpected_error")
1184
+ typer.echo(f"Error: {exc}", err=True)
1185
+ finally:
1186
+ finished_at = datetime.now(UTC)
1187
+ server_label = server_key or (server.lower() if server else "internal")
1188
+ _record_summary(
1189
+ parent_ctx,
1190
+ command="export",
1191
+ args=args_summary,
1192
+ server=server_label,
1193
+ exit_code=exit_code,
1194
+ started_at=started_at,
1195
+ finished_at=finished_at,
1196
+ )
1197
+ if exit_code != 0:
1198
+ raise typer.Exit(code=exit_code) from None
1199
+
1200
+
1201
+ @app.command("draft")
1202
+ def cmd_draft(
1203
+ ctx: typer.Context,
1204
+ action: str = typer.Argument(..., help="Action: get, list, remove"),
1205
+ page_id: str | None = typer.Option(None, "--page-id", help="Page ID (for get/remove)"),
1206
+ space_key: str | None = typer.Option(None, "--space-key", "-k", help="Space key (for list)"),
1207
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1208
+ limit: int = typer.Option(25, "--limit", help="Limit for list (max 100)"),
1209
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations (for remove)"),
1210
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
1211
+ ) -> None:
1212
+ """Manage Confluence draft pages (Server-only)."""
1213
+ parent_ctx = _parent_context(ctx)
1214
+ started_at = datetime.now(UTC)
1215
+ exit_code = 0
1216
+ result_payload: dict[str, Any] = {}
1217
+ args_summary: list[str] = []
1218
+ server_key: str | None = None
1219
+ settings: ConfluenceSettings | None = None
1220
+
1221
+ try:
1222
+ if limit < 1 or limit > 100:
1223
+ raise typer.BadParameter("--limit must be between 1 and 100")
1224
+
1225
+ settings = load_settings(strict=True)
1226
+ server_key = _normalise_server(server, settings)
1227
+ client = _build_http_client(settings, server_key)
1228
+
1229
+ args_summary = [action]
1230
+
1231
+ if action == "get":
1232
+ if not page_id:
1233
+ raise typer.BadParameter("--page-id required for get action")
1234
+ args_summary.append(f"page_id={page_id}")
1235
+ result = client.get_draft_page_by_id(page_id)
1236
+ result_payload = {"draft_page": result}
1237
+
1238
+ elif action == "list":
1239
+ if not space_key:
1240
+ raise typer.BadParameter("--space-key required for list action")
1241
+ args_summary.append(f"space_key={space_key}")
1242
+ args_summary.append(f"limit={limit}")
1243
+ result = client.get_all_draft_pages_from_space(space_key, limit=limit)
1244
+ result_payload = {"space": space_key, "count": len(result), "drafts": result}
1245
+
1246
+ elif action == "remove":
1247
+ if not yes:
1248
+ raise typer.BadParameter("--yes required for remove action")
1249
+ if not page_id:
1250
+ raise typer.BadParameter("--page-id required for remove action")
1251
+ args_summary.append(f"page_id={page_id}")
1252
+ client.remove_page_as_draft(page_id)
1253
+ result_payload = {"page_id": page_id, "status": "removed"}
1254
+
1255
+ else:
1256
+ raise typer.BadParameter(f"Unknown action: {action}")
1257
+
1258
+ if json_output or not sys.stdout.isatty():
1259
+ _emit_json(result_payload)
1260
+ else:
1261
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
1262
+
1263
+ except (ConfluenceClientError, typer.BadParameter) as exc:
1264
+ exit_code = 1
1265
+ logger.error("draft_failed: %s", exc)
1266
+ typer.echo(f"Error: {exc}", err=True)
1267
+ except Exception as exc: # pragma: no cover - unexpected errors
1268
+ exit_code = 1
1269
+ logger.exception("draft_unexpected_error")
1270
+ typer.echo(f"Error: {exc}", err=True)
1271
+ finally:
1272
+ finished_at = datetime.now(UTC)
1273
+ server_label = server_key or (server.lower() if server else "internal")
1274
+ _record_summary(
1275
+ parent_ctx,
1276
+ command="draft",
1277
+ args=args_summary,
1278
+ server=server_label,
1279
+ exit_code=exit_code,
1280
+ started_at=started_at,
1281
+ finished_at=finished_at,
1282
+ )
1283
+ if exit_code != 0:
1284
+ raise typer.Exit(code=exit_code) from None
1285
+
1286
+
1287
+ @app.command("history")
1288
+ def cmd_history(
1289
+ ctx: typer.Context,
1290
+ action: str = typer.Argument(..., help="Action: get, version, remove"),
1291
+ page_id: str | None = typer.Option(None, "--page-id", help="Page ID"),
1292
+ version: int | None = typer.Option(None, "--version", help="Version number (for version/remove)"),
1293
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1294
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations (for remove)"),
1295
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
1296
+ ) -> None:
1297
+ """Manage Confluence page history and versioning."""
1298
+ parent_ctx = _parent_context(ctx)
1299
+ started_at = datetime.now(UTC)
1300
+ exit_code = 0
1301
+ result_payload: dict[str, Any] = {}
1302
+ args_summary: list[str] = []
1303
+ server_key: str | None = None
1304
+ settings: ConfluenceSettings | None = None
1305
+
1306
+ try:
1307
+ settings = load_settings(strict=True)
1308
+ server_key = _normalise_server(server, settings)
1309
+ client = _build_http_client(settings, server_key)
1310
+
1311
+ args_summary = [action]
1312
+
1313
+ if action == "get":
1314
+ if not page_id:
1315
+ raise typer.BadParameter("--page-id required for get action")
1316
+ args_summary.append(f"page_id={page_id}")
1317
+ result = client.history(page_id)
1318
+ result_payload = {"page_id": page_id, "history": result}
1319
+
1320
+ elif action == "version":
1321
+ if not page_id:
1322
+ raise typer.BadParameter("--page-id required for version action")
1323
+ if version is None:
1324
+ raise typer.BadParameter("--version required for version action")
1325
+ args_summary.append(f"page_id={page_id}")
1326
+ args_summary.append(f"version={version}")
1327
+ result = client.get_content_history_by_version_number(page_id, version)
1328
+ result_payload = {"page_id": page_id, "version": version, "content": result}
1329
+
1330
+ elif action == "remove":
1331
+ if not yes:
1332
+ raise typer.BadParameter("--yes required for remove action (experimental feature)")
1333
+ if not page_id:
1334
+ raise typer.BadParameter("--page-id required for remove action")
1335
+ if version is None:
1336
+ raise typer.BadParameter("--version required for remove action")
1337
+ args_summary.append(f"page_id={page_id}")
1338
+ args_summary.append(f"version={version}")
1339
+ typer.echo("Warning: remove_content_history is an experimental feature", err=True)
1340
+ client.remove_content_history(page_id, version)
1341
+ result_payload = {"page_id": page_id, "version": version, "status": "removed"}
1342
+
1343
+ else:
1344
+ raise typer.BadParameter(f"Unknown action: {action}")
1345
+
1346
+ if json_output or not sys.stdout.isatty():
1347
+ _emit_json(result_payload)
1348
+ else:
1349
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
1350
+
1351
+ except (ConfluenceClientError, typer.BadParameter) as exc:
1352
+ exit_code = 1
1353
+ logger.error("history_failed: %s", exc)
1354
+ typer.echo(f"Error: {exc}", err=True)
1355
+ except Exception as exc: # pragma: no cover - unexpected errors
1356
+ exit_code = 1
1357
+ logger.exception("history_unexpected_error")
1358
+ typer.echo(f"Error: {exc}", err=True)
1359
+ finally:
1360
+ finished_at = datetime.now(UTC)
1361
+ server_label = server_key or (server.lower() if server else "internal")
1362
+ _record_summary(
1363
+ parent_ctx,
1364
+ command="history",
1365
+ args=args_summary,
1366
+ server=server_label,
1367
+ exit_code=exit_code,
1368
+ started_at=started_at,
1369
+ finished_at=finished_at,
1370
+ )
1371
+ if exit_code != 0:
1372
+ raise typer.Exit(code=exit_code) from None
1373
+
1374
+
1375
+ @app.command("cache")
1376
+ def cmd_cache(
1377
+ ctx: typer.Context,
1378
+ action: str = typer.Argument(..., help="Action: statistics, flush, size"),
1379
+ cache_name: str | None = typer.Option(None, "--cache-name", help="Cache name (for flush)"),
1380
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1381
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation (for flush)"),
1382
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
1383
+ ) -> None:
1384
+ """Manage Confluence cache (Server-only)."""
1385
+ parent_ctx = _parent_context(ctx)
1386
+ started_at = datetime.now(UTC)
1387
+ exit_code = 0
1388
+ result_payload: dict[str, Any] = {}
1389
+ args_summary: list[str] = []
1390
+ server_key: str | None = None
1391
+ settings: ConfluenceSettings | None = None
1392
+
1393
+ try:
1394
+ settings = load_settings(strict=True)
1395
+ server_key = _normalise_server(server, settings)
1396
+ client = _build_http_client(settings, server_key)
1397
+
1398
+ args_summary = [action]
1399
+
1400
+ if action == "statistics":
1401
+ result = client.get_cache_statistics()
1402
+ result_payload = {"cache_statistics": result}
1403
+ elif action == "flush":
1404
+ if not yes:
1405
+ raise typer.BadParameter("Refusing to flush cache without --yes")
1406
+ if cache_name:
1407
+ args_summary.append(f"cache_name={cache_name}")
1408
+ client.flush_cache(cache_name=cache_name)
1409
+ result_payload = {"status": "success", "cache_flushed": cache_name or "all"}
1410
+ elif action == "size":
1411
+ result = client.get_cache_size()
1412
+ result_payload = {"cache_size": result}
1413
+ else:
1414
+ raise typer.BadParameter(f"Unknown action: {action}")
1415
+
1416
+ if json_output or not sys.stdout.isatty():
1417
+ _emit_json(result_payload)
1418
+ else:
1419
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
1420
+
1421
+ except (ConfluenceClientError, typer.BadParameter) as exc:
1422
+ exit_code = 1
1423
+ logger.error("cache_failed: %s", exc)
1424
+ typer.echo(f"Error: {exc}", err=True)
1425
+ except Exception as exc: # pragma: no cover - unexpected errors
1426
+ exit_code = 1
1427
+ logger.exception("cache_unexpected_error")
1428
+ typer.echo(f"Error: {exc}", err=True)
1429
+ finally:
1430
+ finished_at = datetime.now(UTC)
1431
+ server_label = server_key or (server.lower() if server else "internal")
1432
+ _record_summary(
1433
+ parent_ctx,
1434
+ command="cache",
1435
+ args=args_summary,
1436
+ server=server_label,
1437
+ exit_code=exit_code,
1438
+ started_at=started_at,
1439
+ finished_at=finished_at,
1440
+ )
1441
+ if exit_code != 0:
1442
+ raise typer.Exit(code=exit_code) from None
1443
+
1444
+
1445
+ @app.command("get")
1446
+ def cmd_get(
1447
+ ctx: typer.Context,
1448
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
1449
+ expand: str | None = typer.Option(
1450
+ None, "--expand", "-e", help="Comma separated expansions (e.g., 'body.storage,version')"
1451
+ ),
1452
+ format: str | None = typer.Option(None, "--format", "-f", help="Output format (storage|editor|view)"),
1453
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1454
+ ) -> None:
1455
+ """Get Confluence content by page ID."""
1456
+ parent_ctx = _parent_context(ctx)
1457
+ _ensure_v1_api(parent_ctx, "get")
1458
+ started_at = datetime.now(UTC)
1459
+ settings = load_settings(strict=True)
1460
+ server_key = _normalise_server(server, settings)
1461
+
1462
+ try:
1463
+ client = ContentClientV2(_build_http_client(settings, server_key))
1464
+ content = client.get_content(page_id, expand=_expand_option(expand), format=format)
1465
+
1466
+ if content:
1467
+ _emit_json(content)
1468
+ else:
1469
+ typer.echo(f"Content not found: {page_id}", err=True)
1470
+ raise typer.Exit(code=1) from None
1471
+
1472
+ finished_at = datetime.now(UTC)
1473
+ _record_summary(
1474
+ parent_ctx,
1475
+ command="get",
1476
+ args=[page_id],
1477
+ server=server_key,
1478
+ exit_code=0,
1479
+ started_at=started_at,
1480
+ finished_at=finished_at,
1481
+ )
1482
+
1483
+ except Exception as exc:
1484
+ logger.error("get_command_failed: %s", exc)
1485
+ typer.echo(f"Error: {exc}", err=True)
1486
+ raise typer.Exit(code=1) from None
1487
+
1488
+
1489
+ # ---------------------------------------------------------------------------
1490
+ # Native content commands
1491
+ # ---------------------------------------------------------------------------
1492
+
1493
+
1494
+ @batch_app.command("scan")
1495
+ def batch_scan(
1496
+ ctx: typer.Context,
1497
+ cql: str = typer.Option(..., "--cql", "-q", help="CQL query string"),
1498
+ limit: int = typer.Option(25, "--limit", help="Number of results per request"),
1499
+ max_results: int | None = typer.Option(None, "--max-results", help="Total results to fetch"),
1500
+ start: int = typer.Option(0, "--start", help="Pagination start offset"),
1501
+ expand: str | None = typer.Option(None, "--expand", help="Comma separated expansions"),
1502
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1503
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
1504
+ ) -> None:
1505
+ parent_ctx = _parent_context(ctx)
1506
+ _ensure_v1_api(parent_ctx, "batch.scan")
1507
+ started_at = datetime.now(UTC)
1508
+ settings = load_settings(strict=True)
1509
+ server_key = _normalise_server(server, settings)
1510
+ runner = BatchRunner(_build_http_client(settings, server_key), expand=_expand_option(expand))
1511
+ results: list[dict[str, Any]] = []
1512
+ for item in runner.scan(cql, limit=limit, max_results=max_results, start=start):
1513
+ results.append(item)
1514
+ metrics = runner.last_metrics
1515
+ payload: dict[str, Any] = {
1516
+ "cql": cql,
1517
+ "count": len(results),
1518
+ "limit": limit,
1519
+ "maxResults": max_results,
1520
+ "start": start,
1521
+ "results": results,
1522
+ }
1523
+ if metrics is not None:
1524
+ payload["metrics"] = {
1525
+ "requests": metrics.requests,
1526
+ "pagesProcessed": metrics.pages_processed,
1527
+ "durationSeconds": metrics.duration_seconds,
1528
+ }
1529
+ if json_output or not sys.stdout.isatty():
1530
+ _emit_json(payload)
1531
+ else:
1532
+ typer.echo(
1533
+ f"Scanned {payload['count']} results (requests={payload['metrics']['requests'] if 'metrics' in payload else '?'})"
1534
+ )
1535
+ finished_at = datetime.now(UTC)
1536
+ _record_summary(
1537
+ parent_ctx,
1538
+ command="batch.scan",
1539
+ args=[cql, f"limit={limit}", f"start={start}"],
1540
+ server=server_key,
1541
+ exit_code=0,
1542
+ started_at=started_at,
1543
+ finished_at=finished_at,
1544
+ )
1545
+
1546
+
1547
+ @batch_app.command("snapshot")
1548
+ def batch_snapshot(
1549
+ ctx: typer.Context,
1550
+ cql: str = typer.Option(..., "--cql", "-q", help="CQL query string"),
1551
+ limit: int = typer.Option(25, "--limit", help="Number of results per request"),
1552
+ max_results: int | None = typer.Option(None, "--max-results", help="Total results to fetch"),
1553
+ expand: str | None = typer.Option(None, "--expand", help="Comma separated expansions"),
1554
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1555
+ ) -> None:
1556
+ parent_ctx = _parent_context(ctx)
1557
+ _ensure_v1_api(parent_ctx, "batch.snapshot")
1558
+ started_at = datetime.now(UTC)
1559
+ settings = load_settings(strict=True)
1560
+ server_key = _normalise_server(server, settings)
1561
+ runner = BatchRunner(_build_http_client(settings, server_key))
1562
+ payload = runner.snapshot(cql, limit=limit, max_results=max_results, expand=_expand_option(expand))
1563
+ _emit_json(payload)
1564
+ finished_at = datetime.now(UTC)
1565
+ _record_summary(
1566
+ parent_ctx,
1567
+ command="batch.snapshot",
1568
+ args=[cql, f"limit={limit}"],
1569
+ server=server_key,
1570
+ exit_code=0,
1571
+ started_at=started_at,
1572
+ finished_at=finished_at,
1573
+ )
1574
+
1575
+
1576
+ @batch_app.command("poll")
1577
+ def batch_poll(
1578
+ ctx: typer.Context,
1579
+ cql: str = typer.Option(..., "--cql", "-q", help="Base CQL query (will be augmented with lastmodified filter)"),
1580
+ state_file: Path = typer.Option(..., "--state-file", help="Path to persistent cursor file (JSON)"),
1581
+ limit: int = typer.Option(25, "--limit", help="Page size per request"),
1582
+ max_results: int | None = typer.Option(None, "--max-results", help="Max results this run"),
1583
+ expand: str | None = typer.Option(
1584
+ "version", "--expand", help="Comma separated expansions (default includes 'version')"
1585
+ ),
1586
+ out_jsonl: Path | None = typer.Option(None, "--out-jsonl", help="Write results to JSONL (one object per line)"),
1587
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1588
+ ) -> None:
1589
+ parent_ctx = _parent_context(ctx)
1590
+ _ensure_v1_api(parent_ctx, "batch.poll")
1591
+ started_at = datetime.now(UTC)
1592
+ try:
1593
+ settings = load_settings(strict=True)
1594
+ server_key = _normalise_server(server, settings)
1595
+ client = _build_http_client(settings, server_key)
1596
+ poller = Poller(client)
1597
+ expand_list = _expand_option(expand) or ["version"]
1598
+ count = poller.poll_once(
1599
+ base_cql=cql,
1600
+ state_file=state_file,
1601
+ limit=limit,
1602
+ max_results=max_results,
1603
+ expand=expand_list,
1604
+ out_jsonl=out_jsonl,
1605
+ )
1606
+ typer.echo(f"Polled {count} items")
1607
+ except Exception as exc:
1608
+ logger.exception("batch.poll failed", extra={"cql": cql})
1609
+ typer.echo(f"Error: {exc}", err=True)
1610
+ raise typer.Exit(code=1) from None
1611
+ finally:
1612
+ finished_at = datetime.now(UTC)
1613
+ _record_summary(
1614
+ parent_ctx,
1615
+ command="batch.poll",
1616
+ args=[cql, f"limit={limit}", f"out={str(out_jsonl) if out_jsonl else '-'}"],
1617
+ server=server_key if "server_key" in locals() else "",
1618
+ exit_code=0,
1619
+ started_at=started_at,
1620
+ finished_at=finished_at,
1621
+ )
1622
+
1623
+
1624
+ @webhook_app.command("create")
1625
+ def webhook_create(
1626
+ ctx: typer.Context,
1627
+ name: str = typer.Option(..., "--name", "-n", help="Webhook name"),
1628
+ url: str = typer.Option(..., "--url", help="Destination URL"),
1629
+ event: list[str] = typer.Option(["page_created"], "--event", "-e", help="Event identifier"),
1630
+ active: bool = typer.Option(True, "--active/--inactive", help="Toggle webhook activation"),
1631
+ filters: str | None = typer.Option(None, "--filters", help="JSON object filters"),
1632
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1633
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
1634
+ ) -> None:
1635
+ parent_ctx = _parent_context(ctx)
1636
+ _ensure_v1_api(parent_ctx, "webhook.create")
1637
+ started_at = datetime.now(UTC)
1638
+ settings = load_settings(strict=True)
1639
+ server_key = _normalise_server(server, settings)
1640
+ client = WebhookClient(_build_http_client(settings, server_key))
1641
+ payload = client.create(
1642
+ name=name,
1643
+ url=url,
1644
+ events=event,
1645
+ active=active,
1646
+ filters=_parse_json_option(filters, "--filters"),
1647
+ )
1648
+ if json_output:
1649
+ _emit_json(payload)
1650
+ else:
1651
+ typer.echo(f"Created webhook {payload.get('id')}")
1652
+ finished_at = datetime.now(UTC)
1653
+ _record_summary(
1654
+ parent_ctx,
1655
+ command="webhook.create",
1656
+ args=[name, f"events={len(event)}"],
1657
+ server=server_key,
1658
+ exit_code=0,
1659
+ started_at=started_at,
1660
+ finished_at=finished_at,
1661
+ )
1662
+
1663
+
1664
+ @webhook_app.command("list")
1665
+ def webhook_list(
1666
+ ctx: typer.Context,
1667
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1668
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
1669
+ ) -> None:
1670
+ parent_ctx = _parent_context(ctx)
1671
+ _ensure_v1_api(parent_ctx, "webhook.list")
1672
+ started_at = datetime.now(UTC)
1673
+ settings = load_settings(strict=True)
1674
+ server_key = _normalise_server(server, settings)
1675
+ client = WebhookClient(_build_http_client(settings, server_key))
1676
+ payload = client.list()
1677
+ if json_output:
1678
+ _emit_json(payload)
1679
+ else:
1680
+ typer.echo(f"{len(payload)} webhooks")
1681
+ finished_at = datetime.now(UTC)
1682
+ _record_summary(
1683
+ parent_ctx,
1684
+ command="webhook.list",
1685
+ args=[f"count={len(payload)}"],
1686
+ server=server_key,
1687
+ exit_code=0,
1688
+ started_at=started_at,
1689
+ finished_at=finished_at,
1690
+ )
1691
+
1692
+
1693
+ @webhook_app.command("delete")
1694
+ def webhook_delete(
1695
+ ctx: typer.Context,
1696
+ webhook_id: str = typer.Argument(..., help="Webhook identifier"),
1697
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1698
+ ) -> None:
1699
+ parent_ctx = _parent_context(ctx)
1700
+ _ensure_v1_api(parent_ctx, "webhook.delete")
1701
+ started_at = datetime.now(UTC)
1702
+ settings = load_settings(strict=True)
1703
+ server_key = _normalise_server(server, settings)
1704
+ client = WebhookClient(_build_http_client(settings, server_key))
1705
+ client.delete(webhook_id)
1706
+ typer.echo(f"Deleted webhook {webhook_id}")
1707
+ finished_at = datetime.now(UTC)
1708
+ _record_summary(
1709
+ parent_ctx,
1710
+ command="webhook.delete",
1711
+ args=[webhook_id],
1712
+ server=server_key,
1713
+ exit_code=0,
1714
+ started_at=started_at,
1715
+ finished_at=finished_at,
1716
+ )
1717
+
1718
+
1719
+ @content_app.command("page")
1720
+ def content_page(
1721
+ ctx: typer.Context,
1722
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
1723
+ expand: str | None = typer.Option(None, "--expand", help="Comma separated expansions"),
1724
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1725
+ ) -> None:
1726
+ parent_ctx = _parent_context(ctx)
1727
+ started_at = datetime.now(UTC)
1728
+ try:
1729
+ settings = load_settings(strict=True)
1730
+ server_key = _normalise_server(server, settings)
1731
+ cli_ctx = _get_cli_ctx(parent_ctx)
1732
+ client = _build_content_client(settings, server_key, cli_ctx.api_version)
1733
+ expand_list = _expand_option(expand)
1734
+ payload = client.get_page(page_id, expand=expand_list)
1735
+ _emit_json(payload)
1736
+ except Exception as exc:
1737
+ logger.exception("content.page failed", extra={"page_id": page_id})
1738
+ typer.echo(f"Error: {exc}", err=True)
1739
+ raise typer.Exit(code=1) from None
1740
+ else:
1741
+ finished_at = datetime.now(UTC)
1742
+ _record_summary(
1743
+ parent_ctx,
1744
+ command="content.page",
1745
+ args=[page_id],
1746
+ server=server_key,
1747
+ exit_code=0,
1748
+ started_at=started_at,
1749
+ finished_at=finished_at,
1750
+ )
1751
+
1752
+
1753
+ @content_app.command("search")
1754
+ def content_search(
1755
+ ctx: typer.Context,
1756
+ cql: str = typer.Argument(..., help="CQL query string"),
1757
+ limit: int = typer.Option(25, "--limit", help="Number of results"),
1758
+ start: int = typer.Option(0, "--start", help="Pagination start"),
1759
+ expand: str | None = typer.Option(None, "--expand", help="Comma separated expansions"),
1760
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1761
+ ) -> None:
1762
+ parent_ctx = _parent_context(ctx)
1763
+ started_at = datetime.now(UTC)
1764
+ exit_code = 0
1765
+ server_key: str = ""
1766
+ try:
1767
+ settings = load_settings(strict=True)
1768
+ server_key = _normalise_server(server, settings)
1769
+ cli_ctx = _get_cli_ctx(parent_ctx)
1770
+ client = _build_content_client(settings, server_key, cli_ctx.api_version)
1771
+ expand_list = _expand_option(expand)
1772
+ payload = client.search_cql(cql, limit=limit, start=start, expand=expand_list)
1773
+ _emit_json(payload)
1774
+ except Exception as exc:
1775
+ exit_code = 1
1776
+ logger.exception("content.search failed", extra={"cql": cql})
1777
+ # Provide targeted guidance for CQL auth failures
1778
+ from .errors import ConfluenceAuthError # local import to avoid top-level cycles
1779
+
1780
+ if isinstance(exc, ConfluenceAuthError):
1781
+ typer.echo("Error: Authentication failed for CQL search.", err=True)
1782
+ typer.echo("Hint: We default to Basic auth (VDS_USERNAME/PASSWORD). If using token, ensure:", err=True)
1783
+ typer.echo(" - Token has permission to use CQL (/rest/api/*search)", err=True)
1784
+ typer.echo(" - Or set VDS_USERNAME/VDS_PASSWORD to use Basic auth", err=True)
1785
+ typer.echo(
1786
+ " - Quick check: curl -u \"$VDS_USERNAME:$VDS_PASSWORD\" 'http://confluence.digital.vn/rest/api/search?cql=type=page&limit=1'",
1787
+ err=True,
1788
+ )
1789
+ else:
1790
+ typer.echo(f"Error: {exc}", err=True)
1791
+ finally:
1792
+ finished_at = datetime.now(UTC)
1793
+ _record_summary(
1794
+ parent_ctx,
1795
+ command="content.search",
1796
+ args=_content_summary_args(cql=cql, limit=limit, start=start),
1797
+ server=server_key,
1798
+ exit_code=exit_code,
1799
+ started_at=started_at,
1800
+ finished_at=finished_at,
1801
+ )
1802
+ if exit_code != 0:
1803
+ raise typer.Exit(code=exit_code) from None
1804
+
1805
+
1806
+ @content_app.command("templates")
1807
+ def content_templates(
1808
+ ctx: typer.Context,
1809
+ space_key: str | None = typer.Option(None, "--space", help="Space key"),
1810
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1811
+ ) -> None:
1812
+ parent_ctx = _parent_context(ctx)
1813
+ started_at = datetime.now(UTC)
1814
+ try:
1815
+ settings = load_settings(strict=True)
1816
+ server_key = _normalise_server(server, settings)
1817
+ cli_ctx = _get_cli_ctx(parent_ctx)
1818
+ client = _build_content_client(settings, server_key, cli_ctx.api_version)
1819
+ if hasattr(client, "list_templates"):
1820
+ payload = client.list_templates(space_key=space_key)
1821
+ else:
1822
+ payload = {"templates": [], "message": "Templates API not available for this server"}
1823
+ _emit_json(payload)
1824
+ except Exception as exc:
1825
+ logger.exception("content.templates failed", extra={"space": space_key})
1826
+ typer.echo(f"Error: {exc}", err=True)
1827
+ raise typer.Exit(code=1) from None
1828
+ else:
1829
+ finished_at = datetime.now(UTC)
1830
+ _record_summary(
1831
+ parent_ctx,
1832
+ command="content.templates",
1833
+ args=_content_summary_args(space=space_key or "*"),
1834
+ server=server_key,
1835
+ exit_code=0,
1836
+ started_at=started_at,
1837
+ finished_at=finished_at,
1838
+ )
1839
+
1840
+
1841
+ @content_app.command("create-page")
1842
+ def content_create_page(
1843
+ ctx: typer.Context,
1844
+ space_key: str = typer.Option(..., "--space", "-S", help="Space key to create the page in"),
1845
+ title: str = typer.Option(..., "--title", "-t", help="Page title"),
1846
+ body_file: Path = typer.Option(
1847
+ ..., "--body-file", exists=True, file_okay=True, dir_okay=False, help="Path to storage-format body"
1848
+ ),
1849
+ parent_id: str | None = typer.Option(None, "--parent", help="Ancestor page id"),
1850
+ representation: str = typer.Option(
1851
+ "storage", "--representation", help="Content representation (storage, wiki, view)"
1852
+ ),
1853
+ status: str = typer.Option("current", "--status", help="Target page status (current, draft)"),
1854
+ notify_watchers: bool = typer.Option(
1855
+ False, "--notify-watchers/--no-notify-watchers", help="Send notifications to watchers"
1856
+ ),
1857
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1858
+ ) -> None:
1859
+ parent_ctx = _parent_context(ctx)
1860
+ _ensure_v1_api(parent_ctx, "content.create-page")
1861
+ started_at = datetime.now(UTC)
1862
+ try:
1863
+ settings = load_settings(strict=True)
1864
+ server_key = _normalise_server(server, settings)
1865
+ client = ContentClient(_build_http_client(settings, server_key))
1866
+ body = _read_text_file(body_file)
1867
+ payload = client.create_page(
1868
+ space_key=space_key,
1869
+ title=title,
1870
+ body=body,
1871
+ parent_id=parent_id,
1872
+ representation=representation,
1873
+ status=status,
1874
+ notify_watchers=notify_watchers,
1875
+ )
1876
+ _emit_json(payload)
1877
+ except Exception as exc:
1878
+ logger.exception("content.create-page failed", extra={"space": space_key, "title": title})
1879
+ typer.echo(f"Error: {exc}", err=True)
1880
+ raise typer.Exit(code=1) from None
1881
+ else:
1882
+ finished_at = datetime.now(UTC)
1883
+ _record_summary(
1884
+ parent_ctx,
1885
+ command="content.create-page",
1886
+ args=[f"space={space_key}", f"title={title}"],
1887
+ server=server_key,
1888
+ exit_code=0,
1889
+ started_at=started_at,
1890
+ finished_at=finished_at,
1891
+ )
1892
+
1893
+
1894
+ @content_app.command("update-page")
1895
+ def content_update_page(
1896
+ ctx: typer.Context,
1897
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
1898
+ title: str | None = typer.Option(None, "--title", help="New page title"),
1899
+ body_file: Path | None = typer.Option(
1900
+ None, "--body-file", exists=True, file_okay=True, dir_okay=False, help="Path to storage-format body"
1901
+ ),
1902
+ space_key: str | None = typer.Option(None, "--space", help="Move page to this space"),
1903
+ version: int | None = typer.Option(None, "--version", help="Explicit version number"),
1904
+ representation: str = typer.Option("storage", "--representation", help="Content representation for --body-file"),
1905
+ minor_edit: bool = typer.Option(False, "--minor-edit/--no-minor-edit", help="Mark version as minor edit"),
1906
+ message: str | None = typer.Option(None, "--message", help="Version comment"),
1907
+ status: str = typer.Option("current", "--status", help="Target page status"),
1908
+ notify_watchers: bool = typer.Option(
1909
+ False, "--notify-watchers/--no-notify-watchers", help="Send notifications to watchers"
1910
+ ),
1911
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1912
+ ) -> None:
1913
+ parent_ctx = _parent_context(ctx)
1914
+ _ensure_v1_api(parent_ctx, "content.update-page")
1915
+ if body_file is None and title is None and space_key is None:
1916
+ raise typer.BadParameter("Provide at least one of --title, --body-file or --space")
1917
+ body = _read_text_file(body_file) if body_file else None
1918
+ started_at = datetime.now(UTC)
1919
+ try:
1920
+ settings = load_settings(strict=True)
1921
+ server_key = _normalise_server(server, settings)
1922
+ client = ContentClient(_build_http_client(settings, server_key))
1923
+ payload = client.update_page(
1924
+ page_id,
1925
+ title=title,
1926
+ body=body,
1927
+ space_key=space_key,
1928
+ representation=representation,
1929
+ version=version,
1930
+ minor_edit=minor_edit,
1931
+ message=message,
1932
+ status=status,
1933
+ notify_watchers=notify_watchers,
1934
+ )
1935
+ _emit_json(payload)
1936
+ except Exception as exc:
1937
+ logger.exception("content.update-page failed", extra={"page_id": page_id})
1938
+ typer.echo(f"Error: {exc}", err=True)
1939
+ raise typer.Exit(code=1) from None
1940
+ else:
1941
+ finished_at = datetime.now(UTC)
1942
+ args = [page_id]
1943
+ if version is not None:
1944
+ args.append(f"version={version}")
1945
+ if title is not None:
1946
+ args.append(f"title={title}")
1947
+ _record_summary(
1948
+ parent_ctx,
1949
+ command="content.update-page",
1950
+ args=args,
1951
+ server=server_key,
1952
+ exit_code=0,
1953
+ started_at=started_at,
1954
+ finished_at=finished_at,
1955
+ )
1956
+
1957
+
1958
+ @content_app.command("delete-page")
1959
+ def content_delete_page(
1960
+ ctx: typer.Context,
1961
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
1962
+ status: str = typer.Option("current", "--status", help="Specify current or draft"),
1963
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
1964
+ ) -> None:
1965
+ parent_ctx = _parent_context(ctx)
1966
+ _ensure_v1_api(parent_ctx, "content.delete-page")
1967
+ started_at = datetime.now(UTC)
1968
+ try:
1969
+ settings = load_settings(strict=True)
1970
+ server_key = _normalise_server(server, settings)
1971
+ client = ContentClient(_build_http_client(settings, server_key))
1972
+ client.delete_page(page_id, status=status)
1973
+ typer.echo(f"Deleted page {page_id} (status={status})")
1974
+ except Exception as exc:
1975
+ logger.exception("content.delete-page failed", extra={"page_id": page_id})
1976
+ typer.echo(f"Error: {exc}", err=True)
1977
+ raise typer.Exit(code=1) from None
1978
+ else:
1979
+ finished_at = datetime.now(UTC)
1980
+ _record_summary(
1981
+ parent_ctx,
1982
+ command="content.delete-page",
1983
+ args=[page_id, f"status={status}"],
1984
+ server=server_key,
1985
+ exit_code=0,
1986
+ started_at=started_at,
1987
+ finished_at=finished_at,
1988
+ )
1989
+
1990
+
1991
+ @content_app.command("add-attachment")
1992
+ def content_add_attachment(
1993
+ ctx: typer.Context,
1994
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
1995
+ file_path: Path = typer.Option(..., "--file", exists=True, file_okay=True, dir_okay=False, help="File to upload"),
1996
+ filename: str | None = typer.Option(None, "--filename", help="Override uploaded filename"),
1997
+ content_type: str | None = typer.Option(None, "--content-type", help="MIME type for the attachment"),
1998
+ comment: str | None = typer.Option(None, "--comment", help="Attachment comment"),
1999
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2000
+ ) -> None:
2001
+ parent_ctx = _parent_context(ctx)
2002
+ _ensure_v1_api(parent_ctx, "content.add-attachment")
2003
+ started_at = datetime.now(UTC)
2004
+ try:
2005
+ settings = load_settings(strict=True)
2006
+ server_key = _normalise_server(server, settings)
2007
+ client = ContentClient(_build_http_client(settings, server_key))
2008
+ payload = client.upload_attachment(
2009
+ page_id,
2010
+ str(file_path),
2011
+ filename=filename,
2012
+ content_type=content_type,
2013
+ comment=comment,
2014
+ )
2015
+ _emit_json(payload)
2016
+ except Exception as exc:
2017
+ logger.exception("content.add-attachment failed", extra={"page_id": page_id, "file": str(file_path)})
2018
+ typer.echo(f"Error: {exc}", err=True)
2019
+ raise typer.Exit(code=1) from None
2020
+ else:
2021
+ finished_at = datetime.now(UTC)
2022
+ _record_summary(
2023
+ parent_ctx,
2024
+ command="content.add-attachment",
2025
+ args=[page_id, file_path.name],
2026
+ server=server_key,
2027
+ exit_code=0,
2028
+ started_at=started_at,
2029
+ finished_at=finished_at,
2030
+ )
2031
+
2032
+
2033
+ @content_app.command("update-attachment")
2034
+ def content_update_attachment(
2035
+ ctx: typer.Context,
2036
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
2037
+ attachment_id: str = typer.Argument(..., help="Attachment ID"),
2038
+ file_path: Path = typer.Option(..., "--file", exists=True, file_okay=True, dir_okay=False, help="Replacement file"),
2039
+ filename: str | None = typer.Option(None, "--filename", help="Override uploaded filename"),
2040
+ content_type: str | None = typer.Option(None, "--content-type", help="MIME type for the attachment"),
2041
+ comment: str | None = typer.Option(None, "--comment", help="Attachment comment"),
2042
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2043
+ ) -> None:
2044
+ parent_ctx = _parent_context(ctx)
2045
+ _ensure_v1_api(parent_ctx, "content.update-attachment")
2046
+ started_at = datetime.now(UTC)
2047
+ try:
2048
+ settings = load_settings(strict=True)
2049
+ server_key = _normalise_server(server, settings)
2050
+ client = ContentClient(_build_http_client(settings, server_key))
2051
+ payload = client.update_attachment(
2052
+ page_id,
2053
+ attachment_id,
2054
+ str(file_path),
2055
+ filename=filename,
2056
+ content_type=content_type,
2057
+ comment=comment,
2058
+ )
2059
+ _emit_json(payload)
2060
+ except Exception as exc:
2061
+ logger.exception("content.update-attachment failed", extra={"page_id": page_id, "attachment_id": attachment_id})
2062
+ typer.echo(f"Error: {exc}", err=True)
2063
+ raise typer.Exit(code=1) from None
2064
+ else:
2065
+ finished_at = datetime.now(UTC)
2066
+ _record_summary(
2067
+ parent_ctx,
2068
+ command="content.update-attachment",
2069
+ args=[page_id, attachment_id],
2070
+ server=server_key,
2071
+ exit_code=0,
2072
+ started_at=started_at,
2073
+ finished_at=finished_at,
2074
+ )
2075
+
2076
+
2077
+ @content_app.command("delete-attachment")
2078
+ def content_delete_attachment(
2079
+ ctx: typer.Context,
2080
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
2081
+ attachment_id: str | None = typer.Argument(None, help="Attachment ID"),
2082
+ version: int | None = typer.Option(None, "--version", help="Delete a specific attachment version"),
2083
+ filename: str | None = typer.Option(None, "--filename", help="Attachment filename (delete all versions)"),
2084
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2085
+ ) -> None:
2086
+ parent_ctx = _parent_context(ctx)
2087
+ _ensure_v1_api(parent_ctx, "content.delete-attachment")
2088
+ started_at = datetime.now(UTC)
2089
+ try:
2090
+ settings = load_settings(strict=True)
2091
+ server_key = _normalise_server(server, settings)
2092
+ if not attachment_id and not filename:
2093
+ raise typer.BadParameter("Provide either attachment_id or --filename")
2094
+
2095
+ client = ContentClient(_build_http_client(settings, server_key))
2096
+ client.delete_attachment(page_id, attachment_id=attachment_id, version=version, filename=filename)
2097
+ target = filename or attachment_id
2098
+ typer.echo(f"Deleted attachment {target} from page {page_id}")
2099
+ except Exception as exc:
2100
+ logger.exception("content.delete-attachment failed", extra={"page_id": page_id, "attachment_id": attachment_id})
2101
+ typer.echo(f"Error: {exc}", err=True)
2102
+ raise typer.Exit(code=1) from None
2103
+ else:
2104
+ finished_at = datetime.now(UTC)
2105
+ _record_summary(
2106
+ parent_ctx,
2107
+ command="content.delete-attachment",
2108
+ args=[page_id, filename or attachment_id or "<unspecified>", f"version={version}" if version is not None else "all_versions"],
2109
+ server=server_key,
2110
+ exit_code=0,
2111
+ started_at=started_at,
2112
+ finished_at=finished_at,
2113
+ )
2114
+
2115
+
2116
+ @content_app.command("ancestors")
2117
+ def content_ancestors(
2118
+ ctx: typer.Context,
2119
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
2120
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2121
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
2122
+ ) -> None:
2123
+ """Get page ancestors (parent pages)."""
2124
+ parent_ctx = _parent_context(ctx)
2125
+ started_at = datetime.now(UTC)
2126
+ exit_code = 0
2127
+ result_payload: dict[str, Any] = {}
2128
+ server_key: str | None = None
2129
+
2130
+ try:
2131
+ settings = load_settings(strict=True)
2132
+ server_key = _normalise_server(server, settings)
2133
+ client = _build_http_client(settings, server_key)
2134
+
2135
+ result = client.get_page_ancestors(page_id)
2136
+ result_payload = {"page_id": page_id, "count": len(result), "ancestors": result}
2137
+
2138
+ if json_output or not sys.stdout.isatty():
2139
+ _emit_json(result_payload)
2140
+ else:
2141
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
2142
+
2143
+ except (ConfluenceClientError, typer.BadParameter) as exc:
2144
+ exit_code = 1
2145
+ logger.error("content.ancestors_failed: %s", exc)
2146
+ typer.echo(f"Error: {exc}", err=True)
2147
+ except Exception as exc: # pragma: no cover - unexpected errors
2148
+ exit_code = 1
2149
+ logger.exception("content.ancestors_unexpected_error")
2150
+ typer.echo(f"Error: {exc}", err=True)
2151
+ finally:
2152
+ finished_at = datetime.now(UTC)
2153
+ server_label = server_key or (server.lower() if server else "internal")
2154
+ _record_summary(
2155
+ parent_ctx,
2156
+ command="content.ancestors",
2157
+ args=[page_id],
2158
+ server=server_label,
2159
+ exit_code=exit_code,
2160
+ started_at=started_at,
2161
+ finished_at=finished_at,
2162
+ )
2163
+ if exit_code != 0:
2164
+ raise typer.Exit(code=exit_code) from None
2165
+
2166
+
2167
+ @content_app.command("move")
2168
+ def content_move(
2169
+ ctx: typer.Context,
2170
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
2171
+ target_title: str = typer.Option(..., "--target-title", help="Target page title to move under"),
2172
+ position: str = typer.Option("append", "--position", help="Position: append, before, after"),
2173
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2174
+ yes: bool = typer.Option(False, "--yes", help="Confirm move operation"),
2175
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
2176
+ ) -> None:
2177
+ """Move page to a new location."""
2178
+ parent_ctx = _parent_context(ctx)
2179
+ started_at = datetime.now(UTC)
2180
+ exit_code = 0
2181
+ result_payload: dict[str, Any] = {}
2182
+ server_key: str | None = None
2183
+
2184
+ try:
2185
+ if not yes:
2186
+ raise typer.BadParameter("--yes required for move operation")
2187
+ if position not in ("append", "before", "after"):
2188
+ raise typer.BadParameter("--position must be one of: append, before, after")
2189
+
2190
+ settings = load_settings(strict=True)
2191
+ server_key = _normalise_server(server, settings)
2192
+ client = _build_http_client(settings, server_key)
2193
+
2194
+ result = client.move_page(page_id, target_title, position=position)
2195
+ result_payload = {"page_id": page_id, "target_title": target_title, "position": position, "result": result}
2196
+
2197
+ if json_output or not sys.stdout.isatty():
2198
+ _emit_json(result_payload)
2199
+ else:
2200
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
2201
+
2202
+ except (ConfluenceClientError, typer.BadParameter) as exc:
2203
+ exit_code = 1
2204
+ logger.error("content.move_failed: %s", exc)
2205
+ typer.echo(f"Error: {exc}", err=True)
2206
+ except Exception as exc: # pragma: no cover - unexpected errors
2207
+ exit_code = 1
2208
+ logger.exception("content.move_unexpected_error")
2209
+ typer.echo(f"Error: {exc}", err=True)
2210
+ finally:
2211
+ finished_at = datetime.now(UTC)
2212
+ server_label = server_key or (server.lower() if server else "internal")
2213
+ _record_summary(
2214
+ parent_ctx,
2215
+ command="content.move",
2216
+ args=[page_id, f"target={target_title}", f"position={position}"],
2217
+ server=server_label,
2218
+ exit_code=exit_code,
2219
+ started_at=started_at,
2220
+ finished_at=finished_at,
2221
+ )
2222
+ if exit_code != 0:
2223
+ raise typer.Exit(code=exit_code) from None
2224
+
2225
+
2226
+ @content_app.command("tables")
2227
+ def content_tables(
2228
+ ctx: typer.Context,
2229
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
2230
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2231
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
2232
+ ) -> None:
2233
+ """Extract tables from page."""
2234
+ parent_ctx = _parent_context(ctx)
2235
+ started_at = datetime.now(UTC)
2236
+ exit_code = 0
2237
+ result_payload: dict[str, Any] = {}
2238
+ server_key: str | None = None
2239
+
2240
+ try:
2241
+ settings = load_settings(strict=True)
2242
+ server_key = _normalise_server(server, settings)
2243
+ client = _build_http_client(settings, server_key)
2244
+
2245
+ result = client.get_tables_from_page(page_id)
2246
+ result_payload = {"page_id": page_id, "count": len(result), "tables": result}
2247
+
2248
+ if json_output or not sys.stdout.isatty():
2249
+ _emit_json(result_payload)
2250
+ else:
2251
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
2252
+
2253
+ except (ConfluenceClientError, typer.BadParameter) as exc:
2254
+ exit_code = 1
2255
+ logger.error("content.tables_failed: %s", exc)
2256
+ typer.echo(f"Error: {exc}", err=True)
2257
+ except Exception as exc: # pragma: no cover - unexpected errors
2258
+ exit_code = 1
2259
+ logger.exception("content.tables_unexpected_error")
2260
+ typer.echo(f"Error: {exc}", err=True)
2261
+ finally:
2262
+ finished_at = datetime.now(UTC)
2263
+ server_label = server_key or (server.lower() if server else "internal")
2264
+ _record_summary(
2265
+ parent_ctx,
2266
+ command="content.tables",
2267
+ args=[page_id],
2268
+ server=server_label,
2269
+ exit_code=exit_code,
2270
+ started_at=started_at,
2271
+ finished_at=finished_at,
2272
+ )
2273
+ if exit_code != 0:
2274
+ raise typer.Exit(code=exit_code) from None
2275
+
2276
+
2277
+ @content_app.command("regex")
2278
+ def content_regex(
2279
+ ctx: typer.Context,
2280
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
2281
+ pattern: str = typer.Option(..., "--pattern", help="Regex pattern to match"),
2282
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2283
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
2284
+ ) -> None:
2285
+ """Extract regex matches from page."""
2286
+ parent_ctx = _parent_context(ctx)
2287
+ started_at = datetime.now(UTC)
2288
+ exit_code = 0
2289
+ result_payload: dict[str, Any] = {}
2290
+ server_key: str | None = None
2291
+
2292
+ try:
2293
+ settings = load_settings(strict=True)
2294
+ server_key = _normalise_server(server, settings)
2295
+ client = _build_http_client(settings, server_key)
2296
+
2297
+ result = client.scrap_regex_from_page(page_id, pattern)
2298
+ result_payload = {"page_id": page_id, "pattern": pattern, "count": len(result), "matches": result}
2299
+
2300
+ if json_output or not sys.stdout.isatty():
2301
+ _emit_json(result_payload)
2302
+ else:
2303
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
2304
+
2305
+ except (ConfluenceClientError, typer.BadParameter) as exc:
2306
+ exit_code = 1
2307
+ logger.error("content.regex_failed: %s", exc)
2308
+ typer.echo(f"Error: {exc}", err=True)
2309
+ except Exception as exc: # pragma: no cover - unexpected errors
2310
+ exit_code = 1
2311
+ logger.exception("content.regex_unexpected_error")
2312
+ typer.echo(f"Error: {exc}", err=True)
2313
+ finally:
2314
+ finished_at = datetime.now(UTC)
2315
+ server_label = server_key or (server.lower() if server else "internal")
2316
+ _record_summary(
2317
+ parent_ctx,
2318
+ command="content.regex",
2319
+ args=[page_id, f"pattern={pattern}"],
2320
+ server=server_label,
2321
+ exit_code=exit_code,
2322
+ started_at=started_at,
2323
+ finished_at=finished_at,
2324
+ )
2325
+ if exit_code != 0:
2326
+ raise typer.Exit(code=exit_code) from None
2327
+
2328
+
2329
+ @content_app.command("restrictions")
2330
+ def content_restrictions(
2331
+ ctx: typer.Context,
2332
+ page_id: str = typer.Argument(..., help="Confluence page ID"),
2333
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2334
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON payload"),
2335
+ ) -> None:
2336
+ """Get all restrictions for content."""
2337
+ parent_ctx = _parent_context(ctx)
2338
+ started_at = datetime.now(UTC)
2339
+ exit_code = 0
2340
+ result_payload: dict[str, Any] = {}
2341
+ server_key: str | None = None
2342
+
2343
+ try:
2344
+ settings = load_settings(strict=True)
2345
+ server_key = _normalise_server(server, settings)
2346
+ client = _build_http_client(settings, server_key)
2347
+
2348
+ result = client.get_all_restrictions_for_content(page_id)
2349
+ result_payload = {"page_id": page_id, "restrictions": result}
2350
+
2351
+ if json_output or not sys.stdout.isatty():
2352
+ _emit_json(result_payload)
2353
+ else:
2354
+ typer.echo(json.dumps(result_payload, indent=2, sort_keys=True))
2355
+
2356
+ except (ConfluenceClientError, typer.BadParameter) as exc:
2357
+ exit_code = 1
2358
+ logger.error("content.restrictions_failed: %s", exc)
2359
+ typer.echo(f"Error: {exc}", err=True)
2360
+ except Exception as exc: # pragma: no cover - unexpected errors
2361
+ exit_code = 1
2362
+ logger.exception("content.restrictions_unexpected_error")
2363
+ typer.echo(f"Error: {exc}", err=True)
2364
+ finally:
2365
+ finished_at = datetime.now(UTC)
2366
+ server_label = server_key or (server.lower() if server else "internal")
2367
+ _record_summary(
2368
+ parent_ctx,
2369
+ command="content.restrictions",
2370
+ args=[page_id],
2371
+ server=server_label,
2372
+ exit_code=exit_code,
2373
+ started_at=started_at,
2374
+ finished_at=finished_at,
2375
+ )
2376
+ if exit_code != 0:
2377
+ raise typer.Exit(code=exit_code) from None
2378
+
2379
+
2380
+ @content_app.command("create-template")
2381
+ def content_create_template(
2382
+ ctx: typer.Context,
2383
+ name: str = typer.Option(..., "--name", "-n", help="Template name"),
2384
+ body_file: Path = typer.Option(
2385
+ ..., "--body-file", exists=True, file_okay=True, dir_okay=False, help="Path to storage-format body"
2386
+ ),
2387
+ template_type: str = typer.Option("page", "--type", help="Template type (page or other supported types)"),
2388
+ space_key: str | None = typer.Option(None, "--space", help="Limit template to space key"),
2389
+ description: str | None = typer.Option(None, "--description", help="Template description"),
2390
+ representation: str = typer.Option("storage", "--representation", help="Content representation"),
2391
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2392
+ ) -> None:
2393
+ parent_ctx = _parent_context(ctx)
2394
+ _ensure_v1_api(parent_ctx, "content.create-template")
2395
+ started_at = datetime.now(UTC)
2396
+ try:
2397
+ settings = load_settings(strict=True)
2398
+ server_key = _normalise_server(server, settings)
2399
+ client = ContentClient(_build_http_client(settings, server_key))
2400
+ body = _read_text_file(body_file)
2401
+ payload = client.create_template(
2402
+ name=name,
2403
+ body=body,
2404
+ template_type=template_type,
2405
+ space_key=space_key,
2406
+ description=description,
2407
+ representation=representation,
2408
+ )
2409
+ _emit_json(payload)
2410
+ except Exception as exc:
2411
+ logger.exception("content.create-template failed", extra={"name": name, "space": space_key})
2412
+ typer.echo(f"Error: {exc}", err=True)
2413
+ raise typer.Exit(code=1) from None
2414
+ else:
2415
+ finished_at = datetime.now(UTC)
2416
+ args = [name, f"type={template_type}"]
2417
+ if space_key:
2418
+ args.append(f"space={space_key}")
2419
+ _record_summary(
2420
+ parent_ctx,
2421
+ command="content.create-template",
2422
+ args=args,
2423
+ server=server_key,
2424
+ exit_code=0,
2425
+ started_at=started_at,
2426
+ finished_at=finished_at,
2427
+ )
2428
+
2429
+
2430
+ @content_app.command("update-template")
2431
+ def content_update_template(
2432
+ ctx: typer.Context,
2433
+ template_id: str = typer.Argument(..., help="Template identifier"),
2434
+ name: str | None = typer.Option(None, "--name", help="Template name"),
2435
+ body_file: Path | None = typer.Option(
2436
+ None, "--body-file", exists=True, file_okay=True, dir_okay=False, help="Path to storage-format body"
2437
+ ),
2438
+ description: str | None = typer.Option(None, "--description", help="Template description"),
2439
+ template_type: str | None = typer.Option(None, "--type", help="Template type"),
2440
+ representation: str = typer.Option("storage", "--representation", help="Representation for --body-file"),
2441
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2442
+ ) -> None:
2443
+ parent_ctx = _parent_context(ctx)
2444
+ _ensure_v1_api(parent_ctx, "content.update-template")
2445
+ if name is None and body_file is None and description is None and template_type is None:
2446
+ raise typer.BadParameter("Provide at least one of --name, --body-file, --description, or --type")
2447
+ body = _read_text_file(body_file) if body_file else None
2448
+ started_at = datetime.now(UTC)
2449
+ try:
2450
+ settings = load_settings(strict=True)
2451
+ server_key = _normalise_server(server, settings)
2452
+ client = ContentClient(_build_http_client(settings, server_key))
2453
+ payload = client.update_template(
2454
+ template_id,
2455
+ name=name,
2456
+ body=body,
2457
+ description=description,
2458
+ representation=representation,
2459
+ template_type=template_type,
2460
+ )
2461
+ _emit_json(payload)
2462
+ except Exception as exc:
2463
+ logger.exception("content.update-template failed", extra={"template_id": template_id})
2464
+ typer.echo(f"Error: {exc}", err=True)
2465
+ raise typer.Exit(code=1) from None
2466
+ else:
2467
+ finished_at = datetime.now(UTC)
2468
+ args = [template_id]
2469
+ if name:
2470
+ args.append(f"name={name}")
2471
+ _record_summary(
2472
+ parent_ctx,
2473
+ command="content.update-template",
2474
+ args=args,
2475
+ server=server_key,
2476
+ exit_code=0,
2477
+ started_at=started_at,
2478
+ finished_at=finished_at,
2479
+ )
2480
+
2481
+
2482
+ @content_app.command("delete-template")
2483
+ def content_delete_template(
2484
+ ctx: typer.Context,
2485
+ template_id: str = typer.Argument(..., help="Template identifier"),
2486
+ server: str | None = typer.Option(None, "--server", "-s", help="internal|external"),
2487
+ ) -> None:
2488
+ parent_ctx = _parent_context(ctx)
2489
+ _ensure_v1_api(parent_ctx, "content.delete-template")
2490
+ started_at = datetime.now(UTC)
2491
+ try:
2492
+ settings = load_settings(strict=True)
2493
+ server_key = _normalise_server(server, settings)
2494
+ client = ContentClient(_build_http_client(settings, server_key))
2495
+ client.delete_template(template_id)
2496
+ typer.echo(f"Deleted template {template_id}")
2497
+ except Exception as exc:
2498
+ logger.exception("content.delete-template failed", extra={"template_id": template_id})
2499
+ typer.echo(f"Error: {exc}", err=True)
2500
+ raise typer.Exit(code=1) from None
2501
+ else:
2502
+ finished_at = datetime.now(UTC)
2503
+ _record_summary(
2504
+ parent_ctx,
2505
+ command="content.delete-template",
2506
+ args=[template_id],
2507
+ server=server_key,
2508
+ exit_code=0,
2509
+ started_at=started_at,
2510
+ finished_at=finished_at,
2511
+ )
2512
+
2513
+
2514
+ # ---------------------------------------------------------------------------
2515
+ # Diagnostics
2516
+ # ---------------------------------------------------------------------------
2517
+
2518
+
2519
+ @app.command("env")
2520
+ def cmd_env(ctx: typer.Context) -> None:
2521
+ settings = load_settings(strict=False)
2522
+ typer.echo(
2523
+ f"Default server: {settings.default_server}\n"
2524
+ f"Internal URL: {settings.internal_url}\n"
2525
+ f"External URL: {settings.external_url}\n"
2526
+ f"Internal token set: {bool(settings.internal_token)}\n"
2527
+ f"External token set: {bool(settings.external_token)}"
2528
+ )
2529
+
2530
+
2531
+ if __name__ == "__main__":
2532
+ app()