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,947 @@
1
+ """Project management service - business logic for add/remove/edit/list/status.
2
+
3
+ Orchestrates config persistence and API calls without knowing about CLI or HTTP details.
4
+ """
5
+
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..constants import ENV_KBAGENT_PROJECT
16
+ from ..errors import ConfigError, KeboolaApiError, mask_token
17
+ from ..models import ProjectConfig, normalize_stack_url
18
+ from .base import BaseService
19
+
20
+ # Filesystem-safe slug constraint for ``--new-alias``. Aliases land on disk
21
+ # as the nested-sync directory name (``<cwd>/<alias>/``), so they must not
22
+ # contain path separators, NUL bytes, or anything outside the strict slug
23
+ # alphabet. ``..`` is rejected separately to catch ``foo..bar`` cases the
24
+ # regex would otherwise accept (dot is a legal slug character on its own).
25
+ # This is intentionally stricter than ``project add`` accepts today --
26
+ # rename's filesystem interaction is the new pressure point.
27
+ _ALIAS_FORMAT_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.\-]*")
28
+
29
+
30
+ class ProjectService(BaseService):
31
+ """Business logic for managing Keboola project connections.
32
+
33
+ Uses dependency injection for config_store and client_factory.
34
+ """
35
+
36
+ def add_project(self, alias: str, stack_url: str, token: str) -> dict[str, Any]:
37
+ """Add a new project connection after verifying the token.
38
+
39
+ Calls the Keboola API to verify the token and extract project info,
40
+ then saves the project to the config store.
41
+
42
+ Args:
43
+ alias: Human-friendly project name.
44
+ stack_url: Keboola stack URL.
45
+ token: Storage API token.
46
+
47
+ Returns:
48
+ Dict with project details (alias, project_name, project_id, stack_url, masked_token).
49
+
50
+ Raises:
51
+ KeboolaApiError: If token verification fails.
52
+ ConfigError: If the alias already exists.
53
+ """
54
+ # Accept a bare host or a full project deep-link, not just a clean base
55
+ # URL -- normalize before we build the verification client so the token
56
+ # check hits the right host (and the stored value is the clean base).
57
+ stack_url = normalize_stack_url(stack_url)
58
+ client = self._client_factory(stack_url, token)
59
+ try:
60
+ token_info = client.verify_token()
61
+ finally:
62
+ client.close()
63
+
64
+ project = ProjectConfig(
65
+ stack_url=stack_url,
66
+ token=token,
67
+ project_name=token_info.project_name,
68
+ project_id=token_info.project_id,
69
+ org_id=token_info.org_id,
70
+ org_name=token_info.org_name,
71
+ )
72
+
73
+ self._config_store.add_project(alias, project)
74
+
75
+ return {
76
+ "alias": alias,
77
+ "project_name": token_info.project_name,
78
+ "project_id": token_info.project_id,
79
+ "stack_url": stack_url,
80
+ "token": mask_token(token),
81
+ "org_id": token_info.org_id,
82
+ "org_name": token_info.org_name,
83
+ }
84
+
85
+ def remove_project(self, alias: str) -> dict[str, str]:
86
+ """Remove a project from the configuration.
87
+
88
+ Args:
89
+ alias: The project alias to remove.
90
+
91
+ Returns:
92
+ Dict confirming the removal.
93
+
94
+ Raises:
95
+ ConfigError: If the alias does not exist.
96
+ """
97
+ self._config_store.remove_project(alias)
98
+ return {"alias": alias, "message": f"Project '{alias}' removed."}
99
+
100
+ def edit_project(
101
+ self,
102
+ alias: str,
103
+ stack_url: str | None = None,
104
+ token: str | None = None,
105
+ new_alias: str | None = None,
106
+ search_root: Path | None = None,
107
+ dry_run: bool = False,
108
+ ) -> dict[str, Any]:
109
+ """Edit an existing project's configuration.
110
+
111
+ If the token is changed, re-verifies it against the API to update
112
+ project name and ID. If ``new_alias`` is provided and differs from
113
+ ``alias``, the rename is applied first (config + nested sync dir);
114
+ any subsequent url/token mutations target the new alias key.
115
+
116
+ Args:
117
+ alias: The project alias to edit.
118
+ stack_url: New stack URL (if changing).
119
+ token: New token (if changing).
120
+ new_alias: Rename target. Skipped when None or equal to ``alias``.
121
+ search_root: Workspace root to scan for a nested sync directory
122
+ ``<search_root>/<alias>/.keboola/manifest.json``. Defaults
123
+ to the current working directory at call time. Tests pass
124
+ an explicit ``tmp_path`` to avoid touching the real fs.
125
+ dry_run: If True, validate everything (existence, format,
126
+ collision) and compute the planned cascade WITHOUT
127
+ mutating any state. Result dict carries ``dry_run: True``
128
+ and a ``planned`` sub-dict describing what would happen.
129
+ Validation errors raise the same ``ConfigError``
130
+ exceptions as the live path. Token re-verification is
131
+ also skipped (no API call).
132
+
133
+ Returns:
134
+ Dict with updated project details. When a rename happened,
135
+ includes ``old_alias`` and ``rename`` keys describing the
136
+ cascade outcome. In dry-run mode the dict has
137
+ ``dry_run: True`` and a ``planned`` sub-dict; ``alias``
138
+ reports the ORIGINAL alias (no mutation occurred).
139
+
140
+ Raises:
141
+ KeboolaApiError: If token re-verification fails (live mode only).
142
+ ConfigError: If the alias does not exist, no changes provided,
143
+ or ``new_alias`` is invalid / collides.
144
+ """
145
+ existing = self._config_store.get_project(alias)
146
+ if existing is None:
147
+ raise ConfigError(f"Project '{alias}' not found.")
148
+
149
+ # ``--new-alias`` matching the current alias is treated as no change
150
+ # (rename-to-same-name idempotency). Combined with no url/token, it
151
+ # is the same surface as "user typed the command but specified
152
+ # nothing" -- raise the same error so the UX is consistent.
153
+ new_alias_is_change = new_alias is not None and new_alias != alias
154
+ if stack_url is None and token is None and not new_alias_is_change:
155
+ raise ConfigError(
156
+ "No changes specified. Provide --url, --token, and/or "
157
+ "--new-alias (matching the current alias is a no-op)."
158
+ )
159
+
160
+ # Normalize a bare host / full project deep-link to the clean base URL
161
+ # up front so the dry-run preview and the real edit agree on the value.
162
+ if stack_url is not None:
163
+ stack_url = normalize_stack_url(stack_url)
164
+
165
+ # ----- dry-run path: validate everything, mutate nothing ----------
166
+ if dry_run:
167
+ planned_rename: dict[str, Any] | None = None
168
+ if new_alias is not None and new_alias != alias:
169
+ planned_rename = self._plan_project_alias_rename(
170
+ old_alias=alias,
171
+ new_alias=new_alias,
172
+ search_root=search_root if search_root is not None else Path.cwd(),
173
+ )
174
+ return {
175
+ "alias": alias,
176
+ "project_name": existing.project_name,
177
+ "project_id": existing.project_id,
178
+ "stack_url": stack_url if stack_url is not None else existing.stack_url,
179
+ "token": mask_token(existing.token),
180
+ "dry_run": True,
181
+ "planned": {
182
+ "old_alias": alias,
183
+ "new_alias": new_alias if new_alias_is_change else None,
184
+ "stack_url_would_change": stack_url is not None
185
+ and stack_url != existing.stack_url,
186
+ "token_would_change": token is not None,
187
+ "rename": planned_rename,
188
+ },
189
+ }
190
+
191
+ rename_result: dict[str, Any] | None = None
192
+ original_alias = alias
193
+ if new_alias is not None and new_alias != alias:
194
+ rename_result = self._rename_project_alias(
195
+ old_alias=alias,
196
+ new_alias=new_alias,
197
+ search_root=search_root if search_root is not None else Path.cwd(),
198
+ )
199
+ alias = new_alias # subsequent url/token updates target the new key
200
+
201
+ updates: dict[str, str | int] = {}
202
+
203
+ if stack_url is not None:
204
+ updates["stack_url"] = stack_url
205
+
206
+ if token is not None:
207
+ effective_url = stack_url if stack_url is not None else existing.stack_url
208
+ client = self._client_factory(effective_url, token)
209
+ try:
210
+ token_info = client.verify_token()
211
+ finally:
212
+ client.close()
213
+ updates["token"] = token
214
+ updates["project_name"] = token_info.project_name
215
+ if token_info.project_id is not None:
216
+ updates["project_id"] = token_info.project_id
217
+
218
+ if updates:
219
+ self._config_store.edit_project(alias, **updates)
220
+
221
+ updated = self._config_store.get_project(alias)
222
+ if updated is None:
223
+ raise ConfigError(
224
+ f"Project '{alias}' could not be retrieved after editing. "
225
+ "Config store may be in an inconsistent state."
226
+ )
227
+
228
+ result: dict[str, Any] = {
229
+ "alias": alias,
230
+ "project_name": updated.project_name,
231
+ "project_id": updated.project_id,
232
+ "stack_url": updated.stack_url,
233
+ "token": mask_token(updated.token),
234
+ }
235
+ if rename_result is not None:
236
+ result["old_alias"] = original_alias
237
+ result["rename"] = rename_result
238
+ return result
239
+
240
+ def _rename_project_alias(
241
+ self,
242
+ *,
243
+ old_alias: str,
244
+ new_alias: str,
245
+ search_root: Path,
246
+ ) -> dict[str, Any]:
247
+ """Rename a project alias across config.json and nested sync dirs.
248
+
249
+ Order of operations and rollback contract (review iter 2 -- bugs
250
+ S1 + S2):
251
+
252
+ 1. Format validation rejects path-traversal characters BEFORE any
253
+ I/O. ``..``, ``/``, ``\\``, NUL, leading ``.`` / ``-``, and
254
+ anything outside ``[A-Za-z0-9_.-]`` are rejected here, so the
255
+ subsequent filesystem path computation cannot escape
256
+ ``search_root``.
257
+ 2. ``search_root`` is resolved once via ``Path.resolve()`` to
258
+ collapse symlinks; the disk rename uses the resolved path
259
+ throughout, closing the door on a malicious symlink at
260
+ ``<cwd>/<new-alias>/`` redirecting the move target.
261
+ 3. Config-side rename (`ConfigStore.rename_project`) commits the
262
+ ``projects`` dict-key swap and the ``default_project`` cascade
263
+ atomically (one ``save()`` call). On collision it raises
264
+ BEFORE any disk op runs.
265
+ 4. The optional disk rename (`_rename_nested_sync_dir`) is wrapped
266
+ in a rollback: any ``OSError`` reverses the config rename via
267
+ a second ``rename_project(new, old)`` call so config and disk
268
+ never end up out of sync. The original exception is re-raised
269
+ wrapped in ``ConfigError`` so the user sees an actionable
270
+ message.
271
+ 5. Lineage cache (`*.lineage.json` files in ``search_root``) is
272
+ NOT rewritten -- the cache embeds ``<alias>:<table_id>`` FQNs,
273
+ may live in a sibling git repo, may be committed to disk; a
274
+ partial rewrite is worse than no rewrite. The scan is
275
+ depth-capped at 2 levels to bound cost when ``search_root`` is
276
+ a deep tree (S4).
277
+
278
+ Project alias rename is purely local -- there is no Keboola API
279
+ counterpart to audit, so unlike ``ConfigService.rename_config``
280
+ this method writes no change-description string.
281
+
282
+ Returns a dict describing the cascade:
283
+ {
284
+ "old_alias": str,
285
+ "new_alias": str,
286
+ "default_project_updated": bool,
287
+ "sync_dir": {"old_path": str, "new_path": str, "method": str} | None,
288
+ "lineage_cache_warning": str | None,
289
+ }
290
+ """
291
+ # 1. Format validation. Rejects path-traversal characters and
292
+ # anything that would make a confusing dict key. Fail-fast,
293
+ # no I/O.
294
+ self._validate_alias_format(new_alias)
295
+
296
+ # 2. Resolve search_root once (collapses symlinks -- defense
297
+ # against a malicious cwd that aliases an unrelated tree).
298
+ try:
299
+ resolved_root = search_root.resolve()
300
+ except (OSError, RuntimeError) as exc: # RuntimeError = symlink loop
301
+ raise ConfigError(f"Cannot resolve search_root '{search_root}': {exc}") from exc
302
+
303
+ # 3. Capture pre-state. config_store.rename_project raises on
304
+ # collision before mutating, so this read is safe.
305
+ pre_config = self._config_store.load()
306
+ default_was_match = pre_config.default_project == old_alias
307
+
308
+ # 4. Atomic config-side rename (collision -> ConfigError, no disk op).
309
+ self._config_store.rename_project(old_alias, new_alias)
310
+
311
+ # 5. Optional disk-side rename, with rollback on OS-level failure.
312
+ try:
313
+ sync_dir_result = self._rename_nested_sync_dir(
314
+ old_alias=old_alias, new_alias=new_alias, search_root=resolved_root
315
+ )
316
+ except OSError as exc:
317
+ # Disk move failed; roll the config rename back so config
318
+ # and disk stay in sync. The rollback's own failure is
319
+ # logged-and-swallowed to surface the original cause.
320
+ import contextlib
321
+
322
+ with contextlib.suppress(ConfigError):
323
+ self._config_store.rename_project(new_alias, old_alias)
324
+ raise ConfigError(
325
+ f"Failed to rename sync directory for '{old_alias}' -> "
326
+ f"'{new_alias}': {exc}. Config rolled back to original alias."
327
+ ) from exc
328
+
329
+ # 6. Lineage cache detection -> warn (no rewrite).
330
+ lineage_warning = self._detect_lineage_cache_warning(resolved_root, old_alias)
331
+
332
+ result: dict[str, Any] = {
333
+ "old_alias": old_alias,
334
+ "new_alias": new_alias,
335
+ "default_project_updated": default_was_match,
336
+ "sync_dir": sync_dir_result,
337
+ "lineage_cache_warning": lineage_warning,
338
+ }
339
+ if lineage_warning is not None:
340
+ sys.stderr.write(lineage_warning + "\n")
341
+ return result
342
+
343
+ @staticmethod
344
+ def _validate_alias_format(new_alias: str) -> None:
345
+ """Reject filesystem-unsafe ``--new-alias`` values.
346
+
347
+ Stricter than the no-op check ``project add`` performs today
348
+ (none) because rename uses ``new_alias`` as a directory name.
349
+ Forbidden inputs:
350
+
351
+ - empty / whitespace-only;
352
+ - any whitespace anywhere;
353
+ - the substring ``..`` (path-traversal in any position);
354
+ - characters outside ``[A-Za-z0-9_.-]`` (catches ``/``, ``\\``,
355
+ NUL, control chars, Unicode letters);
356
+ - leading ``.`` or ``-`` (would surprise CLI parsing or hide as
357
+ a dotfile).
358
+ """
359
+ if not new_alias or not new_alias.strip():
360
+ raise ConfigError("Invalid --new-alias: must not be empty or whitespace-only.")
361
+ if any(ch.isspace() for ch in new_alias):
362
+ raise ConfigError(f"Invalid --new-alias '{new_alias}': must not contain whitespace.")
363
+ if ".." in new_alias:
364
+ raise ConfigError(
365
+ f"Invalid --new-alias '{new_alias}': must not contain '..' "
366
+ "(path-traversal sequences are rejected because the alias "
367
+ "is used as a filesystem directory name)."
368
+ )
369
+ if not _ALIAS_FORMAT_RE.fullmatch(new_alias):
370
+ raise ConfigError(
371
+ f"Invalid --new-alias '{new_alias}': must match "
372
+ "[A-Za-z0-9_][A-Za-z0-9_.-]* (filesystem-safe slug). "
373
+ "Path separators, NULs, and characters outside the slug "
374
+ "alphabet are rejected because the alias is used as a "
375
+ "nested-sync directory name."
376
+ )
377
+
378
+ @staticmethod
379
+ def _rename_nested_sync_dir(
380
+ *,
381
+ old_alias: str,
382
+ new_alias: str,
383
+ search_root: Path,
384
+ ) -> dict[str, str] | None:
385
+ """Move ``<search_root>/<old_alias>/`` to ``<search_root>/<new_alias>/``.
386
+
387
+ Skips silently when the source directory does not contain a
388
+ ``.keboola/manifest.json`` (i.e. it is not a kbagent sync
389
+ workspace). Mirrors the collision-suffix and git-mv-with-fallback
390
+ pattern of :meth:`ConfigService._rename_sync_directory`.
391
+
392
+ Caller is responsible for ``search_root.resolve()``-ing first
393
+ and for validating ``new_alias`` via
394
+ :meth:`_validate_alias_format` -- this helper trusts both inputs.
395
+ """
396
+ from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME
397
+
398
+ source_dir = search_root / old_alias
399
+ manifest_path = source_dir / KEBOOLA_DIR_NAME / MANIFEST_FILENAME
400
+ if not manifest_path.exists():
401
+ return None # No nested sync workspace; nothing to rename on disk.
402
+
403
+ # Collision detection: append -2, -3, ... if the target exists or
404
+ # is a symlink (covers both real-dir and symlink-to-elsewhere cases).
405
+ target_dir = search_root / new_alias
406
+ if target_dir.exists() or target_dir.is_symlink():
407
+ counter = 2
408
+ while True:
409
+ candidate = search_root / f"{new_alias}-{counter}"
410
+ if not candidate.exists() and not candidate.is_symlink():
411
+ target_dir = candidate
412
+ break
413
+ counter += 1
414
+
415
+ method = ProjectService._move_directory(source_dir, target_dir)
416
+ return {
417
+ "old_path": str(source_dir),
418
+ "new_path": str(target_dir),
419
+ "method": method,
420
+ }
421
+
422
+ @staticmethod
423
+ def _move_directory(source: Path, target: Path) -> str:
424
+ """Move ``source`` to ``target``; prefer ``git mv`` for cleaner history.
425
+
426
+ Returns the method string (``"git_mv"`` or ``"shutil_move"``) used.
427
+ Mirrors :meth:`ConfigService._move_directory` -- return strings
428
+ are kept identical so JSON consumers parsing the ``method`` key
429
+ across both rename surfaces see the same vocabulary.
430
+ """
431
+ try:
432
+ result = subprocess.run(
433
+ ["git", "mv", str(source), str(target)],
434
+ capture_output=True,
435
+ text=True,
436
+ cwd=str(source.parent),
437
+ check=False,
438
+ )
439
+ if result.returncode == 0:
440
+ return "git_mv"
441
+ except (FileNotFoundError, OSError):
442
+ pass
443
+ shutil.move(str(source), str(target))
444
+ return "shutil_move"
445
+
446
+ @staticmethod
447
+ def _detect_lineage_cache_warning(search_root: Path, old_alias: str) -> str | None:
448
+ """Return a stderr-warning string if a ``*.lineage.json`` exists.
449
+
450
+ Depth-capped at 2 levels (top + 2 subdir levels) to bound cost
451
+ when ``search_root`` is a large tree (e.g. ``$HOME``) -- iter 2
452
+ review S4. Lineage caches typically live at the workspace root
453
+ or one level deep next to nested project dirs; deeper scans are
454
+ unbounded and risk symlink loops.
455
+
456
+ We do not parse the JSON or attempt a rewrite -- lineage caches
457
+ embed the alias inside FQN strings, can live anywhere, and may
458
+ be committed to a sibling git repo; partial rewrites are worse
459
+ than no rewrite. Surfacing the manual rebuild step is enough.
460
+ """
461
+ try:
462
+ if not search_root.is_dir():
463
+ return None
464
+ patterns = ("*.lineage.json", "*/*.lineage.json", "*/*/*.lineage.json")
465
+ for pattern in patterns:
466
+ for p in search_root.glob(pattern):
467
+ # Bail out on first hit; one warning suffices.
468
+ return (
469
+ f"Warning: lineage cache file detected at "
470
+ f"'{p}'. The cache embeds the old alias "
471
+ f"'{old_alias}' in FQN strings and is NOT auto-updated "
472
+ f"by this rename. Run 'kbagent lineage build --output X' "
473
+ f"to rebuild it against the new alias."
474
+ )
475
+ except (OSError, PermissionError):
476
+ return None
477
+ return None
478
+
479
+ def _plan_project_alias_rename(
480
+ self,
481
+ *,
482
+ old_alias: str,
483
+ new_alias: str,
484
+ search_root: Path,
485
+ ) -> dict[str, Any]:
486
+ """Read-only dry-run for ``_rename_project_alias``.
487
+
488
+ Mirrors the live-path validation order (format check, then
489
+ ``search_root`` resolve, then collision check via a config load)
490
+ so the same ``ConfigError`` exceptions surface for the same
491
+ inputs. Skips both mutations: no ``rename_project()`` call, no
492
+ ``_rename_nested_sync_dir()`` call. Predicts the disk-move shape
493
+ (target dir, collision suffix, ``planned_method``) by inspecting
494
+ the filesystem read-only. The lineage cache warning string is
495
+ included in the result but NOT written to stderr (planning
496
+ shouldn't emit user-facing warnings yet).
497
+ """
498
+ # 1. Same validators as the live path.
499
+ self._validate_alias_format(new_alias)
500
+
501
+ try:
502
+ resolved_root = search_root.resolve()
503
+ except (OSError, RuntimeError) as exc:
504
+ raise ConfigError(f"Cannot resolve search_root '{search_root}': {exc}") from exc
505
+
506
+ # 2. Collision check via a read-only load (no rename_project mutation).
507
+ config = self._config_store.load()
508
+ if old_alias not in config.projects:
509
+ raise ConfigError(f"Project '{old_alias}' not found.")
510
+ if new_alias in config.projects:
511
+ raise ConfigError(
512
+ f"Cannot rename '{old_alias}' to '{new_alias}': "
513
+ f"alias '{new_alias}' is already in use."
514
+ )
515
+
516
+ default_would_update = config.default_project == old_alias
517
+
518
+ # 3. Sync-dir probe (read-only).
519
+ sync_dir_planned = self._plan_nested_sync_dir(
520
+ old_alias=old_alias, new_alias=new_alias, search_root=resolved_root
521
+ )
522
+
523
+ # 4. Lineage cache scan -- string returned, NOT written to stderr.
524
+ lineage_warning = self._detect_lineage_cache_warning(resolved_root, old_alias)
525
+
526
+ return {
527
+ "old_alias": old_alias,
528
+ "new_alias": new_alias,
529
+ "default_project_would_update": default_would_update,
530
+ "sync_dir_would_move": sync_dir_planned,
531
+ "lineage_cache_warning": lineage_warning,
532
+ }
533
+
534
+ @staticmethod
535
+ def _plan_nested_sync_dir(
536
+ *,
537
+ old_alias: str,
538
+ new_alias: str,
539
+ search_root: Path,
540
+ ) -> dict[str, Any] | None:
541
+ """Read-only counterpart of ``_rename_nested_sync_dir``.
542
+
543
+ Returns the planned move shape (target path, collision suffix,
544
+ method) without touching disk. Returns ``None`` when no nested
545
+ sync workspace exists at ``<search_root>/<old_alias>/``.
546
+ """
547
+ from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME
548
+
549
+ source_dir = search_root / old_alias
550
+ manifest_path = source_dir / KEBOOLA_DIR_NAME / MANIFEST_FILENAME
551
+ if not manifest_path.exists():
552
+ return None
553
+
554
+ target_dir = search_root / new_alias
555
+ suffix: str | None = None
556
+ if target_dir.exists() or target_dir.is_symlink():
557
+ counter = 2
558
+ while True:
559
+ candidate = search_root / f"{new_alias}-{counter}"
560
+ if not candidate.exists() and not candidate.is_symlink():
561
+ target_dir = candidate
562
+ suffix = f"-{counter}"
563
+ break
564
+ counter += 1
565
+
566
+ # Predict whether ``git mv`` would succeed: requires (a) ``git`` on
567
+ # PATH and (b) the source dir lives inside a git working tree.
568
+ # Heuristic -- the live path falls back to ``shutil.move`` when
569
+ # ``git mv`` returns nonzero, so the dry-run prediction is best-
570
+ # effort, not a contract.
571
+ planned_method = "shutil_move"
572
+ if shutil.which("git"):
573
+ ancestor: Path | None = source_dir
574
+ while ancestor is not None and ancestor != ancestor.parent:
575
+ if (ancestor / ".git").exists():
576
+ planned_method = "git_mv"
577
+ break
578
+ ancestor = ancestor.parent
579
+
580
+ return {
581
+ "old_path": str(source_dir),
582
+ "planned_new_path": str(target_dir),
583
+ "planned_method": planned_method,
584
+ "collision_suffix": suffix,
585
+ }
586
+
587
+ def list_projects(self) -> list[dict[str, Any]]:
588
+ """List all configured projects.
589
+
590
+ Returns:
591
+ List of dicts with project details (token masked).
592
+ """
593
+ config = self._config_store.load()
594
+ result = []
595
+ for alias, project in config.projects.items():
596
+ result.append(
597
+ {
598
+ "alias": alias,
599
+ "project_name": project.project_name,
600
+ "project_id": project.project_id,
601
+ "stack_url": project.stack_url,
602
+ "token": mask_token(project.token),
603
+ "is_default": alias == config.default_project,
604
+ "active_branch_id": project.active_branch_id,
605
+ "org_id": project.org_id,
606
+ "org_name": project.org_name,
607
+ }
608
+ )
609
+ return result
610
+
611
+ def _backfill_org_info(
612
+ self,
613
+ successes: list[tuple[str, dict[str, Any], bool]],
614
+ ) -> None:
615
+ """Persist newly-discovered org info for projects that lack it.
616
+
617
+ Runs serially after the parallel status check so we never write to the
618
+ config JSON from multiple worker threads. A single ``load() -> mutate
619
+ -> save()`` pass updates every alias whose stored org info is empty
620
+ but verify_token just returned a populated organization.
621
+
622
+ No-op when no project needs an update -- common steady-state once the
623
+ backfill has run once.
624
+ """
625
+ # Build the update set first; skip the file write entirely if empty.
626
+ updates: dict[str, tuple[int | None, str | None]] = {}
627
+ for _alias, status_entry, _flag in successes:
628
+ if status_entry.get("status") != "ok":
629
+ continue
630
+ alias = status_entry["alias"]
631
+ new_id = status_entry.get("org_id")
632
+ new_name = status_entry.get("org_name")
633
+ if new_id is None and not new_name:
634
+ continue # verify_token didn't return org info -- nothing to backfill
635
+ current = self._config_store.get_project(alias)
636
+ if current is None:
637
+ continue
638
+ if current.ephemeral:
639
+ # Env-synthesized __env__ (issue #359): its org info can never
640
+ # be persisted (save() strips it), so backfilling is futile and
641
+ # would trigger a spurious config.json write on disk -- breaking
642
+ # the "no config.json in headless mode" guarantee and repeating
643
+ # on every `project status`. Skip it.
644
+ continue
645
+ if current.org_id is not None and current.org_name:
646
+ continue # already populated; skip
647
+ updates[alias] = (new_id, new_name)
648
+
649
+ if not updates:
650
+ return
651
+
652
+ # Single transactional pass: load once, mutate all in-memory, save once.
653
+ config = self._config_store.load()
654
+ for alias, (new_id, new_name) in updates.items():
655
+ if alias not in config.projects:
656
+ continue
657
+ project = config.projects[alias]
658
+ if project.org_id is None and new_id is not None:
659
+ project.org_id = new_id
660
+ if not project.org_name and new_name:
661
+ project.org_name = new_name
662
+ self._config_store.save(config)
663
+
664
+ def _check_project_status(
665
+ self, alias: str, project: ProjectConfig
666
+ ) -> tuple[str, dict[str, Any], bool] | tuple[str, dict[str, str]]:
667
+ """Check connectivity for a single project (runs in a worker thread).
668
+
669
+ Creates its own KeboolaClient, verifies the token, and measures response time.
670
+ Returns (alias, status_entry) on both success AND KeboolaApiError (since an
671
+ API error still produces a valid status entry with status="error").
672
+ Only truly unexpected exceptions return a 2-tuple error.
673
+
674
+ Note: For this worker, both success and KeboolaApiError return 3-tuples
675
+ (alias, status_entry, True) to distinguish from error 2-tuples in _run_parallel.
676
+ """
677
+ status_entry: dict[str, Any] = {
678
+ "alias": alias,
679
+ "stack_url": project.stack_url,
680
+ "token": mask_token(project.token),
681
+ "active_branch_id": project.active_branch_id,
682
+ }
683
+
684
+ client = self._client_factory(project.stack_url, project.token)
685
+ start_time = time.monotonic()
686
+ try:
687
+ token_info = client.verify_token()
688
+ elapsed = time.monotonic() - start_time
689
+ status_entry["status"] = "ok"
690
+ status_entry["response_time_ms"] = round(elapsed * 1000)
691
+ status_entry["project_name"] = token_info.project_name
692
+ status_entry["project_id"] = token_info.project_id
693
+ # Carry org info through to the aggregator so get_status() can
694
+ # opportunistically backfill projects registered before owner.
695
+ # organization was being captured (e.g. before #290).
696
+ status_entry["org_id"] = token_info.org_id
697
+ status_entry["org_name"] = token_info.org_name
698
+ return (alias, status_entry, True)
699
+ except KeboolaApiError as exc:
700
+ elapsed = time.monotonic() - start_time
701
+ status_entry["status"] = "error"
702
+ status_entry["response_time_ms"] = round(elapsed * 1000)
703
+ status_entry["error"] = exc.message
704
+ status_entry["error_code"] = exc.error_code
705
+ return (alias, status_entry, True)
706
+ except Exception as exc:
707
+ return (
708
+ alias,
709
+ {
710
+ "project_alias": alias,
711
+ "error_code": "UNEXPECTED_ERROR",
712
+ "message": str(exc),
713
+ },
714
+ )
715
+ finally:
716
+ client.close()
717
+
718
+ def get_status(self, aliases: list[str] | None = None) -> list[dict[str, Any]]:
719
+ """Check connectivity status for one or more projects.
720
+
721
+ For each project, verifies the token against the API and measures
722
+ response time in parallel using ThreadPoolExecutor.
723
+
724
+ Args:
725
+ aliases: Specific project aliases to check (None = all projects).
726
+
727
+ Returns:
728
+ List of dicts with status, response time, and project details.
729
+
730
+ Raises:
731
+ ConfigError: If a specified alias does not exist.
732
+ """
733
+ projects = self.resolve_projects(aliases)
734
+
735
+ successes, errors = self._run_parallel(projects, self._check_project_status)
736
+
737
+ # Extract status entries from successes (3-tuples: alias, status_entry, True)
738
+ results: list[dict[str, Any]] = []
739
+ for _alias, status_entry, _flag in successes:
740
+ results.append(status_entry)
741
+
742
+ # Opportunistic org-info backfill: projects registered before #290 have
743
+ # org_id/org_name=None in config.json even though verify_token now
744
+ # returns them. Apply updates serially here -- _run_parallel uses worker
745
+ # threads and concurrent ConfigStore writes would race on the JSON file.
746
+ self._backfill_org_info(successes)
747
+
748
+ # Convert unexpected errors to status entries
749
+ for error in errors:
750
+ results.append(
751
+ {
752
+ "alias": error["project_alias"],
753
+ "stack_url": "",
754
+ "token": "",
755
+ "status": "error",
756
+ "response_time_ms": 0,
757
+ "error": error["message"],
758
+ "error_code": error["error_code"],
759
+ }
760
+ )
761
+
762
+ # Sort for deterministic output
763
+ results.sort(key=lambda r: r.get("alias", ""))
764
+
765
+ return results
766
+
767
+ def use_project(self, alias: str) -> dict[str, Any]:
768
+ """Pin an alias as the persistent default project.
769
+
770
+ The pin is stored as ``config.default_project`` in config.json.
771
+ It is overridden at runtime by the ``KBAGENT_PROJECT`` env var and by
772
+ explicit ``--project`` flags.
773
+
774
+ Args:
775
+ alias: The project alias to pin.
776
+
777
+ Returns:
778
+ Dict with the new pin, previous pin, and source.
779
+
780
+ Raises:
781
+ ConfigError: If the alias does not exist.
782
+ """
783
+ config = self._config_store.load()
784
+ if alias not in config.projects:
785
+ raise ConfigError(f"Project '{alias}' not found.")
786
+
787
+ previous = config.default_project or None
788
+ config.default_project = alias
789
+ self._config_store.save(config)
790
+
791
+ env_override = os.environ.get(ENV_KBAGENT_PROJECT)
792
+ return {
793
+ "alias": alias,
794
+ "previous": previous,
795
+ "source": "pin",
796
+ "env_override": env_override or None,
797
+ }
798
+
799
+ def current_project(self) -> dict[str, Any]:
800
+ """Report the effective default project and its source.
801
+
802
+ Resolution:
803
+ - If ``KBAGENT_PROJECT`` is set, it wins (source=env).
804
+ - Otherwise the persisted pin wins (source=pin).
805
+ - If neither is set, ``alias`` is ``None``.
806
+
807
+ The env override is reported even when it points at a project that is
808
+ not (yet) registered in config.json -- callers get the true effective
809
+ alias plus an ``env_points_to_configured_project`` flag to reason about
810
+ it. This avoids silently masking misconfigurations.
811
+
812
+ Returns:
813
+ Dict with keys: alias, source ('env' | 'pin' | 'none'), pinned,
814
+ env_override, env_points_to_configured_project.
815
+ """
816
+ config = self._config_store.load()
817
+ pinned = config.default_project or None
818
+ # Treat KBAGENT_PROJECT="" the same as unset (Unix shell convention:
819
+ # empty env is commonly produced by `unset` substitutes / blank
820
+ # exports). Strict rejection would surprise CI users who export it
821
+ # conditionally. Apply consistently in resolve_pinned_alias().
822
+ env_value = os.environ.get(ENV_KBAGENT_PROJECT)
823
+ env_override = env_value if env_value else None
824
+
825
+ if env_override is not None:
826
+ return {
827
+ "alias": env_override,
828
+ "source": "env",
829
+ "pinned": pinned,
830
+ "env_override": env_override,
831
+ "env_points_to_configured_project": env_override in config.projects,
832
+ }
833
+
834
+ return {
835
+ "alias": pinned,
836
+ "source": "pin" if pinned else "none",
837
+ "pinned": pinned,
838
+ "env_override": None,
839
+ "env_points_to_configured_project": None,
840
+ }
841
+
842
+ def get_info(self, alias: str) -> dict[str, Any]:
843
+ """Return detailed project metadata for a single project.
844
+
845
+ Calls /v2/storage/tokens/verify and formats the full response
846
+ into a structured dict suitable for both JSON and human output.
847
+
848
+ Args:
849
+ alias: The project alias to query.
850
+
851
+ Returns:
852
+ Dict with project_id, project_name, stack_url, default_backend,
853
+ features, limits, metrics, token_id, token_description,
854
+ is_master_token, token_expires, and description fields.
855
+
856
+ Raises:
857
+ ConfigError: If the alias does not exist.
858
+ KeboolaApiError: If the API call fails.
859
+ """
860
+ project = self._config_store.get_project(alias)
861
+ if project is None:
862
+ raise ConfigError(f"Project '{alias}' not found.")
863
+
864
+ client = self._client_factory(project.stack_url, project.token)
865
+ try:
866
+ raw = client.get_project_info()
867
+ finally:
868
+ client.close()
869
+
870
+ owner = raw.get("owner", {})
871
+ return {
872
+ "alias": alias,
873
+ "project_id": owner.get("id"),
874
+ "project_name": owner.get("name", ""),
875
+ "stack_url": project.stack_url,
876
+ "default_backend": owner.get("defaultBackend", "snowflake"),
877
+ "features": owner.get("features", []),
878
+ "limits": owner.get("limits", {}),
879
+ "metrics": owner.get("metrics", {}),
880
+ "token_id": str(raw.get("id", "")),
881
+ "token_description": raw.get("description", ""),
882
+ "is_master_token": raw.get("isMasterToken", False),
883
+ "token_expires": raw.get("expires"),
884
+ }
885
+
886
+ def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]:
887
+ """Resolve the effective project alias for a single-project operation.
888
+
889
+ Precedence (first match wins):
890
+ 1. ``explicit`` argument (typically the CLI ``--project`` flag)
891
+ 2. ``KBAGENT_PROJECT`` env var
892
+ 3. Persisted ``default_project`` pin
893
+ 4. If exactly one project is registered, fall back to it (source=sole)
894
+ 5. Fail hard with ConfigError
895
+
896
+ This is the single-project analog of ``resolve_projects()`` (which
897
+ fans out to all projects). Use this from write/destructive command
898
+ paths where fan-out would be surprising or unsafe.
899
+
900
+ Args:
901
+ explicit: Explicit alias from a CLI flag, or None.
902
+
903
+ Returns:
904
+ Tuple of (alias, source).
905
+
906
+ Raises:
907
+ ConfigError: If the resolved alias is not registered, or if none
908
+ can be resolved.
909
+ """
910
+ config = self._config_store.load()
911
+
912
+ if explicit:
913
+ if explicit not in config.projects:
914
+ raise ConfigError(f"Project '{explicit}' not found.")
915
+ return explicit, "explicit"
916
+
917
+ env_value = os.environ.get(ENV_KBAGENT_PROJECT)
918
+ if env_value:
919
+ if env_value not in config.projects:
920
+ raise ConfigError(
921
+ f"{ENV_KBAGENT_PROJECT}='{env_value}' points to a project "
922
+ "that is not registered. Use 'kbagent project add' or "
923
+ "unset the env var."
924
+ )
925
+ return env_value, "env"
926
+
927
+ pinned = config.default_project
928
+ if pinned:
929
+ if pinned not in config.projects:
930
+ raise ConfigError(
931
+ f"Pinned default project '{pinned}' is not registered. "
932
+ "Run 'kbagent project use <alias>' to repair."
933
+ )
934
+ return pinned, "pin"
935
+
936
+ if len(config.projects) == 1:
937
+ (sole,) = config.projects.keys()
938
+ return sole, "sole"
939
+
940
+ if not config.projects:
941
+ raise ConfigError("No projects configured. Run 'kbagent project add' first.")
942
+
943
+ raise ConfigError(
944
+ "Multiple projects configured and no default pinned. "
945
+ "Pass --project <alias>, set KBAGENT_PROJECT, or run "
946
+ "'kbagent project use <alias>'."
947
+ )