keboola-cli 0.63.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. keboola_agent_cli/__init__.py +34 -0
  2. keboola_agent_cli/__main__.py +5 -0
  3. keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js +2 -0
  4. keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js.map +1 -0
  5. keboola_agent_cli/_ui_dist/assets/architecture-7EHR7CIX-hNCijx_H.js +1 -0
  6. keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js +37 -0
  7. keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js.map +1 -0
  8. keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js +2 -0
  9. keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js.map +1 -0
  10. keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js +133 -0
  11. keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js.map +1 -0
  12. keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js +11 -0
  13. keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js.map +1 -0
  14. keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js +2 -0
  15. keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js.map +1 -0
  16. keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js +2 -0
  17. keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js.map +1 -0
  18. keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js +63 -0
  19. keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js.map +1 -0
  20. keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js +2 -0
  21. keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js.map +1 -0
  22. keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js +2 -0
  23. keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js.map +1 -0
  24. keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js +3 -0
  25. keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js.map +1 -0
  26. keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js +207 -0
  27. keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js.map +1 -0
  28. keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js +232 -0
  29. keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js.map +1 -0
  30. keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js +2 -0
  31. keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js.map +1 -0
  32. keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js +124 -0
  33. keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js.map +1 -0
  34. keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js +16 -0
  35. keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js.map +1 -0
  36. keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js +2 -0
  37. keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js.map +1 -0
  38. keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js +2 -0
  39. keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js.map +1 -0
  40. keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +160 -0
  41. keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js.map +1 -0
  42. keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js +2 -0
  43. keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js.map +1 -0
  44. keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js +71 -0
  45. keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js.map +1 -0
  46. keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js +2 -0
  47. keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js.map +1 -0
  48. keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js +2 -0
  49. keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js.map +1 -0
  50. keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js +2 -0
  51. keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js.map +1 -0
  52. keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js +2 -0
  53. keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js.map +1 -0
  54. keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js +322 -0
  55. keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js.map +1 -0
  56. keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js +5 -0
  57. keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js.map +1 -0
  58. keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js +2 -0
  59. keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js.map +1 -0
  60. keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js +2 -0
  61. keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js.map +1 -0
  62. keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js +44 -0
  63. keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js.map +1 -0
  64. keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js +11 -0
  65. keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js.map +1 -0
  66. keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js +4 -0
  67. keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js.map +1 -0
  68. keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js +25 -0
  69. keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js.map +1 -0
  70. keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js +25 -0
  71. keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js.map +1 -0
  72. keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js +2 -0
  73. keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js.map +1 -0
  74. keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js +86 -0
  75. keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js.map +1 -0
  76. keboola_agent_cli/_ui_dist/assets/eventmodeling-FCH6USID-BrJMIks8.js +1 -0
  77. keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js +163 -0
  78. keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js.map +1 -0
  79. keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js +293 -0
  80. keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js.map +1 -0
  81. keboola_agent_cli/_ui_dist/assets/gitGraph-WXDBUCRP-COacYjo-.js +1 -0
  82. keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js +107 -0
  83. keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js.map +1 -0
  84. keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js +2 -0
  85. keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js.map +1 -0
  86. keboola_agent_cli/_ui_dist/assets/index-CMq50kkV.css +1 -0
  87. keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js +118 -0
  88. keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js.map +1 -0
  89. keboola_agent_cli/_ui_dist/assets/info-J43DQDTF-DdCTRIzU.js +1 -0
  90. keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js +3 -0
  91. keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js.map +1 -0
  92. keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js +2 -0
  93. keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js.map +1 -0
  94. keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js +71 -0
  95. keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js.map +1 -0
  96. keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js +140 -0
  97. keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js.map +1 -0
  98. keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js +90 -0
  99. keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js.map +1 -0
  100. keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js +258 -0
  101. keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js.map +1 -0
  102. keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js +2 -0
  103. keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js.map +1 -0
  104. keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js +2 -0
  105. keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js.map +1 -0
  106. keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js +5 -0
  107. keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js.map +1 -0
  108. keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js +97 -0
  109. keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js.map +1 -0
  110. keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js +2 -0
  111. keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js.map +1 -0
  112. keboola_agent_cli/_ui_dist/assets/packet-YPE3B663-DLiiw_B2.js +1 -0
  113. keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js +2 -0
  114. keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js.map +1 -0
  115. keboola_agent_cli/_ui_dist/assets/pie-LRSECV5Y-CRoO8G1g.js +1 -0
  116. keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js +31 -0
  117. keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js.map +1 -0
  118. keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js +8 -0
  119. keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js.map +1 -0
  120. keboola_agent_cli/_ui_dist/assets/radar-GUYGQ44K-DAlLVJHm.js +1 -0
  121. keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js +85 -0
  122. keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js.map +1 -0
  123. keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js +2 -0
  124. keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js.map +1 -0
  125. keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js +41 -0
  126. keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js.map +1 -0
  127. keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js +163 -0
  128. keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js.map +1 -0
  129. keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js +2 -0
  130. keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js.map +1 -0
  131. keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js +2 -0
  132. keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js.map +1 -0
  133. keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js +2 -0
  134. keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js.map +1 -0
  135. keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js +121 -0
  136. keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js.map +1 -0
  137. keboola_agent_cli/_ui_dist/assets/treeView-BLDUP644-DbLYl23-.js +1 -0
  138. keboola_agent_cli/_ui_dist/assets/treemap-LRROVOQU-Bp0eGlOt.js +1 -0
  139. keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js +35 -0
  140. keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js.map +1 -0
  141. keboola_agent_cli/_ui_dist/assets/wardley-L42UT6IY-D4yH4jqS.js +1 -0
  142. keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js +79 -0
  143. keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js.map +1 -0
  144. keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js +8 -0
  145. keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js.map +1 -0
  146. keboola_agent_cli/_ui_dist/index.html +50 -0
  147. keboola_agent_cli/ai_client.py +83 -0
  148. keboola_agent_cli/auto_update.py +550 -0
  149. keboola_agent_cli/changelog.py +1198 -0
  150. keboola_agent_cli/cli.py +448 -0
  151. keboola_agent_cli/client.py +3422 -0
  152. keboola_agent_cli/commands/__init__.py +0 -0
  153. keboola_agent_cli/commands/_data_app_git.py +343 -0
  154. keboola_agent_cli/commands/_helpers.py +377 -0
  155. keboola_agent_cli/commands/_metadata_input.py +49 -0
  156. keboola_agent_cli/commands/_semantic_layer_crud.py +632 -0
  157. keboola_agent_cli/commands/_semantic_layer_helpers.py +44 -0
  158. keboola_agent_cli/commands/_semantic_layer_reference_data.py +247 -0
  159. keboola_agent_cli/commands/agent.py +968 -0
  160. keboola_agent_cli/commands/branch.py +423 -0
  161. keboola_agent_cli/commands/changelog.py +168 -0
  162. keboola_agent_cli/commands/component.py +216 -0
  163. keboola_agent_cli/commands/config.py +2442 -0
  164. keboola_agent_cli/commands/context.py +1481 -0
  165. keboola_agent_cli/commands/data_app.py +1279 -0
  166. keboola_agent_cli/commands/dev_portal.py +584 -0
  167. keboola_agent_cli/commands/doctor.py +37 -0
  168. keboola_agent_cli/commands/encrypt.py +145 -0
  169. keboola_agent_cli/commands/feature.py +311 -0
  170. keboola_agent_cli/commands/flow.py +948 -0
  171. keboola_agent_cli/commands/http_client.py +157 -0
  172. keboola_agent_cli/commands/init.py +279 -0
  173. keboola_agent_cli/commands/job.py +661 -0
  174. keboola_agent_cli/commands/kai.py +301 -0
  175. keboola_agent_cli/commands/lineage.py +1464 -0
  176. keboola_agent_cli/commands/org.py +292 -0
  177. keboola_agent_cli/commands/permissions.py +360 -0
  178. keboola_agent_cli/commands/project.py +1192 -0
  179. keboola_agent_cli/commands/repl.py +243 -0
  180. keboola_agent_cli/commands/schedule.py +340 -0
  181. keboola_agent_cli/commands/search.py +178 -0
  182. keboola_agent_cli/commands/semantic_layer.py +939 -0
  183. keboola_agent_cli/commands/serve.py +272 -0
  184. keboola_agent_cli/commands/sharing.py +340 -0
  185. keboola_agent_cli/commands/storage.py +2630 -0
  186. keboola_agent_cli/commands/stream.py +266 -0
  187. keboola_agent_cli/commands/sync.py +1277 -0
  188. keboola_agent_cli/commands/tool.py +206 -0
  189. keboola_agent_cli/commands/version.py +186 -0
  190. keboola_agent_cli/commands/workspace.py +635 -0
  191. keboola_agent_cli/config_store.py +582 -0
  192. keboola_agent_cli/constants.py +528 -0
  193. keboola_agent_cli/data_science_client.py +342 -0
  194. keboola_agent_cli/dev_portal_client.py +323 -0
  195. keboola_agent_cli/errors.py +248 -0
  196. keboola_agent_cli/http_base.py +315 -0
  197. keboola_agent_cli/json_utils.py +126 -0
  198. keboola_agent_cli/lib.py +536 -0
  199. keboola_agent_cli/manage_client.py +324 -0
  200. keboola_agent_cli/metastore_client.py +214 -0
  201. keboola_agent_cli/models.py +427 -0
  202. keboola_agent_cli/output.py +1084 -0
  203. keboola_agent_cli/permissions.py +469 -0
  204. keboola_agent_cli/py.typed +3 -0
  205. keboola_agent_cli/result_models.py +271 -0
  206. keboola_agent_cli/server/__init__.py +34 -0
  207. keboola_agent_cli/server/agent_runner.py +1289 -0
  208. keboola_agent_cli/server/agents_store.py +325 -0
  209. keboola_agent_cli/server/app.py +764 -0
  210. keboola_agent_cli/server/auth.py +117 -0
  211. keboola_agent_cli/server/dependencies.py +149 -0
  212. keboola_agent_cli/server/pricing.py +303 -0
  213. keboola_agent_cli/server/routers/__init__.py +1 -0
  214. keboola_agent_cli/server/routers/agents.py +616 -0
  215. keboola_agent_cli/server/routers/ai_chat.py +129 -0
  216. keboola_agent_cli/server/routers/branches.py +133 -0
  217. keboola_agent_cli/server/routers/components.py +48 -0
  218. keboola_agent_cli/server/routers/configs.py +507 -0
  219. keboola_agent_cli/server/routers/data_apps.py +384 -0
  220. keboola_agent_cli/server/routers/dev_portal.py +67 -0
  221. keboola_agent_cli/server/routers/encrypt.py +35 -0
  222. keboola_agent_cli/server/routers/feature.py +179 -0
  223. keboola_agent_cli/server/routers/flows.py +204 -0
  224. keboola_agent_cli/server/routers/health.py +53 -0
  225. keboola_agent_cli/server/routers/jobs.py +175 -0
  226. keboola_agent_cli/server/routers/kai.py +80 -0
  227. keboola_agent_cli/server/routers/lineage.py +226 -0
  228. keboola_agent_cli/server/routers/mcp.py +70 -0
  229. keboola_agent_cli/server/routers/members.py +170 -0
  230. keboola_agent_cli/server/routers/org.py +96 -0
  231. keboola_agent_cli/server/routers/projects.py +106 -0
  232. keboola_agent_cli/server/routers/schedules.py +54 -0
  233. keboola_agent_cli/server/routers/search.py +30 -0
  234. keboola_agent_cli/server/routers/semantic_layer.py +650 -0
  235. keboola_agent_cli/server/routers/sharing.py +86 -0
  236. keboola_agent_cli/server/routers/storage.py +574 -0
  237. keboola_agent_cli/server/routers/stream.py +100 -0
  238. keboola_agent_cli/server/routers/workspaces.py +302 -0
  239. keboola_agent_cli/server/run_broadcaster.py +329 -0
  240. keboola_agent_cli/server/sse.py +25 -0
  241. keboola_agent_cli/services/__init__.py +0 -0
  242. keboola_agent_cli/services/_encryption.py +217 -0
  243. keboola_agent_cli/services/_semantic_layer_cascade.py +147 -0
  244. keboola_agent_cli/services/_semantic_layer_crud.py +382 -0
  245. keboola_agent_cli/services/_semantic_layer_internals.py +1078 -0
  246. keboola_agent_cli/services/_semantic_layer_lookup.py +181 -0
  247. keboola_agent_cli/services/_semantic_layer_reference_data.py +217 -0
  248. keboola_agent_cli/services/_sync_bindings.py +456 -0
  249. keboola_agent_cli/services/_sync_branch.py +191 -0
  250. keboola_agent_cli/services/_sync_bulk.py +228 -0
  251. keboola_agent_cli/services/_sync_clone.py +163 -0
  252. keboola_agent_cli/services/_sync_models.py +97 -0
  253. keboola_agent_cli/services/_sync_push_ops.py +369 -0
  254. keboola_agent_cli/services/_sync_storage.py +376 -0
  255. keboola_agent_cli/services/_sync_writeback.py +167 -0
  256. keboola_agent_cli/services/agent_service.py +458 -0
  257. keboola_agent_cli/services/base.py +175 -0
  258. keboola_agent_cli/services/branch_service.py +588 -0
  259. keboola_agent_cli/services/component_service.py +694 -0
  260. keboola_agent_cli/services/config_service.py +2099 -0
  261. keboola_agent_cli/services/data_app_git_service.py +224 -0
  262. keboola_agent_cli/services/data_app_service.py +2082 -0
  263. keboola_agent_cli/services/deep_lineage_service.py +1322 -0
  264. keboola_agent_cli/services/dev_portal_service.py +345 -0
  265. keboola_agent_cli/services/doctor_service.py +445 -0
  266. keboola_agent_cli/services/encrypt_service.py +87 -0
  267. keboola_agent_cli/services/feature_service.py +268 -0
  268. keboola_agent_cli/services/flow_service.py +769 -0
  269. keboola_agent_cli/services/flow_validation.py +188 -0
  270. keboola_agent_cli/services/http_forwarder_service.py +236 -0
  271. keboola_agent_cli/services/job_idempotency_store.py +285 -0
  272. keboola_agent_cli/services/job_service.py +797 -0
  273. keboola_agent_cli/services/kai_service.py +367 -0
  274. keboola_agent_cli/services/lineage_service.py +274 -0
  275. keboola_agent_cli/services/mcp_service.py +1498 -0
  276. keboola_agent_cli/services/mcp_transport.py +259 -0
  277. keboola_agent_cli/services/member_service.py +593 -0
  278. keboola_agent_cli/services/org_service.py +619 -0
  279. keboola_agent_cli/services/project_service.py +947 -0
  280. keboola_agent_cli/services/repo_validate_service.py +767 -0
  281. keboola_agent_cli/services/schedule_service.py +731 -0
  282. keboola_agent_cli/services/search_service.py +331 -0
  283. keboola_agent_cli/services/semantic_layer_service.py +1497 -0
  284. keboola_agent_cli/services/sharing_service.py +307 -0
  285. keboola_agent_cli/services/storage_service.py +2524 -0
  286. keboola_agent_cli/services/stream_service.py +395 -0
  287. keboola_agent_cli/services/sync_service.py +2244 -0
  288. keboola_agent_cli/services/variables_service.py +447 -0
  289. keboola_agent_cli/services/version_service.py +1038 -0
  290. keboola_agent_cli/services/workspace_service.py +1103 -0
  291. keboola_agent_cli/stream_client.py +217 -0
  292. keboola_agent_cli/sync/__init__.py +1 -0
  293. keboola_agent_cli/sync/branch_mapping.py +174 -0
  294. keboola_agent_cli/sync/clone.py +211 -0
  295. keboola_agent_cli/sync/code_extraction.py +655 -0
  296. keboola_agent_cli/sync/config_format.py +290 -0
  297. keboola_agent_cli/sync/diff_engine.py +566 -0
  298. keboola_agent_cli/sync/git_utils.py +93 -0
  299. keboola_agent_cli/sync/manifest.py +162 -0
  300. keboola_agent_cli/sync/naming.py +90 -0
  301. keboola_agent_cli/sync/secrets.py +62 -0
  302. keboola_agent_cli/sync/sql_split.py +134 -0
  303. keboola_cli-0.63.4.dist-info/METADATA +308 -0
  304. keboola_cli-0.63.4.dist-info/RECORD +306 -0
  305. keboola_cli-0.63.4.dist-info/WHEEL +4 -0
  306. keboola_cli-0.63.4.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1277 @@
