graph-agents-cli 0.3.1__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 (291) hide show
  1. graph_agents_cli/__init__.py +26 -0
  2. graph_agents_cli/_api_policy.py +2145 -0
  3. graph_agents_cli/_approvals.py +400 -0
  4. graph_agents_cli/_build.py +186 -0
  5. graph_agents_cli/_build_info.json +7 -0
  6. graph_agents_cli/_chat_client.py +462 -0
  7. graph_agents_cli/_click.py +157 -0
  8. graph_agents_cli/_defaults.py +139 -0
  9. graph_agents_cli/_experiments.py +64 -0
  10. graph_agents_cli/_http.py +192 -0
  11. graph_agents_cli/_output.py +83 -0
  12. graph_agents_cli/_project.py +462 -0
  13. graph_agents_cli/_remote.py +220 -0
  14. graph_agents_cli/_response_schema.py +264 -0
  15. graph_agents_cli/_runner.py +319 -0
  16. graph_agents_cli/_skills_check.py +274 -0
  17. graph_agents_cli/_tools.py +189 -0
  18. graph_agents_cli/_trust.py +66 -0
  19. graph_agents_cli/api/__init__.py +15 -0
  20. graph_agents_cli/api/_changes.py +506 -0
  21. graph_agents_cli/api/_files.py +658 -0
  22. graph_agents_cli/api/cmd_api.py +2480 -0
  23. graph_agents_cli/deploy/__init__.py +15 -0
  24. graph_agents_cli/deploy/_config.py +171 -0
  25. graph_agents_cli/deploy/_image.py +128 -0
  26. graph_agents_cli/deploy/_kube.py +286 -0
  27. graph_agents_cli/deploy/_modes.py +234 -0
  28. graph_agents_cli/deploy/_preflight.py +370 -0
  29. graph_agents_cli/deploy/_values.py +168 -0
  30. graph_agents_cli/deploy/cmd_deploy.py +1866 -0
  31. graph_agents_cli/deploy/gitops.py +562 -0
  32. graph_agents_cli/deploy/local_load.py +273 -0
  33. graph_agents_cli/dev/__init__.py +13 -0
  34. graph_agents_cli/dev/cmd_build.py +131 -0
  35. graph_agents_cli/dev/cmd_install.py +78 -0
  36. graph_agents_cli/dev/cmd_lint.py +119 -0
  37. graph_agents_cli/dev/cmd_playground.py +297 -0
  38. graph_agents_cli/dev/policy_check.py +1287 -0
  39. graph_agents_cli/eval/__init__.py +22 -0
  40. graph_agents_cli/eval/_client.py +670 -0
  41. graph_agents_cli/eval/_common.py +177 -0
  42. graph_agents_cli/eval/_judge.py +168 -0
  43. graph_agents_cli/eval/_judge_runner.py +238 -0
  44. graph_agents_cli/eval/_paths.py +212 -0
  45. graph_agents_cli/eval/checks.py +581 -0
  46. graph_agents_cli/eval/cmd_analyze.py +278 -0
  47. graph_agents_cli/eval/cmd_compare.py +284 -0
  48. graph_agents_cli/eval/cmd_eval_group.py +80 -0
  49. graph_agents_cli/eval/cmd_generate.py +558 -0
  50. graph_agents_cli/eval/cmd_grade.py +466 -0
  51. graph_agents_cli/eval/cmd_metric.py +156 -0
  52. graph_agents_cli/eval/cmd_run.py +370 -0
  53. graph_agents_cli/eval/cmd_submit.py +400 -0
  54. graph_agents_cli/eval/config.py +435 -0
  55. graph_agents_cli/eval/dataset.py +350 -0
  56. graph_agents_cli/eval/gate.py +420 -0
  57. graph_agents_cli/eval/transcript.py +192 -0
  58. graph_agents_cli/extension/__init__.py +13 -0
  59. graph_agents_cli/extension/_compat.py +86 -0
  60. graph_agents_cli/extension/_loader.py +293 -0
  61. graph_agents_cli/extension/_manifest.py +135 -0
  62. graph_agents_cli/extension/_overrides.py +195 -0
  63. graph_agents_cli/extension/_paths.py +91 -0
  64. graph_agents_cli/extension/_refs.py +193 -0
  65. graph_agents_cli/extension/_resolver.py +453 -0
  66. graph_agents_cli/extension/_schema.py +106 -0
  67. graph_agents_cli/extension/_spec.py +253 -0
  68. graph_agents_cli/extension/_sync.py +102 -0
  69. graph_agents_cli/extension/_trust.py +58 -0
  70. graph_agents_cli/extension/cmd_extension_add.py +259 -0
  71. graph_agents_cli/extension/cmd_extension_group.py +57 -0
  72. graph_agents_cli/extension/cmd_extension_list.py +56 -0
  73. graph_agents_cli/extension/cmd_extension_remove.py +61 -0
  74. graph_agents_cli/extension/cmd_extension_update.py +195 -0
  75. graph_agents_cli/info/__init__.py +13 -0
  76. graph_agents_cli/info/cmd_info.py +222 -0
  77. graph_agents_cli/infra/__init__.py +15 -0
  78. graph_agents_cli/infra/checks.py +1169 -0
  79. graph_agents_cli/infra/cmd_infra.py +103 -0
  80. graph_agents_cli/main.py +591 -0
  81. graph_agents_cli/peer/__init__.py +15 -0
  82. graph_agents_cli/peer/_generate.py +254 -0
  83. graph_agents_cli/peer/cmd_peer.py +1151 -0
  84. graph_agents_cli/run/__init__.py +13 -0
  85. graph_agents_cli/run/_local_server.py +1157 -0
  86. graph_agents_cli/run/_signals.py +141 -0
  87. graph_agents_cli/run/cmd_approvals.py +530 -0
  88. graph_agents_cli/run/cmd_run.py +1421 -0
  89. graph_agents_cli/scaffold/__init__.py +19 -0
  90. graph_agents_cli/scaffold/agents/README.md +24 -0
  91. graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
  92. graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
  93. graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
  94. graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
  95. graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
  96. graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
  97. graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
  98. graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
  99. graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
  100. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
  101. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
  102. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
  103. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
  104. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
  105. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
  106. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
  107. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
  108. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
  109. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
  110. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
  111. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
  112. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
  113. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
  114. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
  115. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
  116. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
  117. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
  118. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
  119. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
  120. graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
  121. graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
  122. graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
  123. graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
  124. graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
  125. graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
  126. graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
  127. graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
  128. graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
  129. graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
  130. graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
  131. graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
  132. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
  133. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
  134. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
  135. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
  136. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
  137. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
  138. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
  139. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
  140. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
  141. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
  142. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
  143. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
  144. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
  145. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
  146. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
  147. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
  148. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
  149. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
  150. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
  151. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
  152. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
  153. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
  154. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
  155. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
  156. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
  157. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
  158. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
  159. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
  160. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
  161. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
  162. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
  163. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
  164. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
  165. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
  166. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
  167. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
  168. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
  169. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
  170. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
  171. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
  172. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
  173. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
  174. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
  175. graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
  176. graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
  177. graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
  178. graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
  179. graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
  180. graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
  181. graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
  182. graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
  183. graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
  184. graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
  185. graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
  186. graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
  187. graph_agents_cli/scaffold/commands/__init__.py +13 -0
  188. graph_agents_cli/scaffold/commands/create.py +1424 -0
  189. graph_agents_cli/scaffold/commands/enhance.py +1652 -0
  190. graph_agents_cli/scaffold/commands/upgrade.py +570 -0
  191. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
  192. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
  193. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
  194. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
  195. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
  196. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
  197. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
  198. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
  199. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
  200. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
  201. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
  202. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
  203. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
  204. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
  205. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
  206. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
  207. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
  208. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
  209. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
  210. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
  211. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
  212. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
  213. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
  214. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
  215. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
  216. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
  217. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
  218. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
  219. graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
  220. graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
  221. graph_agents_cli/scaffold/utils/__init__.py +13 -0
  222. graph_agents_cli/scaffold/utils/backup.py +212 -0
  223. graph_agents_cli/scaffold/utils/build_record.py +257 -0
  224. graph_agents_cli/scaffold/utils/cli_options.py +184 -0
  225. graph_agents_cli/scaffold/utils/fs.py +83 -0
  226. graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
  227. graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
  228. graph_agents_cli/scaffold/utils/keyedit.py +768 -0
  229. graph_agents_cli/scaffold/utils/keymerge.py +537 -0
  230. graph_agents_cli/scaffold/utils/language.py +138 -0
  231. graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
  232. graph_agents_cli/scaffold/utils/logging.py +77 -0
  233. graph_agents_cli/scaffold/utils/manifest.py +292 -0
  234. graph_agents_cli/scaffold/utils/merge.py +970 -0
  235. graph_agents_cli/scaffold/utils/merge3.py +216 -0
  236. graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
  237. graph_agents_cli/scaffold/utils/remote_template.py +376 -0
  238. graph_agents_cli/scaffold/utils/template.py +1352 -0
  239. graph_agents_cli/scaffold/utils/upgrade.py +894 -0
  240. graph_agents_cli/scaffold/utils/version.py +438 -0
  241. graph_agents_cli/secrets/__init__.py +15 -0
  242. graph_agents_cli/secrets/_apply.py +954 -0
  243. graph_agents_cli/secrets/_required.py +188 -0
  244. graph_agents_cli/secrets/cmd_secrets.py +211 -0
  245. graph_agents_cli/setup/__init__.py +13 -0
  246. graph_agents_cli/setup/_antigravity.py +221 -0
  247. graph_agents_cli/setup/cmd_auth.py +1030 -0
  248. graph_agents_cli/setup/cmd_dev_token.py +513 -0
  249. graph_agents_cli/setup/cmd_setup.py +428 -0
  250. graph_agents_cli/setup/cmd_update.py +140 -0
  251. graph_agents_cli/skills/__init__.py +13 -0
  252. graph_agents_cli/skills/_bundle.py +65 -0
  253. graph_agents_cli/skills/data/README.md +19 -0
  254. graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
  255. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
  256. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
  257. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
  258. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
  259. graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
  260. graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
  261. graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
  262. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
  263. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
  264. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
  265. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
  266. graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
  267. graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
  268. graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
  269. graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
  270. graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
  271. graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
  272. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
  273. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
  274. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
  275. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
  276. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
  277. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
  278. graph_agents_cli/system/__init__.py +15 -0
  279. graph_agents_cli/system/_apply.py +519 -0
  280. graph_agents_cli/system/_checks.py +1023 -0
  281. graph_agents_cli/system/_deploy.py +215 -0
  282. graph_agents_cli/system/_model.py +363 -0
  283. graph_agents_cli/system/_system.py +664 -0
  284. graph_agents_cli/system/_views.py +208 -0
  285. graph_agents_cli/system/cmd_system.py +423 -0
  286. graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
  287. graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
  288. graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
  289. graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
  290. graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
  291. graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
