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,2442 @@
1
+ """Configuration commands - list, detail, search, update, delete, and scaffold.
2
+
3
+ Thin CLI layer: parses arguments, calls ConfigService, formats output.
4
+ No business logic belongs here.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import typer
14
+ from rich.markup import escape
15
+ from rich.syntax import Syntax
16
+
17
+ from ..config_store import ConfigStore
18
+ from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME, VALID_COMPONENT_TYPES
19
+ from ..errors import ConfigError, ErrorCode, KeboolaApiError
20
+ from ..output import format_config_detail, format_configs_table, format_search_results
21
+ from ._helpers import (
22
+ check_cli_permission,
23
+ emit_project_warnings,
24
+ get_formatter,
25
+ get_service,
26
+ map_error_to_exit_code,
27
+ resolve_branch,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _detect_branch_prefix(output_dir: Path) -> str | None:
34
+ """Detect kbc project branch path from .keboola/manifest.json.
35
+
36
+ When output_dir is inside a kbc project (has .keboola/manifest.json),
37
+ returns the default branch path (e.g. "main") so scaffold files
38
+ land in the correct location (main/extractor/... instead of extractor/...).
39
+
40
+ Returns None if not a kbc project or manifest is unreadable.
41
+ """
42
+ manifest_path = output_dir / KEBOOLA_DIR_NAME / MANIFEST_FILENAME
43
+ if not manifest_path.is_file():
44
+ return None
45
+
46
+ try:
47
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
48
+ branches = manifest.get("branches", [])
49
+ if branches:
50
+ # Use the first (default) branch path
51
+ branch_path = branches[0].get("path", "")
52
+ if branch_path:
53
+ logger.debug("Detected kbc branch prefix: %s", branch_path)
54
+ return branch_path
55
+ except (json.JSONDecodeError, OSError) as exc:
56
+ logger.debug("Could not read manifest: %s", exc)
57
+
58
+ return None
59
+
60
+
61
+ config_app = typer.Typer(help="Browse and inspect configurations")
62
+
63
+
64
+ @config_app.callback(invoke_without_command=True)
65
+ def _config_permission_check(ctx: typer.Context) -> None:
66
+ check_cli_permission(ctx, "config")
67
+
68
+
69
+ @config_app.command("list", rich_help_panel="Browse")
70
+ def config_list(
71
+ ctx: typer.Context,
72
+ project: list[str] | None = typer.Option(
73
+ None,
74
+ "--project",
75
+ help="Project alias to query (can be repeated for multiple projects)",
76
+ ),
77
+ component_type: str | None = typer.Option(
78
+ None,
79
+ "--component-type",
80
+ help="Filter by component type: extractor, writer, transformation, application",
81
+ ),
82
+ component_id: str | None = typer.Option(
83
+ None,
84
+ "--component-id",
85
+ help="Filter by specific component ID (e.g. keboola.ex-db-snowflake)",
86
+ ),
87
+ branch: int | None = typer.Option(
88
+ None,
89
+ "--branch",
90
+ help="List configs from a specific dev branch ID (defaults to active branch)",
91
+ ),
92
+ include_rows: bool = typer.Option(
93
+ False,
94
+ "--include-rows",
95
+ help=(
96
+ "Include full configuration + rows body per config (noticeably larger "
97
+ "payload). Without this flag the response is summary-level only "
98
+ "(name/description/component/last_modified/folder)."
99
+ ),
100
+ ),
101
+ ) -> None:
102
+ """List configurations from connected projects.
103
+
104
+ If a dev branch is active (via 'branch use'), configs from that branch
105
+ are listed. Use --branch to override.
106
+ """
107
+ formatter = get_formatter(ctx)
108
+ service = get_service(ctx, "config_service")
109
+ config_store: ConfigStore = ctx.obj["config_store"]
110
+
111
+ # --branch requires --project (branch ID is per-project)
112
+ # For list with multiple projects, only validate if explicit --branch given
113
+ if branch is not None and (not project or len(project) != 1):
114
+ formatter.error(
115
+ message="--branch requires exactly one --project (branch ID is per-project)",
116
+ error_code=ErrorCode.INVALID_ARGUMENT,
117
+ )
118
+ raise typer.Exit(code=2)
119
+
120
+ # Resolve active branch (only for single-project queries)
121
+ effective_branch: int | None = branch
122
+ effective_project = project
123
+ if branch is None and project and len(project) == 1:
124
+ _, effective_branch = resolve_branch(config_store, formatter, project[0], None)
125
+
126
+ # Validate component_type if provided
127
+ if component_type and component_type not in VALID_COMPONENT_TYPES:
128
+ formatter.error(
129
+ message=f"Invalid component type '{component_type}'. "
130
+ f"Valid types: {', '.join(VALID_COMPONENT_TYPES)}",
131
+ error_code=ErrorCode.INVALID_ARGUMENT,
132
+ )
133
+ raise typer.Exit(code=2)
134
+
135
+ try:
136
+ result = service.list_configs(
137
+ aliases=effective_project,
138
+ component_type=component_type,
139
+ component_id=component_id,
140
+ branch_id=effective_branch,
141
+ include_rows=include_rows,
142
+ )
143
+ except ConfigError as exc:
144
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
145
+ raise typer.Exit(code=5) from None
146
+
147
+ # In JSON mode, include both configs and errors in the response
148
+ if formatter.json_mode:
149
+ formatter.output(result)
150
+ else:
151
+ # In human mode, show per-project errors as warnings and configs as table
152
+ format_configs_table(formatter.console, result)
153
+ emit_project_warnings(formatter, result)
154
+
155
+
156
+ @config_app.command("detail", rich_help_panel="Browse")
157
+ def config_detail(
158
+ ctx: typer.Context,
159
+ project: list[str] | None = typer.Option(
160
+ None,
161
+ "--project",
162
+ help=(
163
+ "Project alias to query. Repeat for multiple projects (only valid in "
164
+ "bulk mode, i.e. when --config-id is omitted)."
165
+ ),
166
+ ),
167
+ component_id: str = typer.Option(..., "--component-id", help="Component ID"),
168
+ config_id: str | None = typer.Option(
169
+ None,
170
+ "--config-id",
171
+ help=(
172
+ "Configuration ID. When omitted, the command switches to BULK mode "
173
+ "and returns every configuration under --component-id as a JSON "
174
+ "array ({'configs': [...], 'errors': [...]})."
175
+ ),
176
+ ),
177
+ branch: int | None = typer.Option(
178
+ None,
179
+ "--branch",
180
+ help="Get detail from a specific dev branch ID (defaults to active branch)",
181
+ ),
182
+ with_state: bool = typer.Option(
183
+ False,
184
+ "--with-state",
185
+ help=(
186
+ "Attach the runtime state dict to each config under 'state'. "
187
+ "Single-config mode: state is read from the same detail response "
188
+ "(no extra HTTP call). Bulk mode: state is fetched inline via "
189
+ "include=state (no N+1). WARNING: --with-state output may contain "
190
+ "OAuth tokens, refresh tokens, and other credential-bearing runtime "
191
+ "data. Do not pipe into logs, scratch files, or shared workspaces "
192
+ "without redaction."
193
+ ),
194
+ ),
195
+ ) -> None:
196
+ """Show detailed information about one or many configurations.
197
+
198
+ \b
199
+ Two modes, switched by --config-id:
200
+ - Single-config mode (default when --config-id given): returns the
201
+ full configuration detail dict, shape identical to historical output
202
+ (callers depending on this shape are unaffected).
203
+ - Bulk mode (--config-id omitted): returns every config under
204
+ --component-id as {"configs": [...], "errors": [...]}. Works across
205
+ multiple projects when --project is repeated (per-project errors
206
+ surface in the errors list without aborting other projects).
207
+
208
+ If a dev branch is active (via 'branch use') the detail is fetched
209
+ from that branch. --branch overrides; bulk mode with multiple projects
210
+ rejects --branch (branch IDs are per-project).
211
+
212
+ \b
213
+ Examples:
214
+ # Single config (unchanged shape)
215
+ kbagent config detail --project prod --component-id keboola.ex-db-snowflake --config-id 101
216
+ # Bulk: every Snowflake writer in one project
217
+ kbagent --json config detail --project prod --component-id keboola.wr-db-snowflake
218
+ # Bulk: every Snowflake writer across many projects (with runtime state)
219
+ kbagent --json config detail --project prod --project stage --component-id keboola.wr-db-snowflake --with-state
220
+ """
221
+ formatter = get_formatter(ctx)
222
+ service = get_service(ctx, "config_service")
223
+ config_store: ConfigStore = ctx.obj["config_store"]
224
+
225
+ # --project is required (zero, one, or many)
226
+ if not project:
227
+ formatter.error(
228
+ message="--project is required (repeat for multiple projects in bulk mode).",
229
+ error_code=ErrorCode.INVALID_ARGUMENT,
230
+ )
231
+ raise typer.Exit(code=2)
232
+
233
+ # Typer ``...`` enforces presence but not non-emptiness; guard here so
234
+ # ``--component-id ""`` fails fast with a clear message instead of
235
+ # constructing a malformed /components//configs URL downstream.
236
+ if not component_id.strip():
237
+ formatter.error(
238
+ message="--component-id must not be empty.",
239
+ error_code=ErrorCode.INVALID_ARGUMENT,
240
+ )
241
+ raise typer.Exit(code=2)
242
+
243
+ # --config-id + multiple --project is ambiguous: a single config lives in
244
+ # exactly one project. Reject with a clear message instead of picking one.
245
+ if config_id is not None and len(project) > 1:
246
+ formatter.error(
247
+ message=(
248
+ "--config-id is only valid with exactly one --project. "
249
+ "Omit --config-id to fan out across multiple projects."
250
+ ),
251
+ error_code=ErrorCode.INVALID_ARGUMENT,
252
+ )
253
+ raise typer.Exit(code=2)
254
+
255
+ # --branch requires exactly one --project (branch IDs are per-project).
256
+ if branch is not None and len(project) != 1:
257
+ formatter.error(
258
+ message="--branch requires exactly one --project (branch ID is per-project).",
259
+ error_code=ErrorCode.INVALID_ARGUMENT,
260
+ )
261
+ raise typer.Exit(code=2)
262
+
263
+ # Resolve active branch when only one --project was passed
264
+ effective_branch: int | None = branch
265
+ if branch is None and len(project) == 1:
266
+ _, effective_branch = resolve_branch(config_store, formatter, project[0], None)
267
+
268
+ try:
269
+ if config_id is not None:
270
+ # Single-config mode: shape unchanged for backward compat,
271
+ # plus the opt-in sandbox annotation (since v0.42.1, issue #312):
272
+ # the service layer now owns the keboola.sandboxes
273
+ # configurationId->workspace.id resolution so HTTP and REST
274
+ # callers get the same enrichment, not only the CLI.
275
+ result = service.get_config_detail(
276
+ alias=project[0],
277
+ component_id=component_id,
278
+ config_id=config_id,
279
+ branch_id=effective_branch,
280
+ with_state=with_state,
281
+ include_sandbox_annotation=True,
282
+ )
283
+ else:
284
+ # Bulk mode: one call per project, filtered by component_id.
285
+ # Annotation flag stays off here -- bulk mode would N+1 the
286
+ # workspace listing endpoint (one extra round-trip per config),
287
+ # and the field that triggers the annotation in single-config
288
+ # mode (parameters.id) is rarely consumed in bulk anyway.
289
+ result = service.get_config_detail(
290
+ alias=project[0],
291
+ component_id=component_id,
292
+ config_id=None,
293
+ branch_id=effective_branch,
294
+ with_state=with_state,
295
+ aliases=project,
296
+ )
297
+ except ConfigError as exc:
298
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
299
+ raise typer.Exit(code=5) from None
300
+ except KeboolaApiError as exc:
301
+ exit_code = map_error_to_exit_code(exc)
302
+ formatter.error(
303
+ message=exc.message,
304
+ error_code=exc.error_code,
305
+ project=project[0],
306
+ retryable=exc.retryable,
307
+ )
308
+ raise typer.Exit(code=exit_code) from None
309
+
310
+ if config_id is not None:
311
+ # Single-config mode: emit unchanged shape
312
+ formatter.output(result, format_config_detail)
313
+ else:
314
+ # Bulk mode: emit {configs: [...], errors: [...]}
315
+ if formatter.json_mode:
316
+ formatter.output(result)
317
+ else:
318
+ _format_config_detail_bulk(formatter.console, result, component_id, with_state)
319
+ emit_project_warnings(formatter, result)
320
+
321
+
322
+ def _format_config_detail_bulk(
323
+ console: Any,
324
+ data: dict,
325
+ component_id: str,
326
+ with_state: bool,
327
+ ) -> None:
328
+ """Render bulk config detail results as a Rich table grouped by project.
329
+
330
+ Args:
331
+ console: Rich Console instance.
332
+ data: ``{"configs": [...], "errors": [...]}`` from the service layer.
333
+ component_id: The component ID the bulk query was scoped to.
334
+ with_state: Whether ``state`` was requested (adds a State column).
335
+ """
336
+ from rich.table import Table
337
+
338
+ # ``component_id`` is user-supplied and Rich interprets ``[tag]`` syntax.
339
+ # Escape before embedding in Rich-rendered strings so an ID like
340
+ # ``keboola.[red]evil[/red]`` cannot paint the terminal.
341
+ safe_component_id = escape(component_id)
342
+
343
+ configs = data.get("configs", [])
344
+ if not configs:
345
+ console.print(
346
+ f"[dim]No configurations found for [bold]{safe_component_id}[/bold] "
347
+ "in the selected project(s).[/dim]"
348
+ )
349
+ return
350
+
351
+ # Group by project_alias for readability
352
+ grouped: dict[str, list[dict]] = {}
353
+ order: list[str] = []
354
+ for cfg in configs:
355
+ alias = cfg.get("project_alias", "unknown")
356
+ if alias not in grouped:
357
+ order.append(alias)
358
+ grouped[alias] = []
359
+ grouped[alias].append(cfg)
360
+
361
+ for alias in order:
362
+ table = Table(title=f"{safe_component_id} configs -- {alias} ({len(grouped[alias])})")
363
+ table.add_column("Config ID", style="bold cyan", justify="right")
364
+ table.add_column("Name")
365
+ table.add_column("Rows", justify="right", style="dim")
366
+ table.add_column("Version", justify="right", style="dim")
367
+ table.add_column("Disabled", style="dim")
368
+ if with_state:
369
+ table.add_column("State Keys", style="dim")
370
+
371
+ for cfg in grouped[alias]:
372
+ row = [
373
+ str(cfg.get("config_id", "")),
374
+ str(cfg.get("name", "")),
375
+ str(len(cfg.get("rows", []) or [])),
376
+ str(cfg.get("version", "") or ""),
377
+ "yes" if cfg.get("isDisabled") else "",
378
+ ]
379
+ if with_state:
380
+ state = cfg.get("state") or {}
381
+ row.append(str(len(state) if isinstance(state, dict) else 0))
382
+ table.add_row(*row)
383
+
384
+ console.print(table)
385
+ console.print()
386
+
387
+
388
+ @config_app.command("search", rich_help_panel="Browse")
389
+ def config_search(
390
+ ctx: typer.Context,
391
+ query: str = typer.Option(..., "--query", "-q", help="Search string or regex pattern"),
392
+ project: list[str] | None = typer.Option(
393
+ None,
394
+ "--project",
395
+ help="Project alias to search (can be repeated for multiple projects)",
396
+ ),
397
+ component_type: str | None = typer.Option(
398
+ None,
399
+ "--component-type",
400
+ help="Filter by component type: extractor, writer, transformation, application",
401
+ ),
402
+ component_id: str | None = typer.Option(
403
+ None,
404
+ "--component-id",
405
+ help="Filter by specific component ID (e.g. keboola.ex-db-snowflake)",
406
+ ),
407
+ ignore_case: bool = typer.Option(
408
+ False,
409
+ "--ignore-case",
410
+ "-i",
411
+ help="Case-insensitive matching",
412
+ ),
413
+ use_regex: bool = typer.Option(
414
+ False,
415
+ "--regex",
416
+ "-r",
417
+ help="Interpret query as a regular expression",
418
+ ),
419
+ branch: int | None = typer.Option(
420
+ None,
421
+ "--branch",
422
+ help="Search configs in a specific dev branch ID (defaults to active branch)",
423
+ ),
424
+ ) -> None:
425
+ """Search through configuration bodies for a string or pattern.
426
+
427
+ Searches config names, descriptions, parameters, and row definitions.
428
+ Reports which configurations match and where in the JSON tree.
429
+
430
+ If a dev branch is active (via 'branch use'), configs from that branch
431
+ are searched. Use --branch to override.
432
+ """
433
+ formatter = get_formatter(ctx)
434
+ service = get_service(ctx, "config_service")
435
+ config_store: ConfigStore = ctx.obj["config_store"]
436
+
437
+ # --branch requires exactly one --project
438
+ if branch is not None and (not project or len(project) != 1):
439
+ formatter.error(
440
+ message="--branch requires exactly one --project (branch ID is per-project)",
441
+ error_code=ErrorCode.INVALID_ARGUMENT,
442
+ )
443
+ raise typer.Exit(code=2)
444
+
445
+ # Resolve active branch (only for single-project queries)
446
+ effective_branch: int | None = branch
447
+ if branch is None and project and len(project) == 1:
448
+ _, effective_branch = resolve_branch(config_store, formatter, project[0], None)
449
+
450
+ # Validate component_type
451
+ if component_type and component_type not in VALID_COMPONENT_TYPES:
452
+ formatter.error(
453
+ message=f"Invalid component type '{component_type}'. "
454
+ f"Valid types: {', '.join(VALID_COMPONENT_TYPES)}",
455
+ error_code=ErrorCode.INVALID_ARGUMENT,
456
+ )
457
+ raise typer.Exit(code=2)
458
+
459
+ # Validate regex if provided
460
+ if use_regex:
461
+ try:
462
+ re.compile(query)
463
+ except re.error as exc:
464
+ formatter.error(
465
+ message=f"Invalid regex pattern: {exc}",
466
+ error_code=ErrorCode.INVALID_ARGUMENT,
467
+ )
468
+ raise typer.Exit(code=2) from None
469
+
470
+ try:
471
+ result = service.search_configs(
472
+ query=query,
473
+ aliases=project,
474
+ component_type=component_type,
475
+ component_id=component_id,
476
+ ignore_case=ignore_case,
477
+ use_regex=use_regex,
478
+ branch_id=effective_branch,
479
+ )
480
+ except ConfigError as exc:
481
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
482
+ raise typer.Exit(code=5) from None
483
+
484
+ if formatter.json_mode:
485
+ formatter.output(result)
486
+ else:
487
+ format_search_results(formatter.console, result)
488
+ emit_project_warnings(formatter, result)
489
+
490
+
491
+ def _parse_json_input(raw: str) -> dict:
492
+ """Parse JSON from inline string, ``@file``, or ``-`` (stdin)."""
493
+ import sys
494
+
495
+ if raw == "-":
496
+ return json.loads(sys.stdin.read())
497
+ if raw.startswith("@"):
498
+ file_path = Path(raw[1:])
499
+ if not file_path.is_file():
500
+ raise FileNotFoundError(f"Input file not found: {file_path}")
501
+ return json.loads(file_path.read_text(encoding="utf-8"))
502
+ return json.loads(raw)
503
+
504
+
505
+ def _parse_set_value(raw: str) -> object:
506
+ """Try to parse *raw* as JSON; fall back to plain string."""
507
+ try:
508
+ return json.loads(raw)
509
+ except (json.JSONDecodeError, ValueError):
510
+ return raw
511
+
512
+
513
+ @config_app.command("update", rich_help_panel="Lifecycle")
514
+ def config_update(
515
+ ctx: typer.Context,
516
+ project: str = typer.Option(
517
+ ...,
518
+ "--project",
519
+ help="Project alias",
520
+ ),
521
+ component_id: str = typer.Option(
522
+ ...,
523
+ "--component-id",
524
+ help="Component ID (e.g. keboola.python-transformation-v2)",
525
+ ),
526
+ config_id: str = typer.Option(
527
+ ...,
528
+ "--config-id",
529
+ help="Configuration ID to update",
530
+ ),
531
+ name: str | None = typer.Option(
532
+ None,
533
+ "--name",
534
+ help="New configuration name",
535
+ ),
536
+ description: str | None = typer.Option(
537
+ None,
538
+ "--description",
539
+ help="New configuration description",
540
+ ),
541
+ configuration: str | None = typer.Option(
542
+ None,
543
+ "--configuration",
544
+ help="Configuration JSON: inline, @file.json, or - for stdin",
545
+ ),
546
+ configuration_file: Path | None = typer.Option(
547
+ None,
548
+ "--configuration-file",
549
+ help="Path to a JSON file with configuration content",
550
+ exists=True,
551
+ readable=True,
552
+ ),
553
+ set_values: list[str] | None = typer.Option(
554
+ None,
555
+ "--set",
556
+ help="Set a nested value: PATH VALUE (e.g. --set 'parameters.db.host=new-host')",
557
+ ),
558
+ merge: bool = typer.Option(
559
+ False,
560
+ "--merge",
561
+ help="Deep-merge into existing config instead of replacing",
562
+ ),
563
+ dry_run: bool = typer.Option(
564
+ False,
565
+ "--dry-run",
566
+ help="Show what would change without applying",
567
+ ),
568
+ branch: int | None = typer.Option(
569
+ None,
570
+ "--branch",
571
+ help="Update in a specific dev branch ID (defaults to active branch)",
572
+ ),
573
+ allow_plaintext: bool = typer.Option(
574
+ False,
575
+ "--allow-plaintext-on-encrypt-failure",
576
+ help="Allow write even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
577
+ ),
578
+ ) -> None:
579
+ """Update a configuration's metadata and/or content.
580
+
581
+ \b
582
+ Metadata options (--name, --description) update display info.
583
+ Content options modify the configuration JSON itself:
584
+ --configuration / --configuration-file : provide a full JSON blob
585
+ --set PATH=VALUE : set a single nested key (repeatable)
586
+ --merge : deep-merge into existing config (preserves sibling keys)
587
+ --dry-run : preview changes without applying
588
+
589
+ \b
590
+ Examples:
591
+ # Update just the name
592
+ kbagent config update --project P --component-id C --config-id ID --name "New name"
593
+
594
+ # Replace entire configuration from a file
595
+ kbagent config update --project P --component-id C --config-id ID --configuration-file config.json
596
+
597
+ # Deep-merge a partial update (preserves sibling keys!)
598
+ kbagent config update --project P --component-id C --config-id ID \\
599
+ --configuration '{"parameters": {"tables": {"new": "data"}}}' --merge
600
+
601
+ # Set a single nested value
602
+ kbagent config update --project P --component-id C --config-id ID \\
603
+ --set 'parameters.db.host=new-host.example.com'
604
+
605
+ # Preview changes without applying
606
+ kbagent config update --project P --component-id C --config-id ID \\
607
+ --set 'parameters.db.host=new-host' --dry-run
608
+ """
609
+ formatter = get_formatter(ctx)
610
+ service = get_service(ctx, "config_service")
611
+
612
+ # --- Parse configuration content ------------------------------------------
613
+ config_dict: dict | None = None
614
+ if configuration and configuration_file:
615
+ formatter.error(
616
+ message="Cannot use both --configuration and --configuration-file.",
617
+ error_code=ErrorCode.VALIDATION_ERROR,
618
+ )
619
+ raise typer.Exit(code=2) from None
620
+
621
+ if configuration:
622
+ try:
623
+ config_dict = _parse_json_input(configuration)
624
+ except (json.JSONDecodeError, FileNotFoundError) as exc:
625
+ formatter.error(
626
+ message=f"Invalid --configuration input: {exc}",
627
+ error_code=ErrorCode.VALIDATION_ERROR,
628
+ )
629
+ raise typer.Exit(code=2) from None
630
+
631
+ if configuration_file:
632
+ try:
633
+ config_dict = json.loads(configuration_file.read_text(encoding="utf-8"))
634
+ except json.JSONDecodeError as exc:
635
+ formatter.error(
636
+ message=f"Invalid JSON in {configuration_file}: {exc}",
637
+ error_code=ErrorCode.VALIDATION_ERROR,
638
+ )
639
+ raise typer.Exit(code=2) from None
640
+
641
+ # --- Parse --set values ---------------------------------------------------
642
+ parsed_sets: list[tuple[str, object]] | None = None
643
+ if set_values:
644
+ parsed_sets = []
645
+ for item in set_values:
646
+ if "=" not in item:
647
+ formatter.error(
648
+ message=f"Invalid --set format: '{item}'. Expected PATH=VALUE.",
649
+ error_code=ErrorCode.VALIDATION_ERROR,
650
+ )
651
+ raise typer.Exit(code=2) from None
652
+ path, _, raw_value = item.partition("=")
653
+ parsed_sets.append((path.strip(), _parse_set_value(raw_value.strip())))
654
+
655
+ # --set implies merge
656
+ effective_merge = merge or bool(parsed_sets)
657
+
658
+ try:
659
+ result = service.update_config(
660
+ alias=project,
661
+ component_id=component_id,
662
+ config_id=config_id,
663
+ name=name,
664
+ description=description,
665
+ configuration=config_dict,
666
+ set_paths=parsed_sets,
667
+ merge=effective_merge,
668
+ dry_run=dry_run,
669
+ branch_id=branch,
670
+ allow_plaintext_fallback=allow_plaintext,
671
+ )
672
+ except ConfigError as exc:
673
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
674
+ raise typer.Exit(code=5) from None
675
+ except KeboolaApiError as exc:
676
+ formatter.error(
677
+ message=exc.message,
678
+ error_code=exc.error_code,
679
+ retryable=exc.retryable,
680
+ )
681
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
682
+
683
+ # --- Output ---------------------------------------------------------------
684
+ normalizations = result.get("normalizations") or []
685
+
686
+ if result.get("dry_run"):
687
+ changes = result.get("changes", [])
688
+ if formatter.json_mode:
689
+ formatter.output(result)
690
+ else:
691
+ if not changes:
692
+ formatter.success("No changes detected.")
693
+ else:
694
+ formatter.console.print(f"\n[bold]Dry-run: {len(changes)} change(s)[/bold]\n")
695
+ for change in changes:
696
+ formatter.console.print(f" {change}")
697
+ formatter.console.print()
698
+ _emit_normalizations_warning(formatter, normalizations)
699
+ return
700
+
701
+ if formatter.json_mode:
702
+ formatter.output(result)
703
+ else:
704
+ updated_name = result.get("name", "")
705
+ branch_info = ""
706
+ if result.get("branch_id"):
707
+ branch_info = f" (branch {result['branch_id']})"
708
+ formatter.success(
709
+ f"Updated config '{updated_name}' "
710
+ f"({result.get('component_id', component_id)}/{config_id})"
711
+ f"{branch_info}"
712
+ )
713
+ _emit_normalizations_warning(formatter, normalizations)
714
+
715
+
716
+ def _emit_normalizations_warning(formatter: Any, normalizations: list[dict[str, Any]]) -> None:
717
+ """Surface ``parameters.blocks[].codes[].script`` normalizations in human mode.
718
+
719
+ Storage API silently accepts a string for ``script``; the runtime
720
+ validator rejects it later. When kbagent auto-fixes the shape, the
721
+ operator must see what was changed -- otherwise the fix is invisible
722
+ and a downstream consumer might rely on the original (broken) input
723
+ shape having been written verbatim. JSON mode already exposes the
724
+ same data via the ``normalizations`` field on the envelope.
725
+ """
726
+ if not normalizations:
727
+ return
728
+ formatter.console.print(
729
+ f"[yellow]Auto-normalized {len(normalizations)} script field(s) "
730
+ f"to array (string -> list). See --json for details.[/yellow]"
731
+ )
732
+ for entry in normalizations:
733
+ formatter.console.print(
734
+ f" [dim]{entry['path']}: {entry['action']} -> {entry['after_length']} element(s)[/dim]"
735
+ )
736
+
737
+
738
+ @config_app.command("set-default-bucket", rich_help_panel="Storage")
739
+ def config_set_default_bucket(
740
+ ctx: typer.Context,
741
+ project: str = typer.Option(
742
+ ...,
743
+ "--project",
744
+ help="Project alias",
745
+ ),
746
+ component_id: str = typer.Option(
747
+ ...,
748
+ "--component-id",
749
+ help="Component ID (e.g. keboola.ex-db-snowflake)",
750
+ ),
751
+ config_id: str = typer.Option(
752
+ ...,
753
+ "--config-id",
754
+ help="Configuration ID",
755
+ ),
756
+ bucket: str | None = typer.Option(
757
+ None,
758
+ "--bucket",
759
+ help="Bucket ID to set as default output (e.g. in.c-preferred-name)",
760
+ ),
761
+ clear: bool = typer.Option(
762
+ False,
763
+ "--clear",
764
+ help="Remove the default_bucket key. Mutually exclusive with --bucket.",
765
+ ),
766
+ dry_run: bool = typer.Option(
767
+ False,
768
+ "--dry-run",
769
+ help="Show what would change without applying",
770
+ ),
771
+ branch: int | None = typer.Option(
772
+ None,
773
+ "--branch",
774
+ help="Apply in a specific dev branch ID (defaults to active branch)",
775
+ ),
776
+ ) -> None:
777
+ """Set or clear ``storage.output.default_bucket`` on a configuration.
778
+
779
+ \b
780
+ The Keboola Storage API honors ``configuration.storage.output.default_bucket``
781
+ to override the auto-derived bucket name (``in.<component>-<config-id>``)
782
+ for any output table that does not pin its own ``destination``.
783
+
784
+ \b
785
+ Examples:
786
+ # Set
787
+ kbagent config set-default-bucket --project P --component-id keboola.ex-db-snowflake \\
788
+ --config-id 12345 --bucket in.c-preferred-name
789
+
790
+ # Clear
791
+ kbagent config set-default-bucket --project P --component-id keboola.ex-db-snowflake \\
792
+ --config-id 12345 --clear
793
+
794
+ # Preview
795
+ kbagent config set-default-bucket --project P --component-id keboola.ex-db-snowflake \\
796
+ --config-id 12345 --bucket in.c-preferred-name --dry-run
797
+ """
798
+ formatter = get_formatter(ctx)
799
+ service = get_service(ctx, "config_service")
800
+
801
+ if bucket is not None and clear:
802
+ formatter.error(
803
+ message="Pass exactly one of --bucket or --clear, not both.",
804
+ error_code=ErrorCode.VALIDATION_ERROR,
805
+ )
806
+ raise typer.Exit(code=2) from None
807
+ if bucket is None and not clear:
808
+ formatter.error(
809
+ message="Pass --bucket BUCKET_ID or --clear.",
810
+ error_code=ErrorCode.VALIDATION_ERROR,
811
+ )
812
+ raise typer.Exit(code=2) from None
813
+
814
+ try:
815
+ result = service.set_default_bucket(
816
+ alias=project,
817
+ component_id=component_id,
818
+ config_id=config_id,
819
+ bucket=bucket,
820
+ clear=clear,
821
+ dry_run=dry_run,
822
+ branch_id=branch,
823
+ )
824
+ except ConfigError as exc:
825
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
826
+ raise typer.Exit(code=5) from None
827
+ except KeboolaApiError as exc:
828
+ formatter.error(
829
+ message=exc.message,
830
+ error_code=exc.error_code,
831
+ retryable=exc.retryable,
832
+ )
833
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
834
+
835
+ if result.get("dry_run"):
836
+ changes = result.get("changes", [])
837
+ if formatter.json_mode:
838
+ formatter.output(result)
839
+ else:
840
+ if not changes:
841
+ formatter.success("No changes detected.")
842
+ else:
843
+ formatter.console.print(f"\n[bold]Dry-run: {len(changes)} change(s)[/bold]\n")
844
+ for change in changes:
845
+ formatter.console.print(f" {change}")
846
+ formatter.console.print()
847
+ return
848
+
849
+ if formatter.json_mode:
850
+ formatter.output(result)
851
+ return
852
+
853
+ target = f"{component_id}/{config_id}"
854
+ if result.get("changed") is False:
855
+ existing = result.get("default_bucket")
856
+ if clear:
857
+ formatter.success(f"No change: default_bucket was already absent on {target}.")
858
+ else:
859
+ formatter.success(f"No change: default_bucket on {target} was already '{existing}'.")
860
+ return
861
+
862
+ if clear:
863
+ formatter.success(f"Cleared default_bucket on {target}.")
864
+ else:
865
+ formatter.success(f"Set default_bucket on {target} to '{bucket}'.")
866
+
867
+
868
+ @config_app.command("rename", rich_help_panel="Lifecycle")
869
+ def config_rename(
870
+ ctx: typer.Context,
871
+ project: str = typer.Option(
872
+ ...,
873
+ "--project",
874
+ help="Project alias",
875
+ ),
876
+ component_id: str = typer.Option(
877
+ ...,
878
+ "--component-id",
879
+ help="Component ID (e.g. keboola.python-transformation-v2)",
880
+ ),
881
+ config_id: str = typer.Option(
882
+ ...,
883
+ "--config-id",
884
+ help="Configuration ID to rename",
885
+ ),
886
+ name: str = typer.Option(
887
+ ...,
888
+ "--name",
889
+ help="New name for the configuration",
890
+ ),
891
+ branch: int | None = typer.Option(
892
+ None,
893
+ "--branch",
894
+ help="Rename in a specific dev branch ID (defaults to active branch)",
895
+ ),
896
+ directory: Path | None = typer.Option(
897
+ None,
898
+ "--directory",
899
+ "-d",
900
+ help="Sync working directory (auto-detects .keboola/manifest.json in CWD if omitted)",
901
+ ),
902
+ ) -> None:
903
+ """Rename a configuration (update name via API + rename local sync directory).
904
+
905
+ Updates the configuration name in the Keboola project. If a local sync
906
+ directory is detected (either via --directory or the current working
907
+ directory), the local folder is renamed and the manifest is updated
908
+ to match.
909
+
910
+ \b
911
+ Examples:
912
+ # Simple rename
913
+ kbagent config rename --project prod --component-id kds-team.app-custom-python \\
914
+ --config-id abc123 --name "Stripe Extractor"
915
+
916
+ # Rename with explicit sync directory
917
+ kbagent config rename --project prod --component-id kds-team.app-custom-python \\
918
+ --config-id abc123 --name "Stripe Extractor" --directory ./my-project
919
+ """
920
+ formatter = get_formatter(ctx)
921
+ service = get_service(ctx, "config_service")
922
+
923
+ # Auto-detect sync directory from CWD if not specified
924
+ effective_directory = directory
925
+ if effective_directory is None:
926
+ cwd = Path.cwd()
927
+ if (cwd / KEBOOLA_DIR_NAME / MANIFEST_FILENAME).exists():
928
+ effective_directory = cwd
929
+
930
+ try:
931
+ result = service.rename_config(
932
+ alias=project,
933
+ component_id=component_id,
934
+ config_id=config_id,
935
+ name=name,
936
+ branch_id=branch,
937
+ directory=effective_directory,
938
+ )
939
+ except ConfigError as exc:
940
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
941
+ raise typer.Exit(code=5) from None
942
+ except KeboolaApiError as exc:
943
+ formatter.error(
944
+ message=exc.message,
945
+ error_code=exc.error_code,
946
+ retryable=exc.retryable,
947
+ )
948
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
949
+
950
+ if formatter.json_mode:
951
+ formatter.output(result)
952
+ else:
953
+ branch_info = ""
954
+ if result.get("branch_id"):
955
+ branch_info = f" (branch {result['branch_id']})"
956
+ formatter.success(
957
+ f'Renamed "{result["old_name"]}" -> "{result["new_name"]}"'
958
+ f" ({component_id}/{config_id}){branch_info}"
959
+ )
960
+ sync_info = result.get("sync")
961
+ if sync_info:
962
+ formatter.console.print(
963
+ f" Sync: {sync_info['old_path']}/ -> {sync_info['new_path']}/"
964
+ f" ({sync_info['method']})"
965
+ )
966
+
967
+
968
+ @config_app.command("delete", rich_help_panel="Lifecycle")
969
+ def config_delete(
970
+ ctx: typer.Context,
971
+ project: str = typer.Option(
972
+ ...,
973
+ "--project",
974
+ help="Project alias",
975
+ ),
976
+ component_id: str = typer.Option(
977
+ ...,
978
+ "--component-id",
979
+ help="Component ID (e.g. keboola.python-transformation-v2)",
980
+ ),
981
+ config_id: str = typer.Option(
982
+ ...,
983
+ "--config-id",
984
+ help="Configuration ID to delete",
985
+ ),
986
+ branch: int | None = typer.Option(
987
+ None,
988
+ "--branch",
989
+ help="Delete from a specific dev branch ID (defaults to active branch)",
990
+ ),
991
+ ) -> None:
992
+ """Delete a configuration from a project.
993
+
994
+ If a dev branch is active (via 'branch use'), the deletion targets
995
+ that branch. Use --branch to override. Deleting in a branch marks
996
+ the config as removed without affecting Main.
997
+ """
998
+ formatter = get_formatter(ctx)
999
+ service = get_service(ctx, "config_service")
1000
+
1001
+ try:
1002
+ result = service.delete_config(
1003
+ alias=project,
1004
+ component_id=component_id,
1005
+ config_id=config_id,
1006
+ branch_id=branch,
1007
+ )
1008
+ except ConfigError as exc:
1009
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1010
+ raise typer.Exit(code=5) from None
1011
+ except KeboolaApiError as exc:
1012
+ formatter.error(
1013
+ message=exc.message,
1014
+ error_code=exc.error_code,
1015
+ retryable=exc.retryable,
1016
+ )
1017
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1018
+
1019
+ if formatter.json_mode:
1020
+ formatter.output(result)
1021
+ else:
1022
+ branch_info = ""
1023
+ if result.get("branch_id"):
1024
+ branch_info = f" (branch {result['branch_id']})"
1025
+ formatter.success(
1026
+ f"Deleted config {result['component_id']}/{result['config_id']} "
1027
+ f"from project '{result['project_alias']}'{branch_info}"
1028
+ )
1029
+
1030
+
1031
+ # --- File extension to Rich Syntax lexer mapping ---
1032
+ _EXT_TO_LEXER: dict[str, str] = {
1033
+ ".yml": "yaml",
1034
+ ".yaml": "yaml",
1035
+ ".json": "json",
1036
+ ".sql": "sql",
1037
+ ".py": "python",
1038
+ ".toml": "toml",
1039
+ ".md": "markdown",
1040
+ ".txt": "text",
1041
+ ".sh": "bash",
1042
+ ".r": "r",
1043
+ ".js": "javascript",
1044
+ ".ts": "typescript",
1045
+ }
1046
+
1047
+
1048
+ @config_app.command("new", rich_help_panel="Lifecycle")
1049
+ def config_new(
1050
+ ctx: typer.Context,
1051
+ component_id: str = typer.Option(
1052
+ ...,
1053
+ "--component-id",
1054
+ help="Component ID (e.g. keboola.ex-http)",
1055
+ ),
1056
+ name: str = typer.Option(
1057
+ "",
1058
+ "--name",
1059
+ help="Configuration name (default: auto-generated from component; required with --push)",
1060
+ ),
1061
+ project: str | None = typer.Option(
1062
+ None,
1063
+ "--project",
1064
+ help="Project alias (for AI Service auth; required with --push)",
1065
+ ),
1066
+ output_dir: str | None = typer.Option(
1067
+ None,
1068
+ "--output-dir",
1069
+ help="Write scaffold files to disk instead of printing",
1070
+ ),
1071
+ push: bool = typer.Option(
1072
+ False,
1073
+ "--push",
1074
+ help="Also create the configuration remotely via the Storage API (one-shot; requires --project and --name)",
1075
+ ),
1076
+ no_files: bool = typer.Option(
1077
+ False,
1078
+ "--no-files",
1079
+ help="With --push: skip writing/printing scaffold; only POST to API (FIIA-style one-shot)",
1080
+ ),
1081
+ description: str = typer.Option(
1082
+ "",
1083
+ "--description",
1084
+ help="Configuration description (used with --push)",
1085
+ ),
1086
+ configuration: str | None = typer.Option(
1087
+ None,
1088
+ "--configuration",
1089
+ help="Override the configuration body to POST (used with --push): JSON inline, @file, or - for stdin",
1090
+ ),
1091
+ configuration_file: Path | None = typer.Option(
1092
+ None,
1093
+ "--configuration-file",
1094
+ help="Override the configuration body from a JSON file (used with --push)",
1095
+ exists=True,
1096
+ readable=True,
1097
+ ),
1098
+ no_validate: bool = typer.Option(
1099
+ False,
1100
+ "--no-validate",
1101
+ help="Skip schema validation against the component's AI Service spec (used with --push)",
1102
+ ),
1103
+ branch: int | None = typer.Option(
1104
+ None,
1105
+ "--branch",
1106
+ help="Create in a specific dev branch (used with --push; defaults to active branch)",
1107
+ ),
1108
+ dry_run: bool = typer.Option(
1109
+ False,
1110
+ "--dry-run",
1111
+ help="With --push: show planned POST + validation result without creating",
1112
+ ),
1113
+ allow_plaintext: bool = typer.Option(
1114
+ False,
1115
+ "--allow-plaintext-on-encrypt-failure",
1116
+ help="With --push: allow create even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
1117
+ ),
1118
+ ) -> None:
1119
+ """Generate boilerplate configuration files for a Keboola component, optionally creating the config remotely in one shot.
1120
+
1121
+ \b
1122
+ Two modes:
1123
+ * Default (no --push): scaffold only -- generates ready-to-edit files
1124
+ (config YAML, code blocks, description). Writes to --output-dir or
1125
+ prints to stdout. **Zero API calls.**
1126
+ * With --push: scaffold step + POST to Storage API. Requires --project
1127
+ and a non-empty --name. Use --no-files for FIIA-style one-shot create
1128
+ with no filesystem step.
1129
+
1130
+ \b
1131
+ Examples:
1132
+ # Scaffold-only (today's behavior, unchanged)
1133
+ kbagent config new --component-id keboola.ex-http --output-dir ./scratch
1134
+
1135
+ # Scaffold AND remote create:
1136
+ kbagent config new --component-id keboola.ex-http --name "API ingest" \\
1137
+ --project prod --output-dir ./scratch --push
1138
+
1139
+ # FIIA-style: one-shot remote create with no filesystem step:
1140
+ kbagent config new --component-id keboola.ex-http --name "API ingest" \\
1141
+ --project prod --push --no-files
1142
+
1143
+ # Override the POSTed body with a pre-made config:
1144
+ kbagent config new --component-id keboola.python-transformation-v2 \\
1145
+ --name "T1" --project prod --push --no-files \\
1146
+ --configuration-file ./body.json --branch 42
1147
+
1148
+ # Preview the planned POST without creating:
1149
+ kbagent config new --component-id keboola.ex-http --name "smoke" \\
1150
+ --project prod --push --no-files --dry-run
1151
+ """
1152
+ formatter = get_formatter(ctx)
1153
+
1154
+ # ── Flag-combination validation ──────────────────────────────────────────
1155
+ #
1156
+ # All --push-gated flags must be set only when --push is on; --push itself
1157
+ # requires --project and a non-empty --name; --configuration vs
1158
+ # --configuration-file and --output-dir vs --no-files are mutually
1159
+ # exclusive.
1160
+ push_gated: list[tuple[str, bool]] = [
1161
+ ("--no-files", no_files),
1162
+ ("--description", bool(description)),
1163
+ ("--configuration", configuration is not None),
1164
+ ("--configuration-file", configuration_file is not None),
1165
+ ("--no-validate", no_validate),
1166
+ ("--branch", branch is not None),
1167
+ ("--dry-run", dry_run),
1168
+ ]
1169
+ if not push:
1170
+ for flag_name, flag_set in push_gated:
1171
+ if flag_set:
1172
+ formatter.error(
1173
+ message=f"{flag_name} requires --push",
1174
+ error_code=ErrorCode.VALIDATION_ERROR,
1175
+ )
1176
+ raise typer.Exit(code=2)
1177
+ else:
1178
+ if not project:
1179
+ formatter.error(
1180
+ message="--push requires --project",
1181
+ error_code=ErrorCode.VALIDATION_ERROR,
1182
+ )
1183
+ raise typer.Exit(code=2)
1184
+ if not name:
1185
+ formatter.error(
1186
+ message="--push requires a non-empty --name",
1187
+ error_code=ErrorCode.VALIDATION_ERROR,
1188
+ )
1189
+ raise typer.Exit(code=2)
1190
+ if configuration is not None and configuration_file is not None:
1191
+ formatter.error(
1192
+ message="--configuration and --configuration-file are mutually exclusive",
1193
+ error_code=ErrorCode.VALIDATION_ERROR,
1194
+ )
1195
+ raise typer.Exit(code=2)
1196
+ if no_files and output_dir:
1197
+ formatter.error(
1198
+ message="--no-files and --output-dir are mutually exclusive",
1199
+ error_code=ErrorCode.VALIDATION_ERROR,
1200
+ )
1201
+ raise typer.Exit(code=2)
1202
+
1203
+ # ── Parse the optional body override (used only with --push) ─────────────
1204
+ config_body: dict[str, Any] | None = None
1205
+ if push:
1206
+ if configuration is not None:
1207
+ try:
1208
+ config_body = _parse_json_input(configuration)
1209
+ except (json.JSONDecodeError, FileNotFoundError) as exc:
1210
+ formatter.error(
1211
+ message=f"Invalid --configuration input: {exc}",
1212
+ error_code=ErrorCode.VALIDATION_ERROR,
1213
+ )
1214
+ raise typer.Exit(code=2) from None
1215
+ elif configuration_file is not None:
1216
+ try:
1217
+ config_body = json.loads(configuration_file.read_text(encoding="utf-8"))
1218
+ except json.JSONDecodeError as exc:
1219
+ formatter.error(
1220
+ message=f"Invalid JSON in --configuration-file {configuration_file}: {exc}",
1221
+ error_code=ErrorCode.VALIDATION_ERROR,
1222
+ )
1223
+ raise typer.Exit(code=2) from None
1224
+
1225
+ # ── Scaffold step (runs in scaffold-only mode and in push+files modes) ───
1226
+ scaffold: dict[str, Any] | None = None
1227
+ skip_scaffold = push and no_files
1228
+ if not skip_scaffold:
1229
+ component_service = get_service(ctx, "component_service")
1230
+ try:
1231
+ scaffold = component_service.generate_scaffold(
1232
+ alias=project,
1233
+ component_id=component_id,
1234
+ name=name or None,
1235
+ )
1236
+ except ConfigError as exc:
1237
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1238
+ raise typer.Exit(code=5) from None
1239
+ except KeboolaApiError as exc:
1240
+ exit_code = map_error_to_exit_code(exc)
1241
+ formatter.error(
1242
+ message=exc.message,
1243
+ error_code=exc.error_code,
1244
+ project=project or "",
1245
+ retryable=exc.retryable,
1246
+ )
1247
+ raise typer.Exit(code=exit_code) from None
1248
+
1249
+ # ── Push path: also create remotely via Storage API ──────────────────────
1250
+ if push:
1251
+ config_service = get_service(ctx, "config_service")
1252
+ try:
1253
+ push_result = config_service.create_config(
1254
+ alias=project,
1255
+ component_id=component_id,
1256
+ name=name,
1257
+ description=description,
1258
+ configuration=config_body,
1259
+ branch_id=branch,
1260
+ dry_run=dry_run,
1261
+ validate=not no_validate,
1262
+ allow_plaintext_fallback=allow_plaintext,
1263
+ )
1264
+ except ConfigError as exc:
1265
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1266
+ raise typer.Exit(code=5) from None
1267
+ except KeboolaApiError as exc:
1268
+ exit_code = map_error_to_exit_code(exc)
1269
+ formatter.error(
1270
+ message=exc.message,
1271
+ error_code=exc.error_code,
1272
+ project=project or "",
1273
+ retryable=exc.retryable,
1274
+ )
1275
+ raise typer.Exit(code=exit_code) from None
1276
+
1277
+ # When --push is set AND --output-dir is given AND we're not in
1278
+ # dry-run mode, ALSO write the scaffold to disk in addition to the
1279
+ # POST (the "scaffold + push" combo). Dry-run must NOT touch the
1280
+ # filesystem -- the user expects a preview, not a side effect.
1281
+ #
1282
+ # ``silent=True``: the push-result envelope below is the authoritative
1283
+ # output for this path. In JSON mode emitting the scaffold envelope
1284
+ # too would produce two concatenated JSON documents on stdout (breaks
1285
+ # ``jq``); in human mode the dim banner adds noise above the "Created
1286
+ # config ..." line that already lists the same path. ``json_mode`` is
1287
+ # passed as a sentinel ``False`` because ``silent=True`` short-circuits
1288
+ # before it is read.
1289
+ if output_dir and scaffold is not None and not dry_run:
1290
+ _write_scaffold_to_disk(formatter, scaffold, output_dir, json_mode=False, silent=True)
1291
+
1292
+ if formatter.json_mode:
1293
+ formatter.output(push_result)
1294
+ else:
1295
+ _render_push_result_human(formatter, push_result, component_id, name)
1296
+ return
1297
+
1298
+ # ── Scaffold-only path: today's behavior, byte-for-byte unchanged ────────
1299
+ assert scaffold is not None # narrowing for type-checker; skip_scaffold is False here.
1300
+ if output_dir:
1301
+ _write_scaffold_to_disk(formatter, scaffold, output_dir, json_mode=formatter.json_mode)
1302
+ else:
1303
+ # Print scaffold content
1304
+ if formatter.json_mode:
1305
+ formatter.output(scaffold)
1306
+ else:
1307
+ formatter.console.print(f"\n[bold]Scaffold for [cyan]{component_id}[/cyan][/bold]")
1308
+ formatter.console.print(f"[dim]Directory: {scaffold['directory']}[/dim]\n")
1309
+
1310
+ for file_entry in scaffold["files"]:
1311
+ file_name = file_entry["path"]
1312
+ content = file_entry["content"]
1313
+
1314
+ # Determine lexer from file extension
1315
+ suffix = Path(file_name).suffix.lower()
1316
+ lexer = _EXT_TO_LEXER.get(suffix, "text")
1317
+
1318
+ formatter.console.rule(f"[bold]{file_name}[/bold]")
1319
+ syntax = Syntax(
1320
+ content,
1321
+ lexer,
1322
+ theme="monokai",
1323
+ line_numbers=True,
1324
+ )
1325
+ formatter.console.print(syntax)
1326
+ formatter.console.print()
1327
+
1328
+
1329
+ def _write_scaffold_to_disk(
1330
+ formatter: Any,
1331
+ scaffold: dict[str, Any],
1332
+ output_dir: str,
1333
+ json_mode: bool,
1334
+ silent: bool = False,
1335
+ ) -> None:
1336
+ """Shared helper: write the generated scaffold files under ``output_dir``.
1337
+
1338
+ Detects an enclosing ``main/`` branch prefix the same way the pre-push
1339
+ path does, so the layout matches what ``kbagent sync push`` would expect.
1340
+
1341
+ Output rules:
1342
+ - ``silent=True``: write files only; emit no banner and no JSON envelope.
1343
+ Used by the ``--push --output-dir`` path, where the push-result envelope
1344
+ (single JSON in JSON mode; the "Created config ..." line in human mode)
1345
+ is the authoritative output and the scaffold-banner side-channel would
1346
+ either duplicate JSON (breaking ``jq`` pipes) or distract in human mode.
1347
+ - ``silent=False`` (default; scaffold-only mode): emit a JSON envelope
1348
+ ``{directory, files_written}`` when ``json_mode`` is set, otherwise a
1349
+ dim-formatted "Scaffold written to ..." banner.
1350
+ """
1351
+ branch_prefix = _detect_branch_prefix(Path(output_dir))
1352
+ if branch_prefix:
1353
+ scaffold_dir = branch_prefix + "/" + scaffold["directory"]
1354
+ else:
1355
+ scaffold_dir = scaffold["directory"]
1356
+
1357
+ base_path = Path(output_dir) / scaffold_dir
1358
+ base_path.mkdir(parents=True, exist_ok=True)
1359
+
1360
+ for file_entry in scaffold["files"]:
1361
+ file_path = base_path / file_entry["path"]
1362
+ file_path.parent.mkdir(parents=True, exist_ok=True)
1363
+ file_path.write_text(file_entry["content"], encoding="utf-8")
1364
+
1365
+ if silent:
1366
+ return
1367
+
1368
+ if json_mode:
1369
+ formatter.output(
1370
+ {
1371
+ "directory": str(base_path),
1372
+ "files_written": [f["path"] for f in scaffold["files"]],
1373
+ }
1374
+ )
1375
+ else:
1376
+ formatter.console.print(
1377
+ f"[dim]Scaffold written to {base_path} ({len(scaffold['files'])} file(s))[/dim]"
1378
+ )
1379
+
1380
+
1381
+ def _render_push_result_human(
1382
+ formatter: Any,
1383
+ result: dict[str, Any],
1384
+ component_id: str,
1385
+ name: str,
1386
+ ) -> None:
1387
+ """Render the human-mode banner for ``config new --push`` (dry-run + success)."""
1388
+ validation_status = result.get("validation_status", "skipped")
1389
+ validation_errors = result.get("validation_errors", []) or []
1390
+ branch_info = f" (branch {result['branch_id']})" if result.get("branch_id") else ""
1391
+
1392
+ if result.get("dry_run"):
1393
+ formatter.console.print(
1394
+ f"\n[bold]Dry-run -- would POST {escape(component_id)} '{escape(name)}'{branch_info}[/bold]\n"
1395
+ )
1396
+ formatter.console.print("[dim]Planned configuration body:[/dim]")
1397
+ formatter.console.print(json.dumps(result.get("configuration", {}), indent=2))
1398
+ if validation_status == "ok":
1399
+ formatter.console.print("\n[green]✓ Schema validation passed[/green]")
1400
+ elif validation_status == "skipped":
1401
+ formatter.console.print(
1402
+ "\n[yellow]⚠ Schema validation skipped[/yellow] "
1403
+ "[dim](empty shell, no schema available, or --no-validate)[/dim]"
1404
+ )
1405
+ else: # failed
1406
+ formatter.console.print("\n[red]✗ Schema validation failed:[/red]")
1407
+ for err in validation_errors:
1408
+ formatter.console.print(f" [red]• {escape(err)}[/red]")
1409
+ return
1410
+
1411
+ config_id = result.get("id", "")
1412
+ formatter.success(
1413
+ f"Created config '{escape(name)}' [{config_id}] in {escape(component_id)}{branch_info}"
1414
+ )
1415
+ if validation_status == "skipped":
1416
+ formatter.console.print(
1417
+ "[dim]Note: schema validation was skipped "
1418
+ "(empty shell, no schema available, or --no-validate).[/dim]"
1419
+ )
1420
+
1421
+
1422
+ # ── Config metadata commands ───────────────────────────────────────────
1423
+
1424
+
1425
+ @config_app.command("metadata-list", rich_help_panel="Metadata")
1426
+ def config_metadata_list(
1427
+ ctx: typer.Context,
1428
+ project: str = typer.Option(..., "--project", help="Project alias"),
1429
+ component_id: str = typer.Option(..., "--component-id", help="Component ID"),
1430
+ config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
1431
+ branch: int | None = typer.Option(
1432
+ None, "--branch", help="Dev branch ID (defaults to active branch)"
1433
+ ),
1434
+ ) -> None:
1435
+ """List all metadata entries on a configuration."""
1436
+ formatter = get_formatter(ctx)
1437
+ config_store: ConfigStore = ctx.obj["config_store"]
1438
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1439
+ service = get_service(ctx, "config_service")
1440
+ try:
1441
+ result = service.list_config_metadata(
1442
+ alias=project,
1443
+ component_id=component_id,
1444
+ config_id=config_id,
1445
+ branch_id=effective_branch,
1446
+ )
1447
+ except ConfigError as exc:
1448
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1449
+ raise typer.Exit(code=5) from None
1450
+ except KeboolaApiError as exc:
1451
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1452
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1453
+
1454
+ if formatter.json_mode:
1455
+ formatter.output(result)
1456
+ else:
1457
+ entries = result.get("metadata", [])
1458
+ if not entries:
1459
+ formatter.console.print("[dim]No metadata entries.[/dim]")
1460
+ else:
1461
+ for e in entries:
1462
+ formatter.console.print(
1463
+ f" [dim]{escape(str(e.get('id', '')))}[/dim] [green]{escape(e.get('key', ''))}[/green] = {escape(str(e.get('value', '')))} [dim]{escape(e.get('provider', 'user'))}[/dim]"
1464
+ )
1465
+
1466
+
1467
+ @config_app.command("get-metadata", rich_help_panel="Metadata")
1468
+ def config_get_metadata(
1469
+ ctx: typer.Context,
1470
+ project: str = typer.Option(..., "--project", help="Project alias"),
1471
+ component_id: str = typer.Option(..., "--component-id", help="Component ID"),
1472
+ config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
1473
+ key: str = typer.Option(..., "--key", help="Metadata key to read"),
1474
+ branch: int | None = typer.Option(
1475
+ None, "--branch", help="Dev branch ID (defaults to active branch)"
1476
+ ),
1477
+ ) -> None:
1478
+ """Read a single metadata value by key.
1479
+
1480
+ Exits with code 1 (NOT_FOUND) if the key is not present.
1481
+ """
1482
+ formatter = get_formatter(ctx)
1483
+ config_store: ConfigStore = ctx.obj["config_store"]
1484
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1485
+ service = get_service(ctx, "config_service")
1486
+ try:
1487
+ result = service.get_config_metadata_value(
1488
+ alias=project,
1489
+ component_id=component_id,
1490
+ config_id=config_id,
1491
+ key=key,
1492
+ branch_id=effective_branch,
1493
+ )
1494
+ except ConfigError as exc:
1495
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1496
+ raise typer.Exit(code=5) from None
1497
+ except KeboolaApiError as exc:
1498
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1499
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1500
+ formatter.output(result, lambda c, d: c.print(d["value"]))
1501
+
1502
+
1503
+ @config_app.command("set-metadata", rich_help_panel="Metadata")
1504
+ def config_set_metadata(
1505
+ ctx: typer.Context,
1506
+ project: str = typer.Option(..., "--project", help="Project alias"),
1507
+ component_id: str = typer.Option(..., "--component-id", help="Component ID"),
1508
+ config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
1509
+ key: str = typer.Option(..., "--key", help="Metadata key to set"),
1510
+ value: str = typer.Option(..., "--value", help="Metadata value (string)"),
1511
+ branch: int | None = typer.Option(
1512
+ None, "--branch", help="Dev branch ID (defaults to active branch)"
1513
+ ),
1514
+ ) -> None:
1515
+ """Set a metadata key/value on a configuration (upsert)."""
1516
+ formatter = get_formatter(ctx)
1517
+ config_store: ConfigStore = ctx.obj["config_store"]
1518
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1519
+ service = get_service(ctx, "config_service")
1520
+ try:
1521
+ result = service.set_config_metadata(
1522
+ alias=project,
1523
+ component_id=component_id,
1524
+ config_id=config_id,
1525
+ key=key,
1526
+ value=value,
1527
+ branch_id=effective_branch,
1528
+ )
1529
+ except ConfigError as exc:
1530
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1531
+ raise typer.Exit(code=5) from None
1532
+ except KeboolaApiError as exc:
1533
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1534
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1535
+ formatter.output(
1536
+ result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}")
1537
+ )
1538
+
1539
+
1540
+ @config_app.command("delete-metadata", rich_help_panel="Metadata")
1541
+ def config_delete_metadata(
1542
+ ctx: typer.Context,
1543
+ project: str = typer.Option(..., "--project", help="Project alias"),
1544
+ component_id: str = typer.Option(..., "--component-id", help="Component ID"),
1545
+ config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
1546
+ metadata_id: int = typer.Option(..., "--metadata-id", help="Numeric ID from metadata-list"),
1547
+ branch: int | None = typer.Option(
1548
+ None, "--branch", help="Dev branch ID (defaults to active branch)"
1549
+ ),
1550
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
1551
+ ) -> None:
1552
+ """Delete a configuration metadata entry by its numeric ID."""
1553
+ formatter = get_formatter(ctx)
1554
+ config_store: ConfigStore = ctx.obj["config_store"]
1555
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1556
+
1557
+ if (
1558
+ not yes
1559
+ and not formatter.json_mode
1560
+ and not typer.confirm(f"Delete metadata ID {metadata_id} from {component_id}/{config_id}?")
1561
+ ):
1562
+ formatter.console.print("Aborted.")
1563
+ raise typer.Exit(code=0)
1564
+
1565
+ service = get_service(ctx, "config_service")
1566
+ try:
1567
+ result = service.delete_config_metadata(
1568
+ alias=project,
1569
+ component_id=component_id,
1570
+ config_id=config_id,
1571
+ metadata_id=metadata_id,
1572
+ branch_id=effective_branch,
1573
+ )
1574
+ except ConfigError as exc:
1575
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1576
+ raise typer.Exit(code=5) from None
1577
+ except KeboolaApiError as exc:
1578
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1579
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1580
+ formatter.output(
1581
+ result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}")
1582
+ )
1583
+
1584
+
1585
+ @config_app.command("set-folder", rich_help_panel="Metadata")
1586
+ def config_set_folder(
1587
+ ctx: typer.Context,
1588
+ project: str = typer.Option(..., "--project", help="Project alias"),
1589
+ component_id: str = typer.Option(..., "--component-id", help="Component ID"),
1590
+ config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
1591
+ name: str = typer.Option(..., "--name", help="Folder name (empty string to clear)"),
1592
+ branch: int | None = typer.Option(
1593
+ None, "--branch", help="Dev branch ID (defaults to active branch)"
1594
+ ),
1595
+ ) -> None:
1596
+ """Set the folder (KBC.configuration.folderName) on a configuration.
1597
+
1598
+ Organises configs into named groups in the Keboola UI.
1599
+ Pass an empty string to remove the folder assignment.
1600
+ """
1601
+ formatter = get_formatter(ctx)
1602
+ config_store: ConfigStore = ctx.obj["config_store"]
1603
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1604
+ service = get_service(ctx, "config_service")
1605
+ try:
1606
+ result = service.set_config_folder(
1607
+ alias=project,
1608
+ component_id=component_id,
1609
+ config_id=config_id,
1610
+ folder_name=name,
1611
+ branch_id=effective_branch,
1612
+ )
1613
+ except ConfigError as exc:
1614
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1615
+ raise typer.Exit(code=5) from None
1616
+ except KeboolaApiError as exc:
1617
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1618
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1619
+ formatter.output(
1620
+ result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}")
1621
+ )
1622
+
1623
+
1624
+ def _parse_kv_var(raw: str) -> tuple[str, str]:
1625
+ """Split a ``KEY=VALUE`` token into ``(key, value)``; ``#``-prefix preserved."""
1626
+ if "=" not in raw:
1627
+ raise typer.BadParameter(
1628
+ f"Invalid --var: '{raw}'. Expected KEY=VALUE (use # prefix for secrets)."
1629
+ )
1630
+ key, _, value = raw.partition("=")
1631
+ key = key.strip()
1632
+ if not key:
1633
+ raise typer.BadParameter(f"Invalid --var: '{raw}'. Empty key.")
1634
+ return key, value
1635
+
1636
+
1637
+ @config_app.command("variables-set", rich_help_panel="Variables")
1638
+ def config_variables_set(
1639
+ ctx: typer.Context,
1640
+ project: str = typer.Option(..., "--project", help="Project alias"),
1641
+ component_id: str = typer.Option(
1642
+ ..., "--component-id", help="Component ID of the config to attach variables to"
1643
+ ),
1644
+ config_id: str = typer.Option(
1645
+ ..., "--config-id", help="Configuration ID to attach variables to"
1646
+ ),
1647
+ variable: list[str] | None = typer.Option(
1648
+ None,
1649
+ "--var",
1650
+ help="Variable as KEY=VALUE (repeatable). Prefix key with # to mark as a secret (auto-encrypted).",
1651
+ ),
1652
+ replace: bool = typer.Option(
1653
+ False,
1654
+ "--replace",
1655
+ help="Replace ALL variable values instead of merging (drops any keys not in --var).",
1656
+ ),
1657
+ variables_id: str | None = typer.Option(
1658
+ None,
1659
+ "--variables-id",
1660
+ help="Attach parent to an existing keboola.variables config (skips auto-create).",
1661
+ ),
1662
+ values_id: str | None = typer.Option(
1663
+ None,
1664
+ "--values-id",
1665
+ help="Attach to a specific values row (defaults to the first row).",
1666
+ ),
1667
+ branch: int | None = typer.Option(None, "--branch", help="Development branch ID (per-project)"),
1668
+ dry_run: bool = typer.Option(
1669
+ False, "--dry-run", help="Preview the change without writing to Keboola."
1670
+ ),
1671
+ allow_plaintext: bool = typer.Option(
1672
+ False,
1673
+ "--allow-plaintext-on-encrypt-failure",
1674
+ help="Fall back to plaintext if encryption fails (NOT recommended).",
1675
+ ),
1676
+ ) -> None:
1677
+ """Assign variables to a config (auto-creates backing keboola.variables on first call).
1678
+
1679
+ Variables are presented as a flat KEY=VALUE dict. The implementation detail
1680
+ that Keboola stores them as a separate keboola.variables configuration with
1681
+ rows is hidden: first call creates the sibling config named
1682
+ <parent-name>-vars + default row; subsequent calls update the same row.
1683
+ """
1684
+ formatter = get_formatter(ctx)
1685
+ config_store: ConfigStore = ctx.obj["config_store"]
1686
+
1687
+ raw_vars = variable or []
1688
+ if not raw_vars:
1689
+ formatter.error(
1690
+ message="At least one --var KEY=VALUE is required.",
1691
+ error_code=ErrorCode.INVALID_ARGUMENT,
1692
+ )
1693
+ raise typer.Exit(code=2)
1694
+
1695
+ variables_dict: dict[str, str] = {}
1696
+ for raw in raw_vars:
1697
+ try:
1698
+ key, value = _parse_kv_var(raw)
1699
+ except typer.BadParameter as exc:
1700
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
1701
+ raise typer.Exit(code=2) from None
1702
+ variables_dict[key] = value
1703
+
1704
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1705
+
1706
+ service = get_service(ctx, "variables_service")
1707
+
1708
+ if dry_run:
1709
+ try:
1710
+ current = service.get_variables(
1711
+ alias=project,
1712
+ component_id=component_id,
1713
+ config_id=config_id,
1714
+ branch_id=effective_branch,
1715
+ )
1716
+ except KeboolaApiError as exc:
1717
+ formatter.error(
1718
+ message=exc.message,
1719
+ error_code=exc.error_code,
1720
+ retryable=exc.retryable,
1721
+ )
1722
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1723
+ except ConfigError as exc:
1724
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1725
+ raise typer.Exit(code=5) from None
1726
+
1727
+ preview_values = (
1728
+ dict(variables_dict) if replace else {**current["values"], **variables_dict}
1729
+ )
1730
+ result = {
1731
+ "dry_run": True,
1732
+ "project_alias": project,
1733
+ "parent_component_id": component_id,
1734
+ "parent_config_id": config_id,
1735
+ "was_linked": current["linked"],
1736
+ "current_variables_id": current["variables_id"],
1737
+ "current_values": current["values"],
1738
+ "would_write": preview_values,
1739
+ "action": "would_create" if not current["linked"] else "would_update",
1740
+ }
1741
+ if formatter.json_mode:
1742
+ formatter.output(result)
1743
+ else:
1744
+ _format_variables_dry_run(formatter, result)
1745
+ return
1746
+
1747
+ try:
1748
+ result = service.set_variables(
1749
+ alias=project,
1750
+ component_id=component_id,
1751
+ config_id=config_id,
1752
+ variables=variables_dict,
1753
+ replace=replace,
1754
+ variables_id=variables_id,
1755
+ values_id=values_id,
1756
+ branch_id=effective_branch,
1757
+ allow_plaintext_fallback=allow_plaintext,
1758
+ )
1759
+ except KeboolaApiError as exc:
1760
+ formatter.error(
1761
+ message=exc.message,
1762
+ error_code=exc.error_code,
1763
+ retryable=exc.retryable,
1764
+ )
1765
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1766
+ except ConfigError as exc:
1767
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1768
+ raise typer.Exit(code=5) from None
1769
+
1770
+ if formatter.json_mode:
1771
+ formatter.output(result)
1772
+ else:
1773
+ _format_variables_set(formatter, result)
1774
+
1775
+
1776
+ @config_app.command("variables-get", rich_help_panel="Variables")
1777
+ def config_variables_get(
1778
+ ctx: typer.Context,
1779
+ project: str = typer.Option(..., "--project", help="Project alias"),
1780
+ component_id: str = typer.Option(
1781
+ ..., "--component-id", help="Component ID of the config whose variables to read"
1782
+ ),
1783
+ config_id: str = typer.Option(
1784
+ ..., "--config-id", help="Configuration ID whose variables to read"
1785
+ ),
1786
+ branch: int | None = typer.Option(None, "--branch", help="Development branch ID (per-project)"),
1787
+ ) -> None:
1788
+ """Read the current variable values attached to a config."""
1789
+ formatter = get_formatter(ctx)
1790
+ config_store: ConfigStore = ctx.obj["config_store"]
1791
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1792
+
1793
+ service = get_service(ctx, "variables_service")
1794
+ try:
1795
+ result = service.get_variables(
1796
+ alias=project,
1797
+ component_id=component_id,
1798
+ config_id=config_id,
1799
+ branch_id=effective_branch,
1800
+ )
1801
+ except KeboolaApiError as exc:
1802
+ formatter.error(
1803
+ message=exc.message,
1804
+ error_code=exc.error_code,
1805
+ retryable=exc.retryable,
1806
+ )
1807
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1808
+ except ConfigError as exc:
1809
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1810
+ raise typer.Exit(code=5) from None
1811
+
1812
+ if formatter.json_mode:
1813
+ formatter.output(result)
1814
+ else:
1815
+ _format_variables_get(formatter, result)
1816
+
1817
+
1818
+ @config_app.command("variables-clear", rich_help_panel="Variables")
1819
+ def config_variables_clear(
1820
+ ctx: typer.Context,
1821
+ project: str = typer.Option(..., "--project", help="Project alias"),
1822
+ component_id: str = typer.Option(
1823
+ ..., "--component-id", help="Component ID of the config to unlink"
1824
+ ),
1825
+ config_id: str = typer.Option(
1826
+ ..., "--config-id", help="Configuration ID to unlink variables from"
1827
+ ),
1828
+ branch: int | None = typer.Option(None, "--branch", help="Development branch ID (per-project)"),
1829
+ yes: bool = typer.Option(
1830
+ False,
1831
+ "--yes",
1832
+ "-y",
1833
+ help="Skip confirmation prompt.",
1834
+ ),
1835
+ ) -> None:
1836
+ """Unlink variables from a config (does NOT delete the underlying keboola.variables)."""
1837
+ formatter = get_formatter(ctx)
1838
+ config_store: ConfigStore = ctx.obj["config_store"]
1839
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1840
+
1841
+ if not yes and not formatter.json_mode:
1842
+ confirmed = typer.confirm(
1843
+ f"Unlink variables from {component_id}/{config_id}? "
1844
+ "(The underlying variables config will NOT be deleted.)"
1845
+ )
1846
+ if not confirmed:
1847
+ formatter.console.print("[yellow]Aborted.[/yellow]")
1848
+ raise typer.Exit(code=0)
1849
+
1850
+ service = get_service(ctx, "variables_service")
1851
+ try:
1852
+ result = service.clear_variables(
1853
+ alias=project,
1854
+ component_id=component_id,
1855
+ config_id=config_id,
1856
+ branch_id=effective_branch,
1857
+ )
1858
+ except KeboolaApiError as exc:
1859
+ formatter.error(
1860
+ message=exc.message,
1861
+ error_code=exc.error_code,
1862
+ retryable=exc.retryable,
1863
+ )
1864
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1865
+ except ConfigError as exc:
1866
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1867
+ raise typer.Exit(code=5) from None
1868
+
1869
+ if formatter.json_mode:
1870
+ formatter.output(result)
1871
+ else:
1872
+ _format_variables_clear(formatter, result)
1873
+
1874
+
1875
+ def _format_variables_get(formatter: Any, result: dict) -> None:
1876
+ if not result.get("linked"):
1877
+ formatter.console.print(
1878
+ "[yellow]No variables linked[/yellow] to "
1879
+ f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
1880
+ f"[cyan]{escape(result['parent_config_id'])}[/cyan]."
1881
+ )
1882
+ return
1883
+
1884
+ formatter.console.print(
1885
+ f"[bold]Variables on[/bold] "
1886
+ f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
1887
+ f"[cyan]{escape(result['parent_config_id'])}[/cyan] "
1888
+ f"[dim](variables_id={escape(result['variables_id'] or '')}, "
1889
+ f"values_id={escape(result['values_id'] or '')})[/dim]"
1890
+ )
1891
+ if not result["values"]:
1892
+ formatter.console.print(" [dim](no values set)[/dim]")
1893
+ return
1894
+ for k in sorted(result["values"]):
1895
+ v = result["values"][k]
1896
+ display_v = "<encrypted>" if k.startswith("#") else escape(str(v))
1897
+ formatter.console.print(f" [green]{escape(k)}[/green] = {display_v}")
1898
+
1899
+
1900
+ def _format_variables_set(formatter: Any, result: dict) -> None:
1901
+ action_label = (
1902
+ "[green]created[/green]" if result["action"] == "created" else "[yellow]updated[/yellow]"
1903
+ )
1904
+ formatter.console.print(
1905
+ f"Variables {action_label} on "
1906
+ f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
1907
+ f"[cyan]{escape(result['parent_config_id'])}[/cyan]"
1908
+ )
1909
+ formatter.console.print(f" variables_id: [cyan]{escape(result['variables_id'])}[/cyan]")
1910
+ formatter.console.print(f" values_id: [cyan]{escape(result['values_id'])}[/cyan]")
1911
+ if result.get("encrypted_keys"):
1912
+ joined = ", ".join(escape(k) for k in result["encrypted_keys"])
1913
+ formatter.console.print(f" [dim]encrypted: {joined}[/dim]")
1914
+ formatter.console.print("[bold]Final values:[/bold]")
1915
+ for k in sorted(result["values"]):
1916
+ v = result["values"][k]
1917
+ display_v = "<encrypted>" if k.startswith("#") else escape(str(v))
1918
+ formatter.console.print(f" [green]{escape(k)}[/green] = {display_v}")
1919
+
1920
+
1921
+ def _format_variables_clear(formatter: Any, result: dict) -> None:
1922
+ if not result["was_linked"]:
1923
+ formatter.console.print(
1924
+ f"[yellow]Nothing to clear[/yellow]: "
1925
+ f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
1926
+ f"[cyan]{escape(result['parent_config_id'])}[/cyan] had no variables linked."
1927
+ )
1928
+ return
1929
+ formatter.console.print(
1930
+ f"[green]Unlinked[/green] variables from "
1931
+ f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
1932
+ f"[cyan]{escape(result['parent_config_id'])}[/cyan] "
1933
+ f"[dim](was variables_id={escape(result['unlinked_variables_id'] or '')}, "
1934
+ f"values_id={escape(result['unlinked_values_id'] or '')})[/dim]"
1935
+ )
1936
+ formatter.console.print(
1937
+ "[dim]The underlying keboola.variables config was NOT deleted. "
1938
+ "Use 'kbagent config delete' to remove it if no other config references it.[/dim]"
1939
+ )
1940
+
1941
+
1942
+ def _format_variables_dry_run(formatter: Any, result: dict) -> None:
1943
+ verb = "create" if result["action"] == "would_create" else "update"
1944
+ formatter.console.print(
1945
+ f"[yellow]DRY RUN[/yellow]: would {verb} variables on "
1946
+ f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
1947
+ f"[cyan]{escape(result['parent_config_id'])}[/cyan]"
1948
+ )
1949
+ if result["was_linked"]:
1950
+ formatter.console.print(
1951
+ f" current variables_id: [cyan]{escape(result['current_variables_id'] or '')}[/cyan]"
1952
+ )
1953
+ else:
1954
+ formatter.console.print(" [dim]no variables currently linked[/dim]")
1955
+
1956
+ current_keys = set(result["current_values"])
1957
+ proposed_keys = set(result["would_write"])
1958
+ for k in sorted(current_keys | proposed_keys):
1959
+ current_v = result["current_values"].get(k)
1960
+ proposed_v = result["would_write"].get(k)
1961
+ if k not in proposed_keys:
1962
+ display = "<encrypted>" if k.startswith("#") else escape(str(current_v))
1963
+ formatter.console.print(f" [red]- {escape(k)}[/red] = {display} [dim](dropped)[/dim]")
1964
+ elif k not in current_keys:
1965
+ display = "<encrypted>" if k.startswith("#") else escape(str(proposed_v))
1966
+ formatter.console.print(f" [green]+ {escape(k)}[/green] = {display}")
1967
+ elif current_v != proposed_v:
1968
+ display_cur = "<encrypted>" if k.startswith("#") else escape(str(current_v))
1969
+ display_new = "<encrypted>" if k.startswith("#") else escape(str(proposed_v))
1970
+ formatter.console.print(
1971
+ f" [yellow]~ {escape(k)}[/yellow] = {display_cur} -> {display_new}"
1972
+ )
1973
+ else:
1974
+ display = "<encrypted>" if k.startswith("#") else escape(str(current_v))
1975
+ formatter.console.print(f" [dim]= {escape(k)} = {display}[/dim]")
1976
+
1977
+
1978
+ # ── config row-create ──────────────────────────────────────────────────────────
1979
+
1980
+
1981
+ @config_app.command("row-create", rich_help_panel="Rows")
1982
+ def config_row_create(
1983
+ ctx: typer.Context,
1984
+ project: str = typer.Option(
1985
+ ...,
1986
+ "--project",
1987
+ help="Project alias",
1988
+ ),
1989
+ component_id: str = typer.Option(
1990
+ ...,
1991
+ "--component-id",
1992
+ help="Component ID (e.g. keboola.python-transformation-v2)",
1993
+ ),
1994
+ config_id: str = typer.Option(
1995
+ ...,
1996
+ "--config-id",
1997
+ help="Configuration ID to add the row to",
1998
+ ),
1999
+ name: str = typer.Option(
2000
+ ...,
2001
+ "--name",
2002
+ help="Row name",
2003
+ ),
2004
+ description: str = typer.Option(
2005
+ "",
2006
+ "--description",
2007
+ help="Row description",
2008
+ ),
2009
+ configuration: str | None = typer.Option(
2010
+ None,
2011
+ "--configuration",
2012
+ help="Row configuration JSON: inline, @file.json, or - for stdin",
2013
+ ),
2014
+ is_disabled: bool = typer.Option(
2015
+ False,
2016
+ "--is-disabled",
2017
+ help="Create the row in disabled state (excluded from job runs)",
2018
+ ),
2019
+ branch: int | None = typer.Option(
2020
+ None,
2021
+ "--branch",
2022
+ help="Create in a specific dev branch ID (defaults to active branch)",
2023
+ ),
2024
+ allow_plaintext: bool = typer.Option(
2025
+ False,
2026
+ "--allow-plaintext-on-encrypt-failure",
2027
+ help="Allow write even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
2028
+ ),
2029
+ ) -> None:
2030
+ """Create a new configuration row.
2031
+
2032
+ \b
2033
+ Examples:
2034
+ # Create a row with a name only (empty configuration)
2035
+ kbagent config row-create --project P --component-id C --config-id ID --name "Row 1"
2036
+
2037
+ # Create a row with configuration content
2038
+ kbagent config row-create --project P --component-id C --config-id ID \\
2039
+ --name "Row 1" --configuration '{"parameters": {"table": "orders"}}'
2040
+
2041
+ # Create from a JSON file
2042
+ kbagent config row-create --project P --component-id C --config-id ID \\
2043
+ --name "Row 1" --configuration @row.json
2044
+
2045
+ # Create a disabled row
2046
+ kbagent config row-create --project P --component-id C --config-id ID \\
2047
+ --name "Row 1" --is-disabled
2048
+ """
2049
+ formatter = get_formatter(ctx)
2050
+ service = get_service(ctx, "config_service")
2051
+
2052
+ config_dict: dict | None = None
2053
+ if configuration:
2054
+ try:
2055
+ config_dict = _parse_json_input(configuration)
2056
+ except (json.JSONDecodeError, FileNotFoundError) as exc:
2057
+ formatter.error(
2058
+ message=f"Invalid --configuration input: {exc}",
2059
+ error_code=ErrorCode.VALIDATION_ERROR,
2060
+ )
2061
+ raise typer.Exit(code=2) from None
2062
+
2063
+ try:
2064
+ result = service.create_config_row(
2065
+ alias=project,
2066
+ component_id=component_id,
2067
+ config_id=config_id,
2068
+ name=name,
2069
+ description=description,
2070
+ configuration=config_dict,
2071
+ is_disabled=is_disabled,
2072
+ branch_id=branch,
2073
+ allow_plaintext_fallback=allow_plaintext,
2074
+ )
2075
+ except ConfigError as exc:
2076
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2077
+ raise typer.Exit(code=5) from None
2078
+ except KeboolaApiError as exc:
2079
+ formatter.error(
2080
+ message=exc.message,
2081
+ error_code=exc.error_code,
2082
+ retryable=exc.retryable,
2083
+ )
2084
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2085
+
2086
+ if formatter.json_mode:
2087
+ formatter.output(result)
2088
+ else:
2089
+ row_name = result.get("name", name)
2090
+ row_id = result.get("id", "")
2091
+ branch_info = ""
2092
+ if result.get("branch_id"):
2093
+ branch_info = f" (branch {result['branch_id']})"
2094
+ formatter.success(
2095
+ f"Created row '{escape(row_name)}' [{row_id}] "
2096
+ f"in {escape(component_id)}/{escape(config_id)}{branch_info}"
2097
+ )
2098
+
2099
+
2100
+ # ── config row-update ──────────────────────────────────────────────────────────
2101
+
2102
+
2103
+ @config_app.command("row-update", rich_help_panel="Rows")
2104
+ def config_row_update(
2105
+ ctx: typer.Context,
2106
+ project: str = typer.Option(
2107
+ ...,
2108
+ "--project",
2109
+ help="Project alias",
2110
+ ),
2111
+ component_id: str = typer.Option(
2112
+ ...,
2113
+ "--component-id",
2114
+ help="Component ID",
2115
+ ),
2116
+ config_id: str = typer.Option(
2117
+ ...,
2118
+ "--config-id",
2119
+ help="Configuration ID",
2120
+ ),
2121
+ row_id: str = typer.Option(
2122
+ ...,
2123
+ "--row-id",
2124
+ help="Row ID to update",
2125
+ ),
2126
+ name: str | None = typer.Option(
2127
+ None,
2128
+ "--name",
2129
+ help="New row name",
2130
+ ),
2131
+ description: str | None = typer.Option(
2132
+ None,
2133
+ "--description",
2134
+ help="New row description",
2135
+ ),
2136
+ configuration: str | None = typer.Option(
2137
+ None,
2138
+ "--configuration",
2139
+ help="Row configuration JSON: inline, @file.json, or - for stdin",
2140
+ ),
2141
+ set_values: list[str] | None = typer.Option(
2142
+ None,
2143
+ "--set",
2144
+ help="Set a nested value: PATH=VALUE (e.g. --set 'parameters.table=orders')",
2145
+ ),
2146
+ merge: bool = typer.Option(
2147
+ False,
2148
+ "--merge",
2149
+ help="Deep-merge into existing row config instead of replacing",
2150
+ ),
2151
+ dry_run: bool = typer.Option(
2152
+ False,
2153
+ "--dry-run",
2154
+ help="Show what would change without applying",
2155
+ ),
2156
+ is_disabled: bool = typer.Option(
2157
+ False,
2158
+ "--is-disabled",
2159
+ help="Disable the row (mutually exclusive with --is-enabled)",
2160
+ ),
2161
+ is_enabled: bool = typer.Option(
2162
+ False,
2163
+ "--is-enabled",
2164
+ help="Enable the row (mutually exclusive with --is-disabled)",
2165
+ ),
2166
+ branch: int | None = typer.Option(
2167
+ None,
2168
+ "--branch",
2169
+ help="Update in a specific dev branch ID (defaults to active branch)",
2170
+ ),
2171
+ allow_plaintext: bool = typer.Option(
2172
+ False,
2173
+ "--allow-plaintext-on-encrypt-failure",
2174
+ help="Allow write even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
2175
+ ),
2176
+ ) -> None:
2177
+ """Update an existing configuration row.
2178
+
2179
+ \b
2180
+ Content options modify the row configuration JSON:
2181
+ --configuration : provide a full JSON blob (inline, @file, or -)
2182
+ --set PATH=VALUE : set a single nested key (repeatable)
2183
+ --merge : deep-merge into existing row config (preserves sibling keys)
2184
+ --dry-run : preview changes without applying
2185
+
2186
+ \b
2187
+ Examples:
2188
+ # Update just the name
2189
+ kbagent config row-update --project P --component-id C --config-id ID --row-id R --name "New name"
2190
+
2191
+ # Replace row configuration from a file
2192
+ kbagent config row-update --project P --component-id C --config-id ID --row-id R \\
2193
+ --configuration @row.json
2194
+
2195
+ # Set a single nested value (merge implied)
2196
+ kbagent config row-update --project P --component-id C --config-id ID --row-id R \\
2197
+ --set 'parameters.table=new_table'
2198
+
2199
+ # Preview changes without applying
2200
+ kbagent config row-update --project P --component-id C --config-id ID --row-id R \\
2201
+ --set 'parameters.table=new_table' --dry-run
2202
+
2203
+ # Disable a row (excludes it from job runs)
2204
+ kbagent config row-update --project P --component-id C --config-id ID --row-id R --is-disabled
2205
+ """
2206
+ if is_disabled and is_enabled:
2207
+ formatter = get_formatter(ctx)
2208
+ formatter.error(
2209
+ message="--is-disabled and --is-enabled are mutually exclusive.",
2210
+ error_code=ErrorCode.VALIDATION_ERROR,
2211
+ )
2212
+ raise typer.Exit(code=2) from None
2213
+
2214
+ is_disabled_value: bool | None = None
2215
+ if is_disabled:
2216
+ is_disabled_value = True
2217
+ elif is_enabled:
2218
+ is_disabled_value = False
2219
+
2220
+ formatter = get_formatter(ctx)
2221
+ service = get_service(ctx, "config_service")
2222
+
2223
+ config_dict: dict | None = None
2224
+ if configuration:
2225
+ try:
2226
+ config_dict = _parse_json_input(configuration)
2227
+ except (json.JSONDecodeError, FileNotFoundError) as exc:
2228
+ formatter.error(
2229
+ message=f"Invalid --configuration input: {exc}",
2230
+ error_code=ErrorCode.VALIDATION_ERROR,
2231
+ )
2232
+ raise typer.Exit(code=2) from None
2233
+
2234
+ parsed_sets: list[tuple[str, object]] | None = None
2235
+ if set_values:
2236
+ parsed_sets = []
2237
+ for item in set_values:
2238
+ if "=" not in item:
2239
+ formatter.error(
2240
+ message=f"Invalid --set format: '{item}'. Expected PATH=VALUE.",
2241
+ error_code=ErrorCode.VALIDATION_ERROR,
2242
+ )
2243
+ raise typer.Exit(code=2) from None
2244
+ path, _, raw_value = item.partition("=")
2245
+ parsed_sets.append((path.strip(), _parse_set_value(raw_value.strip())))
2246
+
2247
+ effective_merge = merge or bool(parsed_sets)
2248
+
2249
+ try:
2250
+ result = service.update_config_row(
2251
+ alias=project,
2252
+ component_id=component_id,
2253
+ config_id=config_id,
2254
+ row_id=row_id,
2255
+ name=name,
2256
+ description=description,
2257
+ configuration=config_dict,
2258
+ set_paths=parsed_sets,
2259
+ merge=effective_merge,
2260
+ dry_run=dry_run,
2261
+ is_disabled=is_disabled_value,
2262
+ branch_id=branch,
2263
+ allow_plaintext_fallback=allow_plaintext,
2264
+ )
2265
+ except ConfigError as exc:
2266
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2267
+ raise typer.Exit(code=5) from None
2268
+ except KeboolaApiError as exc:
2269
+ formatter.error(
2270
+ message=exc.message,
2271
+ error_code=exc.error_code,
2272
+ retryable=exc.retryable,
2273
+ )
2274
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2275
+
2276
+ if result.get("dry_run"):
2277
+ changes = result.get("changes", [])
2278
+ if formatter.json_mode:
2279
+ formatter.output(result)
2280
+ else:
2281
+ if not changes:
2282
+ formatter.success("No changes detected.")
2283
+ else:
2284
+ formatter.console.print(f"\n[bold]Dry-run: {len(changes)} change(s)[/bold]\n")
2285
+ for change in changes:
2286
+ formatter.console.print(f" {change}")
2287
+ formatter.console.print()
2288
+ return
2289
+
2290
+ if formatter.json_mode:
2291
+ formatter.output(result)
2292
+ else:
2293
+ updated_name = result.get("name", row_id)
2294
+ branch_info = ""
2295
+ if result.get("branch_id"):
2296
+ branch_info = f" (branch {result['branch_id']})"
2297
+ formatter.success(
2298
+ f"Updated row '{escape(updated_name)}' [{row_id}] "
2299
+ f"in {escape(component_id)}/{escape(config_id)}{branch_info}"
2300
+ )
2301
+
2302
+
2303
+ # ── config row-delete ──────────────────────────────────────────────────────────
2304
+
2305
+
2306
+ @config_app.command("row-delete", rich_help_panel="Rows")
2307
+ def config_row_delete(
2308
+ ctx: typer.Context,
2309
+ project: str = typer.Option(..., "--project", help="Project alias"),
2310
+ component_id: str = typer.Option(..., "--component-id", help="Component ID"),
2311
+ config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
2312
+ row_id: str = typer.Option(..., "--row-id", help="Row ID to delete"),
2313
+ branch: int | None = typer.Option(
2314
+ None, "--branch", help="Delete from a specific dev branch ID (defaults to active branch)"
2315
+ ),
2316
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
2317
+ ) -> None:
2318
+ """Delete a configuration row.
2319
+
2320
+ \b
2321
+ Examples:
2322
+ kbagent config row-delete --project P --component-id C --config-id ID --row-id ROW
2323
+ kbagent config row-delete --project P --component-id C --config-id ID --row-id ROW --yes
2324
+ """
2325
+ formatter = get_formatter(ctx)
2326
+
2327
+ if (
2328
+ not yes
2329
+ and not formatter.json_mode
2330
+ and not typer.confirm(f"Delete row [{row_id}] from {component_id}/{config_id}?")
2331
+ ):
2332
+ formatter.console.print("Aborted.")
2333
+ raise typer.Exit(code=0)
2334
+
2335
+ service = get_service(ctx, "config_service")
2336
+
2337
+ try:
2338
+ result = service.delete_config_row(
2339
+ alias=project,
2340
+ component_id=component_id,
2341
+ config_id=config_id,
2342
+ row_id=row_id,
2343
+ branch_id=branch,
2344
+ )
2345
+ except ConfigError as exc:
2346
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2347
+ raise typer.Exit(code=5) from None
2348
+ except KeboolaApiError as exc:
2349
+ formatter.error(
2350
+ message=exc.message,
2351
+ error_code=exc.error_code,
2352
+ retryable=exc.retryable,
2353
+ )
2354
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2355
+
2356
+ if formatter.json_mode:
2357
+ formatter.output(result)
2358
+ else:
2359
+ branch_info = ""
2360
+ if result.get("branch_id"):
2361
+ branch_info = f" (branch {result['branch_id']})"
2362
+ formatter.success(
2363
+ f"Deleted row [{row_id}] from {escape(component_id)}/{escape(config_id)}{branch_info}"
2364
+ )
2365
+
2366
+
2367
+ # ── config oauth-url ───────────────────────────────────────────────────────────
2368
+
2369
+
2370
+ @config_app.command(
2371
+ "oauth-url",
2372
+ rich_help_panel="OAuth",
2373
+ help=(
2374
+ "Requires master token. Generate an OAuth authorization URL for a component configuration."
2375
+ ),
2376
+ )
2377
+ def config_oauth_url(
2378
+ ctx: typer.Context,
2379
+ project: str = typer.Option(
2380
+ ...,
2381
+ "--project",
2382
+ help="Project alias",
2383
+ ),
2384
+ component_id: str = typer.Option(
2385
+ ...,
2386
+ "--component-id",
2387
+ help="Component ID (e.g. keboola.ex-google-drive)",
2388
+ ),
2389
+ config_id: str = typer.Option(
2390
+ ...,
2391
+ "--config-id",
2392
+ help="Configuration ID to authorize",
2393
+ ),
2394
+ redirect_url: str | None = typer.Option(
2395
+ None,
2396
+ "--redirect-url",
2397
+ help="Optional URL to return to after the OAuth flow completes (sets returnUrl query param)",
2398
+ ),
2399
+ ) -> None:
2400
+ """Generate an OAuth authorization URL for a component configuration.
2401
+
2402
+ Opens a short-lived, component-scoped authorization link.
2403
+ The user must open this URL in a browser and grant access.
2404
+
2405
+ \b
2406
+ Examples:
2407
+ kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID
2408
+
2409
+ # Redirect back to a custom URL after the OAuth flow completes
2410
+ kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID \\
2411
+ --redirect-url https://example.com/oauth-done
2412
+ """
2413
+ formatter = get_formatter(ctx)
2414
+ service = get_service(ctx, "config_service")
2415
+
2416
+ try:
2417
+ result = service.get_oauth_url(
2418
+ alias=project,
2419
+ component_id=component_id,
2420
+ config_id=config_id,
2421
+ redirect_url=redirect_url,
2422
+ )
2423
+ except ConfigError as exc:
2424
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2425
+ raise typer.Exit(code=5) from None
2426
+ except KeboolaApiError as exc:
2427
+ formatter.error(
2428
+ message=exc.message,
2429
+ error_code=exc.error_code,
2430
+ retryable=exc.retryable,
2431
+ )
2432
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2433
+
2434
+ if formatter.json_mode:
2435
+ formatter.output(result)
2436
+ else:
2437
+ formatter.console.print(
2438
+ f"[bold]OAuth URL for[/bold] [cyan]{escape(component_id)}[/cyan]/"
2439
+ f"[cyan]{escape(config_id)}[/cyan]:\n"
2440
+ )
2441
+ formatter.console.print(f" [link]{result['url']}[/link]")
2442
+ formatter.console.print("\n[dim]Open this URL in a browser and grant access.[/dim]")