1
+ """Sync commands - init, pull, push, diff, and status for local filesystem sync.
2
+
3
+ Thin CLI layer: parses arguments, calls SyncService, formats output.
4
+ No business logic belongs here.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+
12
+ from ..errors import ConfigError, ErrorCode, KeboolaApiError, SyncConflictError
13
+ from ._helpers import check_cli_permission, get_formatter, get_service, map_error_to_exit_code
14
+
15
+ sync_app = typer.Typer(help="Sync project configurations with local filesystem")
16
+
17
+
18
+ @sync_app.callback(invoke_without_command=True)
19
+ def _sync_permission_check(ctx: typer.Context) -> None:
20
+ check_cli_permission(ctx, "sync")
21
+
22
+
23
+ KEBOOLA_DIR = ".keboola"
24
+ MANIFEST_FILE = "manifest.json"
25
+
26
+
27
+ def _safe_resolve_dir(directory: Path) -> Path:
28
+ """Resolve a directory path safely (handles deleted CWD)."""
29
+ try:
30
+ return directory.resolve()
31
+ except (OSError, ValueError):
32
+ return Path(str(directory)).expanduser()
33
+
34
+
35
+ def _resolve_project_root(directory: Path, alias: str | None = None) -> Path:
36
+ """Find the project root directory containing .keboola/manifest.json.
37
+
38
+ Tries in order:
39
+ 1. directory itself (explicit --directory or current dir)
40
+ 2. directory/{alias}/ (auto-detect subdirectory from --all-projects layout)
41
+ """
42
+ root = _safe_resolve_dir(directory)
43
+ if (root / KEBOOLA_DIR / MANIFEST_FILE).exists():
44
+ return root
45
+ if alias:
46
+ sub = root / alias
47
+ if (sub / KEBOOLA_DIR / MANIFEST_FILE).exists():
48
+ return sub
49
+ return root # let caller handle the error
50
+
51
+
52
+ def _load_override_file(path: Path) -> dict[str, str]:
53
+ """Load a clone override map (JSON or YAML) as a flat ``{str: str}`` dict.
54
+
55
+ YAML's loader also parses JSON, so a single path handles both. Every value
56
+ is coerced to ``str`` (bucket ids, variable values, and path prefixes are
57
+ all strings).
58
+ """
59
+ import yaml
60
+
61
+ if not path.exists():
62
+ raise ConfigError(f"Override file not found: {path}")
63
+ try:
64
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
65
+ except yaml.YAMLError as exc:
66
+ raise ConfigError(f"Cannot parse override file {path}: {exc}") from exc
67
+ if not isinstance(data, dict):
68
+ raise ConfigError(f"Override file {path} must contain a JSON/YAML object (mapping).")
69
+ return {str(key): str(value) for key, value in data.items()}
70
+
71
+
72
+ def _change_label(change: dict) -> str:
73
+ """Build a human-readable label for a config change entry."""
74
+ path = change.get("path", "")
75
+ name = change.get("config_name", "")
76
+ component = change["component_id"]
77
+ if path:
78
+ return f"{component}/{path}"
79
+ if name:
80
+ return f"{component}/{name}"
81
+ config_id = change.get("config_id", "")
82
+ return f"{component}/{config_id}" if config_id else component
83
+
84
+
85
+ @sync_app.command("init")
86
+ def sync_init(
87
+ ctx: typer.Context,
88
+ project: str = typer.Option(
89
+ ...,
90
+ "--project",
91
+ help="Project alias to initialize sync for",
92
+ ),
93
+ directory: Path = typer.Option(
94
+ Path("."),
95
+ "--directory",
96
+ "-d",
97
+ help="Target directory for the project files",
98
+ ),
99
+ git_branching: bool = typer.Option(
100
+ False,
101
+ "--git-branching",
102
+ help="Enable git-branching mode (maps git branches to Keboola branches)",
103
+ ),
104
+ adopt_existing: bool = typer.Option(
105
+ False,
106
+ "--adopt-existing",
107
+ help="Adopt an existing .keboola/manifest.json (e.g. written by kbc) "
108
+ "instead of failing. Validates the manifest's project_id against the alias "
109
+ "and normalises the file. Idempotent.",
110
+ ),
111
+ ) -> None:
112
+ """Initialize a sync working directory for a Keboola project.
113
+
114
+ Creates the .keboola/ directory with manifest.json containing
115
+ project metadata and naming conventions. Optionally enables
116
+ git-branching mode for branch-to-branch mapping.
117
+
118
+ Use --adopt-existing to register a directory that was already initialised
119
+ by the official kbc CLI without overwriting the manifest.
120
+ """
121
+ formatter = get_formatter(ctx)
122
+ service = get_service(ctx, "sync_service")
123
+ project_root = directory.resolve()
124
+
125
+ try:
126
+ result = service.init_sync(
127
+ alias=project,
128
+ project_root=project_root,
129
+ git_branching=git_branching,
130
+ adopt_existing=adopt_existing,
131
+ )
132
+ except ConfigError as exc:
133
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
134
+ raise typer.Exit(code=5) from None
135
+ except FileExistsError as exc:
136
+ formatter.error(message=str(exc), error_code=ErrorCode.ALREADY_EXISTS)
137
+ raise typer.Exit(code=1) from None
138
+ except KeboolaApiError as exc:
139
+ formatter.error(
140
+ message=exc.message,
141
+ error_code=exc.error_code,
142
+ retryable=exc.retryable,
143
+ )
144
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
145
+
146
+ if formatter.json_mode:
147
+ formatter.output(result)
148
+ else:
149
+ status = result.get("status", "initialized")
150
+ if status == "adopted":
151
+ formatter.success(
152
+ f"Adopted manifest for project '{result['project_alias']}' "
153
+ f"(ID: {result['project_id']})"
154
+ )
155
+ else:
156
+ formatter.success(
157
+ f"Initialized sync for project '{result['project_alias']}' "
158
+ f"(ID: {result['project_id']})"
159
+ )
160
+ formatter.console.print(f" API host: {result['api_host']}")
161
+ if result["git_branching"]:
162
+ formatter.console.print(
163
+ f" Git-branching: enabled (default branch: {result['default_branch']})"
164
+ )
165
+ for f in result["files_created"]:
166
+ formatter.console.print(f" Created: {f}")
167
+
168
+
169
+ def _format_pull_result(formatter: Any, result: dict) -> None:
170
+ """Format a single-project pull result for human output."""
171
+ is_dry = result.get("status") == "dry_run"
172
+ details = result.get("details", [])
173
+ new_cfgs = [d for d in details if d["action"] == "new"]
174
+ updated_cfgs = [d for d in details if d["action"] == "updated"]
175
+ removed_cfgs = [d for d in details if d["action"] == "removed"]
176
+ renamed_cfgs = [d for d in details if d["action"] == "renamed"]
177
+ skipped_cfgs = [d for d in details if d["action"] == "skipped"]
178
+
179
+ has_changes = bool(new_cfgs or updated_cfgs or removed_cfgs or renamed_cfgs)
180
+
181
+ storage = result.get("storage", {})
182
+ jobs_written = result.get("jobs_written", 0)
183
+ has_extra = bool(storage.get("buckets") or storage.get("tables") or jobs_written)
184
+
185
+ if not has_changes and not skipped_cfgs and not has_extra:
186
+ formatter.console.print("[green]Already up to date.[/green] No changes from remote.")
187
+ return
188
+ elif is_dry:
189
+ formatter.console.print("[yellow]Dry run -- no files written:[/yellow]")
190
+ formatter.console.print(
191
+ f" Would pull {result['configs_pulled']} configurations "
192
+ f"({result['rows_pulled']} rows), "
193
+ f"write {result['files_written']} files"
194
+ )
195
+ else:
196
+ formatter.success(
197
+ f"Pulled {result['configs_pulled']} configurations "
198
+ f"({result['rows_pulled']} rows) "
199
+ f"into {result['branch_dir']}/"
200
+ )
201
+ formatter.console.print(f" Files written: {result['files_written']}")
202
+ if storage.get("buckets") or storage.get("tables"):
203
+ formatter.console.print(
204
+ f" Storage: {storage.get('buckets', 0)} buckets, {storage.get('tables', 0)} tables"
205
+ )
206
+ if storage.get("samples"):
207
+ formatter.console.print(f" Samples: {storage['samples']} tables")
208
+ if jobs_written:
209
+ formatter.console.print(f" Jobs: {jobs_written} configs with job history")
210
+
211
+ if renamed_cfgs:
212
+ formatter.console.print(f" [magenta]Renamed ({len(renamed_cfgs)}):[/magenta]")
213
+ for d in renamed_cfgs:
214
+ formatter.console.print(
215
+ f" > {d.get('old_path', '?')} -> {d['component_id']}/{d['config_name']}"
216
+ )
217
+ if new_cfgs:
218
+ formatter.console.print(f" [green]New ({len(new_cfgs)}):[/green]")
219
+ for d in new_cfgs:
220
+ formatter.console.print(f" + {d['component_id']}/{d['config_name']}")
221
+ if updated_cfgs:
222
+ formatter.console.print(f" [yellow]Updated ({len(updated_cfgs)}):[/yellow]")
223
+ for d in updated_cfgs:
224
+ formatter.console.print(f" ~ {d['component_id']}/{d['config_name']}")
225
+ if removed_cfgs:
226
+ formatter.console.print(f" [red]Removed from remote ({len(removed_cfgs)}):[/red]")
227
+ for d in removed_cfgs:
228
+ formatter.console.print(f" - {d['path']}")
229
+ if skipped_cfgs:
230
+ formatter.console.print(
231
+ f" [cyan]Skipped ({len(skipped_cfgs)}) -- locally modified:[/cyan]"
232
+ )
233
+ for d in skipped_cfgs:
234
+ formatter.console.print(f" ! {d['component_id']}/{d['config_name']}")
235
+
236
+
237
+ def _format_diff_result(formatter: Any, result: dict) -> None:
238
+ """Format a single-project diff result for human output."""
239
+ changes = result["changes"]
240
+ summary = result["summary"]
241
+ remote_only = result.get("remote_only", [])
242
+
243
+ if not changes and not remote_only:
244
+ formatter.console.print("[green]No differences.[/green]")
245
+ return
246
+
247
+ local_changes = [c for c in changes if c["change_type"] in ("added", "modified", "deleted")]
248
+ remote_changes = [c for c in changes if c["change_type"] == "remote_modified"]
249
+ conflict_changes = [c for c in changes if c["change_type"] == "conflict"]
250
+
251
+ if local_changes:
252
+ for change in local_changes:
253
+ ct = change["change_type"]
254
+ label = _change_label(change)
255
+ prefix = {"added": "+", "modified": "~", "deleted": "-"}.get(ct, "?")
256
+ formatter.console.print(f" {prefix} {ct.upper()} {label}")
257
+ formatter.console.print(
258
+ f" {summary['added']} to create, {summary['modified']} to update, "
259
+ f"{summary['deleted']} to delete"
260
+ )
261
+ if remote_changes:
262
+ for change in remote_changes:
263
+ formatter.console.print(f" ~ REMOTE MODIFIED {_change_label(change)}")
264
+ if conflict_changes:
265
+ for change in conflict_changes:
266
+ formatter.console.print(f" ! CONFLICT {_change_label(change)}")
267
+ if remote_only:
268
+ formatter.console.print(f" {len(remote_only)} new remote-only config(s)")
269
+
270
+
271
+ def _format_conflict_list(formatter: Any, conflicts: list[dict[str, str]]) -> None:
272
+ """Print the per-config force-pull conflict list (human mode only)."""
273
+ if not conflicts:
274
+ return
275
+ n = len(conflicts)
276
+ formatter.console.print(
277
+ f"\n[bold red]Merge conflict:[/bold red] {n} config(s) changed BOTH "
278
+ f"locally and on the remote since the last pull:"
279
+ )
280
+ for c in conflicts:
281
+ label = "row" if c.get("scope") == "row" else "config"
282
+ formatter.console.print(
283
+ f" [red]![/red] {c.get('component_id')}/{c.get('config_id')} "
284
+ f"[dim]({label})[/dim] {c.get('config_name', '')}"
285
+ )
286
+
287
+
288
+ def _format_push_result(formatter: Any, result: dict) -> None:
289
+ """Format a single-project push result for human output."""
290
+ status = result.get("status", "")
291
+ if status == "no_changes":
292
+ formatter.console.print(" No changes to push.")
293
+ return
294
+ if status == "dry_run":
295
+ summary = result.get("summary", {})
296
+ formatter.console.print(
297
+ f" Would create {summary.get('added', 0)}, "
298
+ f"update {summary.get('modified', 0)}, "
299
+ f"delete {summary.get('deleted', 0)}"
300
+ )
301
+ return
302
+ formatter.console.print(
303
+ f" {result.get('created', 0)} created, "
304
+ f"{result.get('updated', 0)} updated, "
305
+ f"{result.get('deleted', 0)} deleted"
306
+ )
307
+ # Show name drift warnings
308
+ drift_warnings = result.get("name_drift_warnings", [])
309
+ if drift_warnings:
310
+ formatter.console.print(
311
+ f"\n [yellow]Warning: {len(drift_warnings)} config(s) have "
312
+ f"local directory names that don't match their config name:[/yellow]"
313
+ )
314
+ for w in drift_warnings:
315
+ formatter.console.print(
316
+ f" '{w['local_dirname']}' should be "
317
+ f"'{w['expected_dirname']}' (config: {w['config_name']})"
318
+ )
319
+ formatter.console.print(" Run 'kbagent config rename' or 'kbagent sync pull' to fix.")
320
+
321
+
322
+ def _pull_one_liner(result: dict) -> str:
323
+ """One-line summary of a single pull result."""
324
+ details = result.get("details", [])
325
+ new_n = sum(1 for d in details if d["action"] == "new")
326
+ upd_n = sum(1 for d in details if d["action"] == "updated")
327
+ rem_n = sum(1 for d in details if d["action"] == "removed")
328
+ ren_n = sum(1 for d in details if d["action"] == "renamed")
329
+ skip_n = sum(1 for d in details if d["action"] == "skipped")
330
+ if not new_n and not upd_n and not rem_n and not ren_n and not skip_n:
331
+ return "[green]up to date[/green]"
332
+ parts = []
333
+ if ren_n:
334
+ parts.append(f"[magenta]>{ren_n} renamed[/magenta]")
335
+ if new_n:
336
+ parts.append(f"[green]+{new_n} new[/green]")
337
+ if upd_n:
338
+ parts.append(f"[yellow]~{upd_n} updated[/yellow]")
339
+ if rem_n:
340
+ parts.append(f"[red]-{rem_n} removed[/red]")
341
+ if skip_n:
342
+ parts.append(f"[cyan]!{skip_n} skipped[/cyan]")
343
+ return ", ".join(parts)
344
+
345
+
346
+ def _diff_one_liner(result: dict) -> str:
347
+ """One-line summary of a single diff result."""
348
+ s = result.get("summary", {})
349
+ mod = s.get("modified", 0)
350
+ add = s.get("added", 0)
351
+ dlt = s.get("deleted", 0)
352
+ rmod = s.get("remote_modified", 0)
353
+ conf = s.get("conflict", 0)
354
+ ro = s.get("remote_only", 0)
355
+ if not any([mod, add, dlt, rmod, conf, ro]):
356
+ return "[green]in sync[/green]"
357
+ parts = []
358
+ if add:
359
+ parts.append(f"[green]{add} to create[/green]")
360
+ if mod:
361
+ parts.append(f"[yellow]{mod} to push[/yellow]")
362
+ if dlt:
363
+ parts.append(f"[red]{dlt} to delete[/red]")
364
+ if rmod:
365
+ parts.append(f"[cyan]{rmod} to pull[/cyan]")
366
+ if conf:
367
+ parts.append(f"[red]{conf} conflicts[/red]")
368
+ if ro:
369
+ parts.append(f"[cyan]{ro} new remote[/cyan]")
370
+ return ", ".join(parts)
371
+
372
+
373
+ def _push_one_liner(result: dict) -> str:
374
+ """One-line summary of a single push result."""
375
+ status = result.get("status", "")
376
+ if status == "no_changes":
377
+ return "[green]nothing to push[/green]"
378
+ if status == "dry_run":
379
+ s = result.get("summary", {})
380
+ return f"would: +{s.get('added', 0)} ~{s.get('modified', 0)} -{s.get('deleted', 0)}"
381
+ c = result.get("created", 0)
382
+ u = result.get("updated", 0)
383
+ d = result.get("deleted", 0)
384
+ return f"+{c} created, ~{u} updated, -{d} deleted"
385
+
386
+
387
+ def _format_all_results(
388
+ formatter: Any,
389
+ data: dict,
390
+ per_project_formatter: Any = None,
391
+ one_liner: Any = None,
392
+ ) -> None:
393
+ """Format multi-project results for human output.
394
+
395
+ In default mode: one line per project (compact summary).
396
+ With --verbose: full detail per project.
397
+ Errors always shown.
398
+ """
399
+ summary = data["summary"]
400
+ projects = data["projects"]
401
+ skipped = data.get("skipped", [])
402
+ verbose = getattr(formatter, "verbose", False)
403
+
404
+ for alias in sorted(projects):
405
+ proj_result = projects[alias]
406
+ if "error" in proj_result:
407
+ formatter.console.print(f" [red]x[/red] {alias}: [red]{proj_result['error']}[/red]")
408
+ elif verbose and per_project_formatter:
409
+ formatter.console.print(f"\n[bold]{alias}:[/bold]")
410
+ per_project_formatter(formatter, proj_result)
411
+ elif one_liner:
412
+ formatter.console.print(f" [green]OK[/green] {alias}: {one_liner(proj_result)}")
413
+ else:
414
+ formatter.console.print(f" [green]OK[/green] {alias}")
415
+
416
+ if skipped:
417
+ formatter.console.print(f"\n[dim]Skipped (no manifest): {', '.join(skipped)}[/dim]")
418
+
419
+ formatter.console.print(
420
+ f"\n{summary['total']} projects: "
421
+ f"[green]{summary['success']} OK[/green], "
422
+ f"[red]{summary['failed']} failed[/red]"
423
+ + (f", {summary.get('skipped', 0)} skipped" if summary.get("skipped") else "")
424
+ )
425
+
426
+
427
+ @sync_app.command("pull")
428
+ def sync_pull(
429
+ ctx: typer.Context,
430
+ project: str | None = typer.Option(
431
+ None,
432
+ "--project",
433
+ help="Project alias to pull configurations from",
434
+ ),
435
+ all_projects: bool = typer.Option(
436
+ False,
437
+ "--all-projects",
438
+ help="Pull all configured projects in parallel",
439
+ ),
440
+ directory: Path = typer.Option(
441
+ Path("."),
442
+ "--directory",
443
+ "-d",
444
+ help="Project root directory (must contain .keboola/)",
445
+ ),
446
+ force: bool = typer.Option(
447
+ False,
448
+ "--force",
449
+ help=(
450
+ "Force re-pull. Locally-modified configs whose remote is unchanged "
451
+ "are PRESERVED (kept as pending changes for `sync push`); a true "
452
+ "merge conflict (local AND remote both changed since the last pull) "
453
+ "aborts the pull so you can resolve it."
454
+ ),
455
+ ),
456
+ dry_run: bool = typer.Option(
457
+ False,
458
+ "--dry-run",
459
+ help="Show what would be pulled without writing any files",
460
+ ),
461
+ job_limit: int = typer.Option(
462
+ 5,
463
+ "--job-limit",
464
+ help="Max recent jobs to pull per configuration (default 5)",
465
+ ),
466
+ no_storage: bool = typer.Option(
467
+ False,
468
+ "--no-storage",
469
+ help="Skip downloading storage bucket/table metadata",
470
+ ),
471
+ no_jobs: bool = typer.Option(
472
+ False,
473
+ "--no-jobs",
474
+ help="Skip downloading per-config job history",
475
+ ),
476
+ with_samples: bool = typer.Option(
477
+ False,
478
+ "--with-samples",
479
+ help="Download table data samples (CSV previews)",
480
+ ),
481
+ sample_limit: int = typer.Option(
482
+ 100,
483
+ "--sample-limit",
484
+ help="Max rows per table sample (default 100)",
485
+ ),
486
+ max_samples: int = typer.Option(
487
+ 50,
488
+ "--max-samples",
489
+ help="Max number of tables to sample (default 50)",
490
+ ),
491
+ branch: int | None = typer.Option(
492
+ None,
493
+ "--branch",
494
+ help=(
495
+ "Dev branch ID. Overrides the manifest / 'branch use' active "
496
+ "branch for this single invocation. Requires exactly one --project."
497
+ ),
498
+ ),
499
+ ) -> None:
500
+ """Download configurations from a Keboola project to local files.
501
+
502
+ Use --project for a single project or --all-projects for all configured
503
+ projects in parallel (each in its own subdirectory).
504
+ """
505
+ formatter = get_formatter(ctx)
506
+ service = get_service(ctx, "sync_service")
507
+
508
+ if all_projects and project:
509
+ formatter.error(
510
+ message="Cannot use --project with --all-projects",
511
+ error_code=ErrorCode.USAGE_ERROR,
512
+ )
513
+ raise typer.Exit(code=2)
514
+ if not all_projects and not project:
515
+ formatter.error(
516
+ message="Specify --project ALIAS or --all-projects",
517
+ error_code=ErrorCode.USAGE_ERROR,
518
+ )
519
+ raise typer.Exit(code=2)
520
+ if branch is not None and all_projects:
521
+ formatter.error(
522
+ message="--branch requires --project (branch id is per-project)",
523
+ error_code=ErrorCode.USAGE_ERROR,
524
+ )
525
+ raise typer.Exit(code=2)
526
+
527
+ if all_projects:
528
+ base_dir = _safe_resolve_dir(directory)
529
+ try:
530
+ data = service.pull_all(
531
+ base_dir,
532
+ force=force,
533
+ dry_run=dry_run,
534
+ job_limit=job_limit,
535
+ no_storage=no_storage,
536
+ no_jobs=no_jobs,
537
+ with_samples=with_samples,
538
+ sample_limit=sample_limit,
539
+ max_samples=max_samples,
540
+ )
541
+ except ConfigError as exc:
542
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
543
+ raise typer.Exit(code=5) from None
544
+
545
+ if formatter.json_mode:
546
+ formatter.output(data)
547
+ else:
548
+ _format_all_results(formatter, data, _format_pull_result, _pull_one_liner)
549
+ return
550
+
551
+ project_root = _resolve_project_root(directory, project)
552
+
553
+ # Auto-init if no manifest exists (same as --all-projects behavior)
554
+ manifest_path = project_root / KEBOOLA_DIR / MANIFEST_FILE
555
+ if not manifest_path.exists():
556
+ try:
557
+ service.init_sync(project, project_root)
558
+ except Exception as exc:
559
+ formatter.error(message=str(exc), error_code=ErrorCode.INIT_ERROR)
560
+ raise typer.Exit(code=1) from None
561
+
562
+ try:
563
+ result = service.pull(
564
+ alias=project,
565
+ project_root=project_root,
566
+ force=force,
567
+ dry_run=dry_run,
568
+ job_limit=job_limit,
569
+ no_storage=no_storage,
570
+ no_jobs=no_jobs,
571
+ with_samples=with_samples,
572
+ sample_limit=sample_limit,
573
+ max_samples=max_samples,
574
+ branch_override=branch,
575
+ )
576
+ except SyncConflictError as exc:
577
+ if not formatter.json_mode:
578
+ _format_conflict_list(formatter, exc.conflicts)
579
+ formatter.error(
580
+ message=exc.message,
581
+ error_code=exc.error_code,
582
+ project=project or "",
583
+ details={"conflicts": exc.conflicts},
584
+ )
585
+ raise typer.Exit(code=1) from None
586
+ except FileNotFoundError as exc:
587
+ formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
588
+ raise typer.Exit(code=1) from None
589
+ except ConfigError as exc:
590
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
591
+ raise typer.Exit(code=5) from None
592
+ except KeboolaApiError as exc:
593
+ formatter.error(
594
+ message=exc.message,
595
+ error_code=exc.error_code,
596
+ retryable=exc.retryable,
597
+ )
598
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
599
+
600
+ if formatter.json_mode:
601
+ formatter.output(result)
602
+ else:
603
+ _format_pull_result(formatter, result)
604
+
605
+
606
+ @sync_app.command("status")
607
+ def sync_status(
608
+ ctx: typer.Context,
609
+ directory: Path = typer.Option(
610
+ Path("."),
611
+ "--directory",
612
+ "-d",
613
+ help="Project root directory (must contain .keboola/)",
614
+ ),
615
+ ) -> None:
616
+ """Show which local configurations have been modified, added, or deleted.
617
+
618
+ Compares the local filesystem state against the manifest to detect
619
+ changes since the last pull.
620
+ """
621
+ formatter = get_formatter(ctx)
622
+ service = get_service(ctx, "sync_service")
623
+ project_root = directory.resolve()
624
+
625
+ try:
626
+ result = service.status(project_root=project_root)
627
+ except FileNotFoundError as exc:
628
+ formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
629
+ raise typer.Exit(code=1) from None
630
+
631
+ if formatter.json_mode:
632
+ formatter.output(result)
633
+ else:
634
+ modified = result["modified"]
635
+ added = result["added"]
636
+ deleted = result["deleted"]
637
+ unchanged = result["unchanged"]
638
+ secret_warnings = result.get("plaintext_secret_warnings", [])
639
+
640
+ if not modified and not added and not deleted:
641
+ formatter.console.print(
642
+ f"[green]No changes detected.[/green] ({unchanged} configurations tracked)"
643
+ )
644
+ else:
645
+ if modified:
646
+ formatter.console.print(f"\n[yellow]Modified ({len(modified)}):[/yellow]")
647
+ for m in modified:
648
+ formatter.console.print(f" M {m['path']}")
649
+
650
+ if added:
651
+ formatter.console.print(f"\n[green]Added ({len(added)}):[/green]")
652
+ for a in added:
653
+ formatter.console.print(f" A {a['path']}")
654
+
655
+ if deleted:
656
+ formatter.console.print(f"\n[red]Deleted ({len(deleted)}):[/red]")
657
+ for d in deleted:
658
+ formatter.console.print(f" D {d['path']}")
659
+
660
+ formatter.console.print(
661
+ f"\n{len(modified)} modified, {len(added)} added, "
662
+ f"{len(deleted)} deleted, {unchanged} unchanged"
663
+ )
664
+
665
+ # Plaintext secret warning -- independent of local change state (issue #378).
666
+ if secret_warnings:
667
+ formatter.console.print(
668
+ f"\n[bold red]PLAINTEXT SECRETS in {len(secret_warnings)} synced "
669
+ f"config(s)[/bold red] (issue #378) -- in sync with the remote but NOT encrypted:"
670
+ )
671
+ for w in secret_warnings:
672
+ keys = ", ".join(w["secret_keys"])
673
+ formatter.console.print(f" [red]![/red] {w['path']} ({keys})")
674
+ formatter.console.print(
675
+ "[dim] Re-push on kbagent >=0.54.0 to encrypt, then ROTATE the credential "
676
+ "-- config version history keeps the old plaintext.[/dim]"
677
+ )
678
+
679
+
680
+ @sync_app.command("diff")
681
+ def sync_diff(
682
+ ctx: typer.Context,
683
+ project: str | None = typer.Option(
684
+ None,
685
+ "--project",
686
+ help="Project alias to diff against",
687
+ ),
688
+ all_projects: bool = typer.Option(
689
+ False,
690
+ "--all-projects",
691
+ help="Diff all configured projects in parallel",
692
+ ),
693
+ directory: Path = typer.Option(
694
+ Path("."),
695
+ "--directory",
696
+ "-d",
697
+ help="Project root directory (must contain .keboola/)",
698
+ ),
699
+ branch: int | None = typer.Option(
700
+ None,
701
+ "--branch",
702
+ help=(
703
+ "Dev branch ID. Overrides the manifest / 'branch use' active "
704
+ "branch for this single invocation. Requires exactly one --project."
705
+ ),
706
+ ),
707
+ ) -> None:
708
+ """Show detailed diff between local and remote configurations.
709
+
710
+ Use --project for a single project or --all-projects for all configured
711
+ projects in parallel.
712
+ """
713
+ formatter = get_formatter(ctx)
714
+ service = get_service(ctx, "sync_service")
715
+
716
+ if all_projects and project:
717
+ formatter.error(
718
+ message="Cannot use --project with --all-projects",
719
+ error_code=ErrorCode.USAGE_ERROR,
720
+ )
721
+ raise typer.Exit(code=2)
722
+ if not all_projects and not project:
723
+ formatter.error(
724
+ message="Specify --project ALIAS or --all-projects",
725
+ error_code=ErrorCode.USAGE_ERROR,
726
+ )
727
+ raise typer.Exit(code=2)
728
+ if branch is not None and all_projects:
729
+ formatter.error(
730
+ message="--branch requires --project (branch id is per-project)",
731
+ error_code=ErrorCode.USAGE_ERROR,
732
+ )
733
+ raise typer.Exit(code=2)
734
+
735
+ if all_projects:
736
+ base_dir = _safe_resolve_dir(directory)
737
+ try:
738
+ data = service.diff_all(base_dir)
739
+ except ConfigError as exc:
740
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
741
+ raise typer.Exit(code=5) from None
742
+
743
+ if formatter.json_mode:
744
+ formatter.output(data)
745
+ else:
746
+ _format_all_results(formatter, data, _format_diff_result, _diff_one_liner)
747
+ return
748
+
749
+ project_root = _resolve_project_root(directory, project)
750
+
751
+ try:
752
+ result = service.diff(alias=project, project_root=project_root, branch_override=branch)
753
+ except FileNotFoundError as exc:
754
+ formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
755
+ raise typer.Exit(code=1) from None
756
+ except ConfigError as exc:
757
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
758
+ raise typer.Exit(code=5) from None
759
+ except KeboolaApiError as exc:
760
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
761
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
762
+
763
+ if formatter.json_mode:
764
+ formatter.output(result)
765
+ else:
766
+ changes = result["changes"]
767
+ summary = result["summary"]
768
+ remote_only = result.get("remote_only", [])
769
+
770
+ if not changes and not remote_only:
771
+ formatter.console.print(
772
+ "[green]No differences found.[/green] Local and remote are in sync."
773
+ )
774
+ return
775
+
776
+ # Categorize changes by direction
777
+ prefix_map = {
778
+ "added": "[green]+ ",
779
+ "modified": "[yellow]~ ",
780
+ "remote_modified": "[cyan]~ ",
781
+ "conflict": "[red]! ",
782
+ "deleted": "[red]- ",
783
+ }
784
+ suffix_map = {
785
+ "added": "[/green]",
786
+ "modified": "[/yellow]",
787
+ "remote_modified": "[/cyan]",
788
+ "conflict": "[/red]",
789
+ "deleted": "[/red]",
790
+ }
791
+ label_map = {
792
+ "added": "ADDED",
793
+ "modified": "MODIFIED",
794
+ "remote_modified": "REMOTE MODIFIED",
795
+ "conflict": "CONFLICT",
796
+ "deleted": "DELETED",
797
+ }
798
+
799
+ local_changes = [c for c in changes if c["change_type"] in ("added", "modified", "deleted")]
800
+ remote_changes = [c for c in changes if c["change_type"] == "remote_modified"]
801
+ conflict_changes = [c for c in changes if c["change_type"] == "conflict"]
802
+
803
+ # Local changes (what push would do)
804
+ if local_changes:
805
+ formatter.console.print("[bold]Local changes (push would apply):[/bold]")
806
+ for change in local_changes:
807
+ ct = change["change_type"]
808
+ label = _change_label(change)
809
+ formatter.console.print(
810
+ f" {prefix_map[ct]}{label_map[ct]} {label}{suffix_map[ct]}"
811
+ )
812
+ for detail in change.get("details", []):
813
+ formatter.console.print(f" {detail}")
814
+ formatter.console.print(
815
+ f"\n{summary['added']} to create, {summary['modified']} to update, "
816
+ f"{summary['deleted']} to delete"
817
+ )
818
+
819
+ # Remote changes (need pull)
820
+ if remote_changes:
821
+ if local_changes:
822
+ formatter.console.print()
823
+ formatter.console.print("[bold]Remote changes (run 'sync pull' to fetch):[/bold]")
824
+ for change in remote_changes:
825
+ label = _change_label(change)
826
+ formatter.console.print(f" [cyan]~ REMOTE MODIFIED {label}[/cyan]")
827
+ for detail in change.get("details", []):
828
+ formatter.console.print(f" {detail}")
829
+
830
+ # Conflicts (both sides changed)
831
+ if conflict_changes:
832
+ if local_changes or remote_changes:
833
+ formatter.console.print()
834
+ formatter.console.print(
835
+ "[bold red]Conflicts (both local and remote changed):[/bold red]"
836
+ )
837
+ for change in conflict_changes:
838
+ label = _change_label(change)
839
+ formatter.console.print(f" [red]! CONFLICT {label}[/red]")
840
+ for detail in change.get("details", []):
841
+ formatter.console.print(f" {detail}")
842
+
843
+ # Remote-only configs (new on server, not yet pulled)
844
+ if remote_only:
845
+ if changes:
846
+ formatter.console.print()
847
+ formatter.console.print(
848
+ f"[bold]Remote only ({len(remote_only)} new, run 'sync pull' to fetch):[/bold]"
849
+ )
850
+ for cfg in remote_only:
851
+ name = cfg.get("config_name", cfg.get("config_id", ""))
852
+ formatter.console.print(f" [cyan]+ NEW {cfg['component_id']}/{name}[/cyan]")
853
+
854
+
855
+ @sync_app.command("push")
856
+ def sync_push(
857
+ ctx: typer.Context,
858
+ project: str | None = typer.Option(
859
+ None,
860
+ "--project",
861
+ help="Project alias to push changes to",
862
+ ),
863
+ all_projects: bool = typer.Option(
864
+ False,
865
+ "--all-projects",
866
+ help="Push all configured projects in parallel",
867
+ ),
868
+ directory: Path = typer.Option(
869
+ Path("."),
870
+ "--directory",
871
+ "-d",
872
+ help="Project root directory (must contain .keboola/)",
873
+ ),
874
+ dry_run: bool = typer.Option(
875
+ False,
876
+ "--dry-run",
877
+ help="Show what would be pushed without actually pushing",
878
+ ),
879
+ force: bool = typer.Option(
880
+ False,
881
+ "--force",
882
+ help="Allow deletion of remote configs that were removed locally",
883
+ ),
884
+ allow_plaintext: bool = typer.Option(
885
+ False,
886
+ "--allow-plaintext-on-encrypt-failure",
887
+ help="Allow push even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
888
+ ),
889
+ branch: int | None = typer.Option(
890
+ None,
891
+ "--branch",
892
+ help=(
893
+ "Dev branch ID. Overrides the manifest / 'branch use' active "
894
+ "branch for this single invocation. Requires exactly one --project. "
895
+ "When no '<branch_name>/' subtree exists on disk, the default tree "
896
+ "(main/) is promoted to this branch."
897
+ ),
898
+ ),
899
+ no_name_drift_warnings: bool = typer.Option(
900
+ False,
901
+ "--no-name-drift-warnings",
902
+ help=(
903
+ "Suppress the cosmetic name_drift_warnings array in the result "
904
+ "envelope (the underlying detection still runs)."
905
+ ),
906
+ ),
907
+ ) -> None:
908
+ """Push local configuration changes to a Keboola project.
909
+
910
+ Use --project for a single project or --all-projects for all configured
911
+ projects in parallel.
912
+ """
913
+ formatter = get_formatter(ctx)
914
+ service = get_service(ctx, "sync_service")
915
+
916
+ if all_projects and project:
917
+ formatter.error(
918
+ message="Cannot use --project with --all-projects",
919
+ error_code=ErrorCode.USAGE_ERROR,
920
+ )
921
+ raise typer.Exit(code=2)
922
+ if not all_projects and not project:
923
+ formatter.error(
924
+ message="Specify --project ALIAS or --all-projects",
925
+ error_code=ErrorCode.USAGE_ERROR,
926
+ )
927
+ raise typer.Exit(code=2)
928
+ if branch is not None and all_projects:
929
+ formatter.error(
930
+ message="--branch requires --project (branch id is per-project)",
931
+ error_code=ErrorCode.USAGE_ERROR,
932
+ )
933
+ raise typer.Exit(code=2)
934
+
935
+ if all_projects:
936
+ base_dir = _safe_resolve_dir(directory)
937
+ try:
938
+ data = service.push_all(
939
+ base_dir,
940
+ dry_run=dry_run,
941
+ force=force,
942
+ allow_plaintext_fallback=allow_plaintext,
943
+ )
944
+ except ConfigError as exc:
945
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
946
+ raise typer.Exit(code=5) from None
947
+
948
+ if formatter.json_mode:
949
+ formatter.output(data)
950
+ else:
951
+ _format_all_results(formatter, data, _format_push_result, _push_one_liner)
952
+ return
953
+
954
+ project_root = _resolve_project_root(directory, project)
955
+
956
+ try:
957
+ result = service.push(
958
+ alias=project,
959
+ project_root=project_root,
960
+ dry_run=dry_run,
961
+ force=force,
962
+ allow_plaintext_fallback=allow_plaintext,
963
+ branch_override=branch,
964
+ no_name_drift_warnings=no_name_drift_warnings,
965
+ )
966
+ except FileNotFoundError as exc:
967
+ formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
968
+ raise typer.Exit(code=1) from None
969
+ except ConfigError as exc:
970
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
971
+ raise typer.Exit(code=5) from None
972
+ except KeboolaApiError as exc:
973
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
974
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
975
+
976
+ if formatter.json_mode:
977
+ formatter.output(result)
978
+ else:
979
+ status = result.get("status", "")
980
+
981
+ if status == "no_changes":
982
+ formatter.console.print("[green]No changes to push.[/green]")
983
+ skipped_reason = result.get("skipped_reason")
984
+ if skipped_reason:
985
+ formatter.console.print(f" [yellow]{skipped_reason}[/yellow]")
986
+ return
987
+
988
+ if status == "dry_run":
989
+ formatter.console.print("[yellow]Dry run -- no changes applied:[/yellow]")
990
+ for change in result.get("changes", []):
991
+ label = _change_label(change)
992
+ formatter.console.print(f" {change['change_type'].upper()} {label}")
993
+ summary = result["summary"]
994
+ formatter.console.print(
995
+ f"\nWould create {summary['added']}, update {summary['modified']}, "
996
+ f"delete {summary['deleted']}"
997
+ )
998
+ return
999
+
1000
+ formatter.success(
1001
+ f"Pushed: {result['created']} created, "
1002
+ f"{result['updated']} updated, "
1003
+ f"{result['deleted']} deleted"
1004
+ )
1005
+ for change in result.get("pushed_details", []):
1006
+ label = _change_label(change)
1007
+ action = change["change_type"].upper()
1008
+ formatter.console.print(f" {action} {label}")
1009
+ for err in result.get("errors", []):
1010
+ formatter.warning(
1011
+ f" Error: {err['change_type']} {err['component_id']}/{err['config_id']}: "
1012
+ f"{err['message']}"
1013
+ )
1014
+
1015
+
1016
+ @sync_app.command("clone")
1017
+ def sync_clone(
1018
+ ctx: typer.Context,
1019
+ source: Path = typer.Option(
1020
+ ...,
1021
+ "--source",
1022
+ help="Reference synced project directory (must contain .keboola/manifest.json)",
1023
+ ),
1024
+ target: str = typer.Option(
1025
+ ...,
1026
+ "--target",
1027
+ help="Target project alias to clone INTO (a fresh/empty project on first clone)",
1028
+ ),
1029
+ target_dir: Path = typer.Option(
1030
+ ...,
1031
+ "--target-dir",
1032
+ help="Directory to materialise the clone into (must not exist on first clone)",
1033
+ ),
1034
+ bucket_map: Path | None = typer.Option(
1035
+ None,
1036
+ "--bucket-map",
1037
+ help="JSON/YAML file mapping {old_bucket_id: new_bucket_id} for input/output rewrites",
1038
+ ),
1039
+ variable_values: Path | None = typer.Option(
1040
+ None,
1041
+ "--variable-values",
1042
+ help="JSON/YAML file mapping {variable_name: value} to override keboola.variables rows",
1043
+ ),
1044
+ instance_rename: Path | None = typer.Option(
1045
+ None,
1046
+ "--instance-rename",
1047
+ help="JSON/YAML file mapping {old_path_prefix: new_path_prefix} to rename config dirs",
1048
+ ),
1049
+ dry_run: bool = typer.Option(
1050
+ False,
1051
+ "--dry-run",
1052
+ help="Apply overrides and show the would-be diff without pushing",
1053
+ ),
1054
+ branch: int | None = typer.Option(
1055
+ None,
1056
+ "--branch",
1057
+ help="Target dev branch id (defaults to the target project's production branch)",
1058
+ ),
1059
+ ) -> None:
1060
+ """Clone a reference project into a fresh target, parameterised by overrides.
1061
+
1062
+ Copies the reference tree, applies declarative overrides (bucket_map,
1063
+ variable_values, instance_rename), and pushes so every config is CREATEd
1064
+ fresh -- keboola.flow task configIds and transformation variable links are
1065
+ remapped reference->ULID automatically. Idempotent: re-running with an
1066
+ existing --target-dir just pushes and reports no_changes.
1067
+ """
1068
+ formatter = get_formatter(ctx)
1069
+ service = get_service(ctx, "sync_service")
1070
+
1071
+ try:
1072
+ overrides: dict[str, Any] = {}
1073
+ if bucket_map is not None:
1074
+ overrides["bucket_map"] = _load_override_file(bucket_map)
1075
+ if variable_values is not None:
1076
+ overrides["variable_values"] = _load_override_file(variable_values)
1077
+ if instance_rename is not None:
1078
+ overrides["instance_rename"] = _load_override_file(instance_rename)
1079
+
1080
+ result = service.clone_project(
1081
+ source=source,
1082
+ target_alias=target,
1083
+ target_dir=target_dir,
1084
+ overrides=overrides,
1085
+ dry_run=dry_run,
1086
+ branch_override=branch,
1087
+ )
1088
+ except FileNotFoundError as exc:
1089
+ formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
1090
+ raise typer.Exit(code=1) from None
1091
+ except FileExistsError as exc:
1092
+ formatter.error(message=str(exc), error_code=ErrorCode.CONFIG_ERROR)
1093
+ raise typer.Exit(code=5) from None
1094
+ except ConfigError as exc:
1095
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1096
+ raise typer.Exit(code=5) from None
1097
+ except KeboolaApiError as exc:
1098
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1099
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1100
+
1101
+ if formatter.json_mode:
1102
+ formatter.output(result)
1103
+ else:
1104
+ _format_clone_result(formatter, result)
1105
+
1106
+
1107
+ def _format_clone_result(formatter: Any, result: dict[str, Any]) -> None:
1108
+ """Human-mode rendering for ``sync clone``."""
1109
+ status = result.get("status", "")
1110
+ overrides = (
1111
+ f"buckets={result.get('bucket_rewrites', 0)}, "
1112
+ f"variables={result.get('variable_overrides', 0)}, "
1113
+ f"renamed={result.get('renamed_instances', 0)}"
1114
+ )
1115
+ if status == "dry_run":
1116
+ summary = result.get("summary", {})
1117
+ formatter.console.print("[yellow]Dry run -- nothing pushed.[/yellow]")
1118
+ formatter.console.print(f" Overrides applied: {overrides}")
1119
+ formatter.console.print(
1120
+ f" Would create {summary.get('added', 0)} config(s) in "
1121
+ f"[cyan]{result.get('target_alias')}[/cyan]."
1122
+ )
1123
+ return
1124
+ if status == "no_changes":
1125
+ formatter.console.print(
1126
+ f"[green]Already cloned[/green] -- no changes to push into "
1127
+ f"[cyan]{result.get('target_alias')}[/cyan]."
1128
+ )
1129
+ return
1130
+ formatter.success(
1131
+ f"Cloned into {result.get('target_alias')}: {result.get('created', 0)} created "
1132
+ f"({overrides}, flow_task_remaps={result.get('flow_task_remaps', 0)})"
1133
+ )
1134
+ for err in result.get("errors", []):
1135
+ formatter.warning(
1136
+ f" Error: {err.get('change_type')} "
1137
+ f"{err.get('component_id')}/{err.get('config_id')}: {err.get('message')}"
1138
+ )
1139
+
1140
+
1141
+ @sync_app.command("branch-link")
1142
+ def sync_branch_link(
1143
+ ctx: typer.Context,
1144
+ project: str = typer.Option(..., "--project", help="Project alias"),
1145
+ directory: Path = typer.Option(Path("."), "--directory", "-d", help="Project root directory"),
1146
+ branch_id: int | None = typer.Option(
1147
+ None, "--branch-id", help="Link to existing Keboola branch by ID"
1148
+ ),
1149
+ branch_name: str | None = typer.Option(
1150
+ None, "--branch-name", help="Create/find branch with this name"
1151
+ ),
1152
+ ) -> None:
1153
+ """Link the current git branch to a Keboola development branch.
1154
+
1155
+ Creates a new Keboola dev branch if one doesn't exist with the same name
1156
+ as the current git branch.
1157
+ """
1158
+ formatter = get_formatter(ctx)
1159
+ service = get_service(ctx, "sync_service")
1160
+ project_root = directory.resolve()
1161
+
1162
+ try:
1163
+ result = service.branch_link(
1164
+ alias=project,
1165
+ project_root=project_root,
1166
+ branch_id=branch_id,
1167
+ branch_name=branch_name,
1168
+ )
1169
+ except FileNotFoundError as exc:
1170
+ formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
1171
+ raise typer.Exit(code=1) from None
1172
+ except ConfigError as exc:
1173
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1174
+ raise typer.Exit(code=5) from None
1175
+ except KeboolaApiError as exc:
1176
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1177
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1178
+
1179
+ if formatter.json_mode:
1180
+ formatter.output(result)
1181
+ else:
1182
+ status = result["status"]
1183
+ if status == "already_linked":
1184
+ formatter.console.print(
1185
+ f"Already linked: {result['git_branch']} -> "
1186
+ f"Keboola branch {result['keboola_branch_id']} ({result['keboola_branch_name']})"
1187
+ )
1188
+ else:
1189
+ formatter.success(
1190
+ f"Linked {result['git_branch']} -> "
1191
+ f"Keboola branch {result['keboola_branch_id']} ({result['keboola_branch_name']})"
1192
+ )
1193
+
1194
+
1195
+ @sync_app.command("branch-unlink")
1196
+ def sync_branch_unlink(
1197
+ ctx: typer.Context,
1198
+ directory: Path = typer.Option(Path("."), "--directory", "-d", help="Project root directory"),
1199
+ ) -> None:
1200
+ """Remove the branch mapping for the current git branch.
1201
+
1202
+ Does NOT delete the Keboola branch itself.
1203
+ """
1204
+ formatter = get_formatter(ctx)
1205
+ service = get_service(ctx, "sync_service")
1206
+ project_root = directory.resolve()
1207
+
1208
+ try:
1209
+ result = service.branch_unlink(project_root=project_root)
1210
+ except FileNotFoundError as exc:
1211
+ formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
1212
+ raise typer.Exit(code=1) from None
1213
+ except ConfigError as exc:
1214
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1215
+ raise typer.Exit(code=5) from None
1216
+
1217
+ if formatter.json_mode:
1218
+ formatter.output(result)
1219
+ else:
1220
+ if result["status"] == "not_linked":
1221
+ formatter.console.print(f"Branch '{result['git_branch']}' is not linked.")
1222
+ else:
1223
+ formatter.success(
1224
+ f"Unlinked {result['git_branch']} from Keboola branch {result['keboola_branch_id']}"
1225
+ )
1226
+
1227
+
1228
+ @sync_app.command("branch-status")
1229
+ def sync_branch_status(
1230
+ ctx: typer.Context,
1231
+ directory: Path = typer.Option(Path("."), "--directory", "-d", help="Project root directory"),
1232
+ ) -> None:
1233
+ """Show the branch mapping status for the current git branch."""
1234
+ formatter = get_formatter(ctx)
1235
+ service = get_service(ctx, "sync_service")
1236
+ project_root = directory.resolve()
1237
+
1238
+ try:
1239
+ result = service.branch_status(project_root=project_root)
1240
+ except FileNotFoundError as exc:
1241
+ formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
1242
+ raise typer.Exit(code=1) from None
1243
+ except ConfigError as exc:
1244
+ # Corrupted .keboola/branch-mapping.json -- surface as clean
1245
+ # exit-5 envelope so the user sees the descriptive message
1246
+ # ("Failed to parse ...: Invalid branch ID ...") instead of a
1247
+ # Python traceback. Mirrors the handler other sync commands
1248
+ # already have (issue #269 sec-20 follow-up).
1249
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1250
+ raise typer.Exit(code=5) from None
1251
+
1252
+ if formatter.json_mode:
1253
+ formatter.output(result)
1254
+ else:
1255
+ if not result.get("git_branching"):
1256
+ formatter.console.print("Git-branching mode is not enabled.")
1257
+ return
1258
+
1259
+ git_branch = result.get("git_branch", "unknown")
1260
+ if result.get("linked"):
1261
+ if result.get("is_production"):
1262
+ formatter.console.print(
1263
+ f"Branch: {git_branch}\nKeboola: production\nStatus: [green]Linked[/green]"
1264
+ )
1265
+ else:
1266
+ formatter.console.print(
1267
+ f"Branch: {git_branch}\n"
1268
+ f"Keboola: {result['keboola_branch_id']} ({result['keboola_branch_name']})\n"
1269
+ f"Status: [green]Linked[/green]"
1270
+ )
1271
+ else:
1272
+ formatter.console.print(
1273
+ f"Branch: {git_branch}\n"
1274
+ f"Keboola: (none)\n"
1275
+ f"Status: [red]Not linked[/red]\n\n"
1276
+ f"Run 'kbagent sync branch-link --project ALIAS' to link."
1277
+ )