@@ -0,0 +1,462 @@
1
+ # Copyright 2026 graph-agents-cli contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Synchronous client for the chat API (``POST /chat``, SSE) of a scaffolded agent.
16
+
17
+ The server side is ``POST /chat`` streaming server-sent events, ``GET /health``,
18
+ ``GET /threads``, ``GET /threads/{thread_id}/messages`` and the approval routes
19
+ (``GET /approvals``, ``GET /threads/{thread_id}/approvals``, ``POST
20
+ /threads/{thread_id}/approvals/{approval_id}``). This module is the only
21
+ client-side implementation of that surface; ``run``, ``approvals`` and
22
+ ``eval generate`` build on it.
23
+
24
+ A run that reaches a call its API's policy gates pauses: its stream ends with
25
+ ``message.end`` whose ``status`` is ``awaiting_approval`` and whose
26
+ ``approval`` names the call. Deciding it (``decide_approval``) resumes the run
27
+ and streams the continuation with the same events as ``POST /chat``.
28
+
29
+ Only ``httpx`` is imported here: no model SDK, no framework. Clients come from
30
+ ``graph_agents_cli._http``: a request to the local server never goes through a
31
+ proxy, and a remote one fails with one line when the environment names a proxy
32
+ httpx cannot use.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import re
39
+ from collections.abc import Iterable, Iterator, Mapping
40
+ from typing import Any, NamedTuple
41
+ from urllib.parse import quote
42
+
43
+ import httpx
44
+
45
+ from graph_agents_cli import _http
46
+
47
+ # Event names the scaffolded app streams from POST /chat.
48
+ EVENT_MESSAGE_START = "message.start"
49
+ EVENT_MESSAGE_DELTA = "message.delta"
50
+ EVENT_TOOL_CALL = "tool.call"
51
+ EVENT_TOOL_RESULT = "tool.result"
52
+ EVENT_MESSAGE_END = "message.end"
53
+ EVENT_ERROR = "error"
54
+
55
+ KNOWN_EVENTS = frozenset(
56
+ {
57
+ EVENT_MESSAGE_START,
58
+ EVENT_MESSAGE_DELTA,
59
+ EVENT_TOOL_CALL,
60
+ EVENT_TOOL_RESULT,
61
+ EVENT_MESSAGE_END,
62
+ EVENT_ERROR,
63
+ }
64
+ )
65
+
66
+ # `message.end` status of a run paused on a gated call (its payload has `approval`).
67
+ STATUS_AWAITING_APPROVAL = "awaiting_approval"
68
+ # The decisions `POST /threads/{thread_id}/approvals/{approval_id}` takes.
69
+ DECISION_APPROVE = "approve"
70
+ DECISION_REJECT = "reject"
71
+ DECISIONS = (DECISION_APPROVE, DECISION_REJECT)
72
+ # The 409 code of `POST /chat` on a thread whose run waits for an approval.
73
+ APPROVAL_PENDING = "approval_pending"
74
+
75
+ # Scalar timeout kept for callers that pass one number (every phase applies it).
76
+ DEFAULT_TIMEOUT = 120.0
77
+ # The streaming call: connecting must be quick, but a healthy agent may send no
78
+ # event for minutes while a tool or a non-streaming model phase runs. The read
79
+ # timeout bounds that gap without treating it as "unreachable"; ``post_chat``
80
+ # reads this at call time so a caller (or a test) may replace it.
81
+ STREAM_READ_TIMEOUT = 600.0
82
+ STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=STREAM_READ_TIMEOUT, write=10.0, pool=10.0)
83
+
84
+
85
+ class SseEvent(NamedTuple):
86
+ """One parsed server-sent event.
87
+
88
+ ``data`` is the JSON-decoded payload when the ``data:`` lines form valid
89
+ JSON, otherwise the raw joined string. ``raw`` is always the joined string.
90
+ """
91
+
92
+ event: str
93
+ data: Any
94
+ raw: str = ""
95
+ id: str | None = None
96
+
97
+
98
+ # `scheme://user:password@`: the userinfo of any URL in a text.
99
+ _URL_USERINFO = re.compile(r"(\b[A-Za-z][A-Za-z0-9+.-]*://)[^/\s@]+@")
100
+
101
+
102
+ def redact_credentials(text: str) -> str:
103
+ """``text`` with the ``user:password@`` of every URL in it replaced by ``***@``."""
104
+ return _URL_USERINFO.sub(r"\1***@", text or "")
105
+
106
+
107
+ class ChatClientError(Exception):
108
+ """Base class for chat client failures."""
109
+
110
+
111
+ class ChatHTTPError(ChatClientError):
112
+ """The server answered with an HTTP error status before streaming.
113
+
114
+ ``url`` and the message never carry the URL's credentials (``***@``).
115
+ """
116
+
117
+ def __init__(self, status_code: int, body: str, url: str = "") -> None:
118
+ self.status_code = status_code
119
+ self.body = body
120
+ self.url = redact_credentials(url)
121
+ super().__init__(f"HTTP {status_code} from {self.url or 'server'}: {body}")
122
+
123
+
124
+ def _normalise_base(base_url: str) -> str:
125
+ return base_url.rstrip("/")
126
+
127
+
128
+ def _decode_data(raw: str) -> Any:
129
+ if not raw:
130
+ return None
131
+ try:
132
+ return json.loads(raw)
133
+ except (json.JSONDecodeError, ValueError):
134
+ return raw
135
+
136
+
137
+ def iter_sse(lines: Iterable[str]) -> Iterator[SseEvent]:
138
+ """Parse a stream of text lines into :class:`SseEvent` objects.
139
+
140
+ Follows the WHATWG event-stream grammar: ``:`` lines are comments
141
+ (keep-alives) and are dropped, ``data:`` lines accumulate joined by
142
+ ``\\n``, ``event:`` names the event (default ``message``), ``id:`` is kept,
143
+ ``retry:`` and unknown fields are ignored, and a blank line dispatches the
144
+ pending event. A pending event at end of stream is dispatched as well, so a
145
+ server that closes without a trailing blank line still delivers its last
146
+ event. Events with no data are dropped, as the spec requires.
147
+ """
148
+ event_name = ""
149
+ data_lines: list[str] = []
150
+ event_id: str | None = None
151
+
152
+ def flush() -> SseEvent | None:
153
+ nonlocal event_name, data_lines, event_id
154
+ if not data_lines:
155
+ event_name = ""
156
+ event_id = None
157
+ return None
158
+ raw = "\n".join(data_lines)
159
+ ev = SseEvent(event_name or "message", _decode_data(raw), raw, event_id)
160
+ event_name = ""
161
+ data_lines = []
162
+ event_id = None
163
+ return ev
164
+
165
+ for line in lines:
166
+ # Accept both bare and CRLF-terminated lines from any source.
167
+ line = line.rstrip("\r\n")
168
+ if line == "":
169
+ ev = flush()
170
+ if ev is not None:
171
+ yield ev
172
+ continue
173
+ if line.startswith(":"):
174
+ continue # comment / keep-alive
175
+ field, sep, value = line.partition(":")
176
+ if sep and value.startswith(" "):
177
+ value = value[1:]
178
+ if field == "data":
179
+ data_lines.append(value)
180
+ elif field == "event":
181
+ event_name = value
182
+ elif field == "id":
183
+ event_id = value
184
+ # retry: and unknown fields are ignored.
185
+
186
+ ev = flush()
187
+ if ev is not None:
188
+ yield ev
189
+
190
+
191
+ def _iter_stream_lines(chunks: Iterable[str]) -> Iterator[str]:
192
+ """Split decoded text chunks into lines on LF, CR or CRLF only.
193
+
194
+ ``httpx.Response.iter_lines`` uses ``str.splitlines`` semantics and also
195
+ breaks on U+0085, U+2028, U+2029, VT, FF, FS, GS and RS, which are legal
196
+ inside a ``data:`` payload (the scaffolded server encodes with
197
+ ``ensure_ascii=False``); the event-stream grammar recognises only CR, LF
198
+ and CRLF as line terminators. A trailing CR is held back until the next
199
+ chunk shows whether an LF follows, so a CRLF split across chunks is one
200
+ line break, and a partial last line is flushed at end of stream.
201
+ """
202
+ buf = ""
203
+ for chunk in chunks:
204
+ buf += chunk
205
+ while True:
206
+ i_n = buf.find("\n")
207
+ i_r = buf.find("\r")
208
+ if i_n == -1 and i_r == -1:
209
+ break
210
+ if i_r != -1 and (i_n == -1 or i_r < i_n):
211
+ if i_r == len(buf) - 1:
212
+ break # trailing CR: wait to see whether an LF follows
213
+ end = i_r + 2 if buf[i_r + 1] == "\n" else i_r + 1
214
+ yield buf[:i_r]
215
+ buf = buf[end:]
216
+ else:
217
+ yield buf[:i_n]
218
+ buf = buf[i_n + 1 :]
219
+ if buf:
220
+ yield buf.rstrip("\r")
221
+
222
+
223
+ def _stream_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
224
+ merged: dict[str, str] = {}
225
+ if headers:
226
+ merged.update(headers)
227
+ # Do not override an explicit Accept from the caller.
228
+ if not any(k.lower() == "accept" for k in merged):
229
+ merged["Accept"] = "text/event-stream"
230
+ return merged
231
+
232
+
233
+ def post_chat(
234
+ base_url: str,
235
+ message: str,
236
+ *,
237
+ thread_id: str | None = None,
238
+ headers: Mapping[str, str] | None = None,
239
+ timeout: float | httpx.Timeout | None = None,
240
+ metadata: Mapping[str, Any] | None = None,
241
+ ) -> Iterator[SseEvent]:
242
+ """POST ``/chat`` and yield the streamed events as they arrive.
243
+
244
+ ``timeout`` defaults to :data:`STREAM_TIMEOUT` (looked up at call time): a
245
+ short connect phase and a long per-read gap. A scalar applies to every
246
+ phase. Raises :class:`ChatHTTPError` when the response status is not 2xx
247
+ (the body is read fully so the caller can show it), and lets ``httpx``
248
+ transport errors propagate so callers can distinguish "unreachable"
249
+ (``ConnectError``) from a stalled stream (``ReadTimeout``) and from
250
+ "refused".
251
+ """
252
+ base = _normalise_base(base_url)
253
+ body: dict[str, Any] = {"message": message, "metadata": dict(metadata or {})}
254
+ if thread_id:
255
+ body["thread_id"] = thread_id
256
+ yield from _post_stream(f"{base}/chat", body, headers=headers, timeout=timeout)
257
+
258
+
259
+ def _post_stream(
260
+ url: str,
261
+ body: Mapping[str, Any],
262
+ *,
263
+ headers: Mapping[str, str] | None,
264
+ timeout: float | httpx.Timeout | None,
265
+ ) -> Iterator[SseEvent]:
266
+ """POST ``body`` as JSON and yield the SSE events of the answer (``post_chat``'s rules)."""
267
+ if timeout is None:
268
+ timeout = STREAM_TIMEOUT
269
+ with (
270
+ _http.client(url, timeout=timeout) as client,
271
+ client.stream("POST", url, json=dict(body), headers=_stream_headers(headers)) as resp,
272
+ ):
273
+ if resp.status_code >= 400:
274
+ resp.read()
275
+ raise ChatHTTPError(resp.status_code, resp.text, url)
276
+ yield from iter_sse(_iter_stream_lines(resp.iter_text()))
277
+
278
+
279
+ def path_segment(value: str, what: str) -> str:
280
+ """``value`` as one URL path segment: percent-encoded, so no id can reach another route.
281
+
282
+ An approval id or a thread id comes from a server payload or the command
283
+ line; ``../chat``, ``a/b``, ``.`` or ``..`` must stay one segment of the
284
+ approval route. Percent-encoding leaves dots alone, and a client drops a
285
+ ``.``/``..`` segment (or the one before it), so their dots are encoded too.
286
+ """
287
+ if not isinstance(value, str) or not value.strip():
288
+ raise ValueError(f"{what} must be a non-empty string")
289
+ segment = quote(value, safe="")
290
+ return segment.replace(".", "%2E") if segment in (".", "..") else segment
291
+
292
+
293
+ def approval_url(base_url: str, thread_id: str, approval_id: str | None = None) -> str:
294
+ """``<base>/threads/<thread_id>/approvals[/<approval_id>]`` with each id one segment."""
295
+ url = f"{_normalise_base(base_url)}/threads/{path_segment(thread_id, 'thread id')}/approvals"
296
+ if approval_id is not None:
297
+ url += f"/{path_segment(approval_id, 'approval id')}"
298
+ return url
299
+
300
+
301
+ def decide_approval(
302
+ base_url: str,
303
+ thread_id: str,
304
+ approval_id: str,
305
+ decision: str,
306
+ *,
307
+ comment: str | None = None,
308
+ headers: Mapping[str, str] | None = None,
309
+ timeout: float | httpx.Timeout | None = None,
310
+ ) -> Iterator[SseEvent]:
311
+ """Approve or reject a pending approval; yield the events of the resumed run.
312
+
313
+ ``POST /threads/{thread_id}/approvals/{approval_id}`` with
314
+ ``{"decision": "approve"|"reject", "comment": ...}`` and nothing else: the
315
+ server binds the decision to the call it recorded, so the client never
316
+ sends (or could change) the call itself. The answer is an SSE stream of the
317
+ continuation, with the same events as ``POST /chat``. A refusal is a
318
+ :class:`ChatHTTPError`: 403 (not an allowed approver), 404 (no such
319
+ approval or thread), 409 (already decided), 410 (expired, which rejected
320
+ the call).
321
+ """
322
+ if decision not in DECISIONS:
323
+ raise ValueError(f"decision must be one of {', '.join(DECISIONS)}")
324
+ body: dict[str, Any] = {"decision": decision}
325
+ if comment:
326
+ body["comment"] = comment
327
+ url = approval_url(base_url, thread_id, approval_id)
328
+ yield from _post_stream(url, body, headers=headers, timeout=timeout)
329
+
330
+
331
+ def _json_list(resp: httpx.Response, url: str, key: str) -> list[dict[str, Any]]:
332
+ if resp.status_code >= 400:
333
+ raise ChatHTTPError(resp.status_code, resp.text, url)
334
+ try:
335
+ data = resp.json()
336
+ except ValueError:
337
+ raise ChatClientError(
338
+ f"unexpected answer from {redact_credentials(url)}: not JSON"
339
+ ) from None
340
+ if isinstance(data, dict):
341
+ data = data.get(key, [])
342
+ if not isinstance(data, list):
343
+ raise ChatClientError(f"unexpected answer from {redact_credentials(url)}: not a list")
344
+ return [item for item in data if isinstance(item, dict)]
345
+
346
+
347
+ def list_approvals(
348
+ base_url: str,
349
+ thread_id: str,
350
+ *,
351
+ headers: Mapping[str, str] | None = None,
352
+ timeout: float = 30.0,
353
+ ) -> list[dict[str, Any]]:
354
+ """GET ``/threads/{thread_id}/approvals``: the thread's approvals, pending or decided.
355
+
356
+ The server may wrap the list as ``{"approvals": [...]}``; both shapes are accepted.
357
+ """
358
+ url = approval_url(base_url, thread_id)
359
+ resp = _http.get(url, headers=dict(headers or {}), timeout=timeout)
360
+ return _json_list(resp, url, "approvals")
361
+
362
+
363
+ def list_visible_approvals(
364
+ base_url: str,
365
+ *,
366
+ status: str | None = None,
367
+ headers: Mapping[str, str] | None = None,
368
+ limit: int = 100,
369
+ offset: int = 0,
370
+ timeout: float = 30.0,
371
+ ) -> list[dict[str, Any]]:
372
+ """GET ``/approvals``: across threads, the approvals the caller may see (one page).
373
+
374
+ The caller's own, the ones naming one of its roles (it may decide them),
375
+ and every one for a read-across role; each row carries its ``thread_id``.
376
+ An agent without this route answers 404 or 405 (a :class:`ChatHTTPError`).
377
+ """
378
+ url = f"{_normalise_base(base_url)}/approvals"
379
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
380
+ if status:
381
+ params["status"] = status
382
+ resp = _http.get(url, params=params, headers=dict(headers or {}), timeout=timeout)
383
+ return _json_list(resp, url, "approvals")
384
+
385
+
386
+ def list_threads(
387
+ base_url: str,
388
+ *,
389
+ headers: Mapping[str, str] | None = None,
390
+ limit: int = 100,
391
+ offset: int = 0,
392
+ timeout: float = 30.0,
393
+ ) -> list[dict[str, Any]]:
394
+ """GET ``/threads``: the caller's own threads (``{thread_id, ...}`` rows), one page."""
395
+ url = f"{_normalise_base(base_url)}/threads"
396
+ resp = _http.get(
397
+ url,
398
+ params={"limit": limit, "offset": offset},
399
+ headers=dict(headers or {}),
400
+ timeout=timeout,
401
+ )
402
+ return _json_list(resp, url, "threads")
403
+
404
+
405
+ def delete_thread(
406
+ base_url: str,
407
+ thread_id: str,
408
+ *,
409
+ headers: Mapping[str, str] | None = None,
410
+ timeout: float = 30.0,
411
+ ) -> None:
412
+ """DELETE ``/threads/{thread_id}``: the thread with its runs and its approvals.
413
+
414
+ Only its owner may. Raises :class:`ChatHTTPError` on any status but 2xx
415
+ (a 404 too: the route may be missing as well as the thread), and lets
416
+ transport errors propagate.
417
+ """
418
+ url = f"{_normalise_base(base_url)}/threads/{path_segment(thread_id, 'thread id')}"
419
+ resp = _http.delete(url, headers=dict(headers or {}), timeout=timeout)
420
+ if resp.status_code >= 300:
421
+ raise ChatHTTPError(resp.status_code, resp.text, url)
422
+
423
+
424
+ def get_health(
425
+ base_url: str,
426
+ *,
427
+ headers: Mapping[str, str] | None = None,
428
+ timeout: float = 10.0,
429
+ ) -> dict[str, Any]:
430
+ """GET ``/health`` and return its JSON body.
431
+
432
+ Raises :class:`ChatHTTPError` on a non-2xx status and lets transport
433
+ errors propagate.
434
+ """
435
+ url = f"{_normalise_base(base_url)}/health"
436
+ resp = _http.get(url, headers=dict(headers or {}), timeout=timeout)
437
+ if resp.status_code >= 400:
438
+ raise ChatHTTPError(resp.status_code, resp.text, url)
439
+ data = resp.json()
440
+ return data if isinstance(data, dict) else {"status": data}
441
+
442
+
443
+ def get_thread_messages(
444
+ base_url: str,
445
+ thread_id: str,
446
+ *,
447
+ headers: Mapping[str, str] | None = None,
448
+ timeout: float = 30.0,
449
+ ) -> list[dict[str, Any]]:
450
+ """GET ``/threads/{thread_id}/messages`` and return the ordered messages.
451
+
452
+ The server may wrap the list as ``{"messages": [...]}``; both shapes are
453
+ accepted.
454
+ """
455
+ url = f"{_normalise_base(base_url)}/threads/{thread_id}/messages"
456
+ resp = _http.get(url, headers=dict(headers or {}), timeout=timeout)
457
+ if resp.status_code >= 400:
458
+ raise ChatHTTPError(resp.status_code, resp.text, url)
459
+ data = resp.json()
460
+ if isinstance(data, dict):
461
+ data = data.get("messages", [])
462
+ return list(data)
@@ -0,0 +1,157 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Click helpers: lazy command loading and source-path display in --help."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib
20
+ import inspect
21
+ from typing import Any
22
+
23
+ import click
24
+
25
+ from graph_agents_cli._experiments import resolve_experiment
26
+
27
+
28
+ class LazyGroup(click.Group):
29
+ """Click group that defers importing subcommand modules until needed.
30
+
31
+ Register subcommands with `add_lazy_command(name, "module.path:obj",
32
+ short_help)`. The module is imported only when the command is actually
33
+ invoked (e.g. `tool name ...`) or when its own help is requested
34
+ (`tool name --help`). The parent group's --help (`tool --help`) renders
35
+ the supplied `short_help` strings directly without triggering any imports.
36
+
37
+ `short_help` must match the real command's docstring summary; the parity
38
+ test in tests/unittests/cli/test_click.py enforces this.
39
+
40
+ Pattern reference:
41
+ https://click.palletsprojects.com/en/stable/complex/#lazily-loading-subcommands
42
+
43
+ TODO: Python 3.15 introduces a native `lazy` import keyword (PEP 810). Once
44
+ our minimum Python version reaches 3.15, the `add_lazy_command` mechanism
45
+ here may become redundant — revisit and consider simplifying.
46
+ """
47
+
48
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
49
+ super().__init__(*args, **kwargs)
50
+ self._lazy_commands: dict[str, tuple[str, str]] = {}
51
+ # name -> experiment label
52
+ self._experiment_gates: dict[str, str] = {}
53
+ self._overrides: dict[str, click.Command] = {}
54
+
55
+ def add_lazy_command(
56
+ self,
57
+ name: str,
58
+ import_path: str,
59
+ short_help: str,
60
+ experiment: str | None = None,
61
+ ) -> None:
62
+ """Register a subcommand for lazy loading.
63
+
64
+ If `experiment` is given, the command is only listed and invocable when
65
+ that experiment resolves truthy; otherwise it behaves as if it were
66
+ never registered (hidden from --help, "No such command" on invoke).
67
+
68
+ The experiment gate is resolved per invocation in `list_commands`/`get_command`,
69
+ not at this registration call: `main` is built once at import, so
70
+ guarding registration would freeze visibility before a unit test could
71
+ override the experiment.
72
+ """
73
+ self._lazy_commands[name] = (import_path, short_help)
74
+ if experiment is not None:
75
+ self._experiment_gates[name] = experiment
76
+
77
+ def _is_visible(self, name: str) -> bool:
78
+ """True if `name` is not hidden via experiment."""
79
+ label = self._experiment_gates.get(name)
80
+ if label is None:
81
+ return True
82
+ return bool(resolve_experiment(label))
83
+
84
+ def list_commands(self, ctx: click.Context) -> list[str]:
85
+ names = set(super().list_commands(ctx)) | set(self._lazy_commands) | set(self._overrides)
86
+ return sorted(filter(self._is_visible, names))
87
+
88
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
89
+ # Overrides are consulted only after the visibility check, so an installed
90
+ # extension cannot expose a command that is gated off.
91
+ if not self._is_visible(cmd_name):
92
+ return None
93
+ if cmd_name in self._overrides:
94
+ return self._overrides[cmd_name]
95
+ if cmd_name in self._lazy_commands and cmd_name not in self.commands:
96
+ import_path, _ = self._lazy_commands[cmd_name]
97
+ module_path, attr = import_path.split(":")
98
+ cmd = getattr(importlib.import_module(module_path), attr)
99
+ patch_source_in_help(cmd)
100
+ self.commands[cmd_name] = cmd
101
+ return super().get_command(ctx, cmd_name)
102
+
103
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
104
+ rows: list[tuple[str, str]] = []
105
+ for name in self.list_commands(ctx):
106
+ if name in self._overrides:
107
+ rows.append((name, self._overrides[name].get_short_help_str(limit=1000)))
108
+ continue
109
+ if name in self.commands:
110
+ # Don't truncate — Click's default of 45 would cut our docstring summaries.
111
+ rows.append((name, self.commands[name].get_short_help_str(limit=1000)))
112
+ continue
113
+ lazy = self._lazy_commands.get(name)
114
+ if lazy is not None:
115
+ rows.append((name, lazy[1]))
116
+ if rows:
117
+ with formatter.section("Commands"):
118
+ formatter.write_dl(rows)
119
+
120
+
121
+ def _source_path(cmd: Any) -> str | None:
122
+ """Resolve the absolute file path of a command's callback module."""
123
+ cb = cmd.callback
124
+ if cb is None:
125
+ return None
126
+ try:
127
+ mod = importlib.import_module(cb.__module__)
128
+ return inspect.getfile(mod)
129
+ except Exception:
130
+ return None
131
+
132
+
133
+ def patch_source_in_help(cmd: Any) -> None:
134
+ """Recursively patch all commands to show source location in --help epilog.
135
+
136
+ Idempotent: a command registered as lazy in two parents (e.g., the
137
+ `create` alias) would otherwise get its `format_epilog` wrapped twice
138
+ and render two `Source:` lines.
139
+ """
140
+ if getattr(cmd, "_source_patched", False):
141
+ return
142
+
143
+ original = cmd.format_epilog
144
+
145
+ def _patched(ctx: click.Context, formatter: click.HelpFormatter) -> None:
146
+ original(ctx, formatter)
147
+ path = _source_path(cmd)
148
+ if path:
149
+ formatter.write("\n")
150
+ formatter.write(f"Source: {path}\n")
151
+
152
+ cmd.format_epilog = _patched
153
+ cmd._source_patched = True
154
+
155
+ if isinstance(cmd, click.Group):
156
+ for sub in cmd.commands.values():
157
+ patch_source_in_help(sub)