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,1151 @@
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
+ """graph-agents-cli peer commands: the other agents this agent asks, over A2A.
16
+
17
+ A peer is an API in ``api-policy.yaml`` with ``protocol: a2a``. ``peer add``
18
+ writes one, with everything the template's A2A client needs: the peer's
19
+ endpoint and card, the JSON-RPC methods it may send, the approve gate (or the
20
+ denial), the credential, limits for a model-bound peer, and the project's
21
+ files that follow (the manifest's ``secrets.keys``, ``.env.example``, the
22
+ chart's values). It then regenerates ``<agent_dir>/tools/a2a_peers.py`` from
23
+ the policy (``_generate.py``), the module that hands the model ``ask_agent``
24
+ and ``approve_agent_action``. ``peer remove``, ``peer sync`` keep that module in
25
+ step; ``peer list`` and ``peer show [--check]`` report. Every change is
26
+ validated with the runtime's rules, shown as one diff and written atomically
27
+ (``--dry-run`` stops after the diff). ``.env`` is never touched.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import copy
33
+ import json
34
+ from collections.abc import Callable
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ import click
40
+ from rich.markup import escape
41
+ from rich.table import Table
42
+
43
+ from graph_agents_cli._api_policy import (
44
+ A2A_APPROVE,
45
+ A2A_KEY,
46
+ A2A_OPERATION_KEY,
47
+ ALLOW_ACTORLESS_KEY,
48
+ DESCRIPTION_KEY,
49
+ DESCRIPTION_MAX_CHARS,
50
+ EXCHANGE_KEY,
51
+ MAX_APPROVAL_TIMEOUT_S,
52
+ MAX_RESPONSE_BYTES_LIMIT,
53
+ MIN_APPROVAL_TIMEOUT_S,
54
+ POLICY_FILENAME,
55
+ PROTOCOL_A2A,
56
+ PROTOCOL_KEY,
57
+ REQUESTER_APPROVER,
58
+ RPC_METHOD_KEY,
59
+ TOKEN_EXCHANGE_CLIENT_ID_ENV,
60
+ api_protocol,
61
+ auth_policy_findings,
62
+ summarize,
63
+ uses_exchange,
64
+ )
65
+ from graph_agents_cli._click import LazyGroup
66
+ from graph_agents_cli._output import Console, print_table
67
+ from graph_agents_cli.api import _changes as ch
68
+ from graph_agents_cli.api._files import (
69
+ Plan,
70
+ chart_values_files,
71
+ env_example_add,
72
+ env_example_remove,
73
+ print_diff,
74
+ read_text,
75
+ sync_manifest,
76
+ values_add,
77
+ values_env_set,
78
+ values_exchange_add,
79
+ values_exchange_remove,
80
+ values_remove,
81
+ )
82
+ from graph_agents_cli.api.cmd_api import (
83
+ _API_KEY_ORDER,
84
+ _edit,
85
+ _load_project,
86
+ _policy_header,
87
+ _Project,
88
+ _validate,
89
+ )
90
+ from graph_agents_cli.peer import _generate as gen
91
+ from graph_agents_cli.scaffold.utils.keyedit import YamlText, block_lines
92
+
93
+ PEER_LOCAL_URL = "http://localhost:8001"
94
+ DEFAULT_CALLS = ("ask", "status")
95
+ CALL_CHOICES = ("ask", "status", "cancel")
96
+ RELAY, DENY = gen.RELAY, gen.DENY
97
+ AUTH_BY_POLICY = {"jwt": "exchange", "custom": "forward", "shared-bearer": "bearer"}
98
+ DEFAULT_MAX_CALLS = 12
99
+ DEFAULT_READ_TIMEOUT_MS = 120_000
100
+ DEFAULT_CONNECT_TIMEOUT_MS = 2000
101
+ DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
102
+ DEFAULT_APPROVAL_TIMEOUT_S = 900
103
+ CARD_TIMEOUT_S = 5.0
104
+ RUNTIME_MODULE = "app_utils/a2a_client.py"
105
+ NOTHING = "Nothing to change."
106
+ SHARED_BEARER_NOTE = (
107
+ "under shared-bearer, any holder of API_KEY (another agent included) can decide requester "
108
+ "gates: the agent behind {api} cannot tell this agent from the person"
109
+ )
110
+
111
+
112
+ class PeerCommandError(click.ClickException):
113
+ """A check failed or the project cannot take the change (exit 3)."""
114
+
115
+ exit_code = 3
116
+
117
+
118
+ @click.group("peer", cls=LazyGroup)
119
+ def peer_group() -> None:
120
+ """Declare the other agents this agent asks, over A2A (its peers).
121
+
122
+ A peer is an api-policy.yaml API with `protocol: a2a`. `peer add NAME`
123
+ writes it (the endpoint, the JSON-RPC methods it may send, the approve gate
124
+ or its denial, the credential, the limits) with the manifest, .env.example
125
+ and chart values that follow, and regenerates <agent_dir>/tools/a2a_peers.py,
126
+ which gives the model `ask_agent` and, for peers it relays approvals to,
127
+ `approve_agent_action`. It then prints what is left to set, here and on the
128
+ peer. Every change is validated, shown as one diff and written atomically;
129
+ --dry-run prints the diff only. .env is never touched.
130
+
131
+ \b
132
+ Exit codes:
133
+ 0 changed, or nothing to change
134
+ 1 show --check: the peer is unreachable, or its card names another endpoint
135
+ 2 usage error
136
+ 3 not in a project, a 0.2 runtime, an invalid policy, or a change it cannot make
137
+ """
138
+
139
+
140
+ def _dry_run_option(function: Callable[..., Any]) -> Callable[..., Any]:
141
+ return click.option(
142
+ "--dry-run", "dry_run", is_flag=True, default=False, help="Print the diff only."
143
+ )(function)
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # The project
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ def _project() -> _Project:
152
+ project = _load_project()
153
+ problem = runtime_problem(project.root, project.config.agent_directory)
154
+ if problem:
155
+ raise PeerCommandError(problem)
156
+ return project
157
+
158
+
159
+ def runtime_problem(root: Path, agent_directory: str) -> str | None:
160
+ """Why the project's runtime cannot have peers (a 0.2 runtime), or None."""
161
+ if (root / agent_directory / RUNTIME_MODULE).is_file():
162
+ return None
163
+ return (
164
+ f"this project's runtime predates A2A peers (no {agent_directory}/{RUNTIME_MODULE}): run "
165
+ "`graph-agents-cli scaffold upgrade` first. A 0.2 runtime refuses every call once "
166
+ "api-policy.yaml uses protocol: a2a (as KI-007)."
167
+ )
168
+
169
+
170
+ def _module(project: _Project) -> tuple[str, str | None]:
171
+ """The generated module's path (from the root) and its text (None: absent)."""
172
+ path = gen.module_path(project.root, project.config.agent_directory)
173
+ rel = path.relative_to(project.root).as_posix()
174
+ text = read_text(path)
175
+ if text is not None and not gen.is_generated(text):
176
+ raise PeerCommandError(
177
+ f"{rel} exists and was not generated by graph-agents-cli (no marker line): move "
178
+ f"it; graph-agents-cli owns tools/{gen.MODULE_NAME}"
179
+ )
180
+ return rel, text
181
+
182
+
183
+ def _names(project: _Project, extra: dict[str, str] | None = None) -> dict[str, str]:
184
+ """The peer name of each API: the generated module's, plus `extra` (API -> name)."""
185
+ _rel, text = _module(project)
186
+ return {**gen.names_in(text), **(extra or {})}
187
+
188
+
189
+ def _sync_module(
190
+ plan: Plan,
191
+ project: _Project,
192
+ document: dict[str, Any] | None,
193
+ extra: dict[str, str] | None = None,
194
+ ) -> None:
195
+ rel, before = _module(project)
196
+ peers = gen.peers_of(document, _names(project, extra))
197
+ plan.set_text(rel, before, gen.module_text(peers, project.config.agent_directory))
198
+
199
+
200
+ def _peers(project: _Project) -> dict[str, gen.Peer]:
201
+ return {peer.name: peer for peer in gen.peers_of(project.document, _names(project))}
202
+
203
+
204
+ def _peer(project: _Project, name: str) -> gen.Peer:
205
+ peers = _peers(project)
206
+ if name not in peers:
207
+ known = ", ".join(peers) or "none"
208
+ raise PeerCommandError(f"no peer {name!r} in {POLICY_FILENAME} (peers: {known})")
209
+ return peers[name]
210
+
211
+
212
+ def _client_id(project: _Project) -> str:
213
+ """This agent's client id at the issuer, as the chart sets it (else the project's name)."""
214
+ files = chart_values_files(project.root, project.config)
215
+ if files:
216
+ try:
217
+ env = YamlText(read_text(files[0]) or "").get(("env",))
218
+ except Exception:
219
+ env = None
220
+ value = env.get(TOKEN_EXCHANGE_CLIENT_ID_ENV) if isinstance(env, dict) else None
221
+ if isinstance(value, str) and value and "CHANGE-ME" not in value:
222
+ return value
223
+ return project.config.project_name or "<this agent's client id>"
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # The card (--card on add, --check on show)
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ def _read_card(source: str) -> dict[str, Any]:
232
+ """A card from a file, or fetched (no credential) from a URL; ValueError when unreadable."""
233
+ if source.startswith(("http://", "https://")):
234
+ import httpx
235
+
236
+ response = httpx.get(
237
+ source,
238
+ headers={"A2A-Version": "1.0"},
239
+ timeout=CARD_TIMEOUT_S,
240
+ follow_redirects=False,
241
+ )
242
+ if response.status_code == 401:
243
+ raise ValueError("401: the card needs a credential (KI-120)")
244
+ response.raise_for_status()
245
+ data = response.json()
246
+ else:
247
+ data = json.loads(Path(source).read_text(encoding="utf-8"))
248
+ if not isinstance(data, dict):
249
+ raise ValueError("not an agent card (a JSON object)")
250
+ return data
251
+
252
+
253
+ def _card_interface(card: dict[str, Any]) -> dict[str, Any] | None:
254
+ for interface in card.get("supportedInterfaces") or []:
255
+ if (
256
+ isinstance(interface, dict)
257
+ and str(interface.get("protocolBinding", "")).upper() == "JSONRPC"
258
+ and str(interface.get("protocolVersion", "")).startswith("1.")
259
+ ):
260
+ return interface
261
+ return None
262
+
263
+
264
+ def _card_reads_origin(card: dict[str, Any]) -> bool:
265
+ capabilities = card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {}
266
+ return any(
267
+ isinstance(e, dict) and e.get("uri") == ORIGIN_EXTENSION
268
+ for e in capabilities.get("extensions") or []
269
+ )
270
+
271
+
272
+ ORIGIN_EXTENSION = "https://ss7172.github.io/graph-agents-cli/a2a/ext/origin/v1"
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # add
277
+ # ---------------------------------------------------------------------------
278
+
279
+
280
+ def _parse_calls(value: str) -> list[str]:
281
+ calls = [part.strip() for part in value.split(",") if part.strip()]
282
+ unknown = [c for c in calls if c not in CALL_CHOICES]
283
+ if unknown or "ask" not in calls:
284
+ raise click.UsageError(
285
+ f"--calls takes a comma list of {', '.join(CALL_CHOICES)}, with ask (got {value!r})"
286
+ )
287
+ return calls
288
+
289
+
290
+ def _entry(
291
+ *,
292
+ description: str | None,
293
+ path: str,
294
+ url_env: str,
295
+ auth: str,
296
+ token_env: str | None,
297
+ audience: str | None,
298
+ scope: str | None,
299
+ resource: str | None,
300
+ allow_actorless: bool,
301
+ forward_audience: bool,
302
+ calls: list[str],
303
+ approvals: str,
304
+ approval_timeout_s: int,
305
+ max_calls_per_run: int,
306
+ read_timeout_ms: int,
307
+ max_response_bytes: int,
308
+ ) -> dict[str, Any]:
309
+ """The api-policy entry of a peer, its keys in the schema's order."""
310
+ api: dict[str, Any] = {}
311
+ if description:
312
+ api[DESCRIPTION_KEY] = description
313
+ api[PROTOCOL_KEY] = PROTOCOL_A2A
314
+ api[A2A_KEY] = {"path": path}
315
+ api["base_url_env"] = url_env
316
+ api["auth"] = auth
317
+ if auth == "bearer":
318
+ api["token_env"] = token_env
319
+ if auth == "forward" and forward_audience and audience:
320
+ api["forward_audience"] = audience
321
+ if auth == "exchange":
322
+ exchange: dict[str, Any] = {"audience": audience}
323
+ if scope:
324
+ exchange["scope"] = scope
325
+ if resource:
326
+ exchange["resource"] = resource
327
+ if allow_actorless:
328
+ exchange[ALLOW_ACTORLESS_KEY] = True
329
+ api[EXCHANGE_KEY] = exchange
330
+ api["allowed_methods"] = ["GET", "POST"]
331
+ operations: list[dict[str, Any]] = [
332
+ {
333
+ "operationId": gen.CARD_OPERATION,
334
+ "methods": ["GET"],
335
+ "path": f"{path}{gen.CARD_SUFFIX}",
336
+ },
337
+ {RPC_METHOD_KEY: "SendMessage", "methods": ["POST"], "path": path},
338
+ ]
339
+ if "status" in calls or approvals == RELAY: # the relay reads the task first
340
+ operations.append({RPC_METHOD_KEY: "GetTask", "methods": ["POST"], "path": path})
341
+ if "cancel" in calls:
342
+ operations.append({RPC_METHOD_KEY: "CancelTask", "methods": ["POST"], "path": path})
343
+ if approvals == RELAY:
344
+ operations.append(
345
+ {"operationId": gen.LEDGER_OPERATION, "methods": ["GET"], "path": gen.LEDGER_PATH}
346
+ )
347
+ api[ch.ALLOWED] = operations
348
+ if approvals == DENY:
349
+ api[ch.DENIED] = [{A2A_OPERATION_KEY: A2A_APPROVE}]
350
+ api["timeouts_ms"] = {"connect": DEFAULT_CONNECT_TIMEOUT_MS, "read": read_timeout_ms}
351
+ api["limits"] = {
352
+ "max_calls_per_run": max_calls_per_run,
353
+ "max_response_bytes": max_response_bytes,
354
+ }
355
+ if approvals == RELAY:
356
+ api["approval"] = [
357
+ {
358
+ "required_for": {"operations": [{A2A_OPERATION_KEY: A2A_APPROVE}]},
359
+ "approvers": [REQUESTER_APPROVER],
360
+ "timeout_s": approval_timeout_s,
361
+ }
362
+ ]
363
+ return api
364
+
365
+
366
+ def _same_peer(existing: dict[str, Any], path: str) -> bool:
367
+ return (
368
+ api_protocol(existing) == PROTOCOL_A2A and (existing.get(A2A_KEY) or {}).get("path") == path
369
+ )
370
+
371
+
372
+ def left_for_you(
373
+ project: _Project,
374
+ name: str,
375
+ api_name: str,
376
+ api: dict[str, Any],
377
+ *,
378
+ first_exchange: bool,
379
+ cluster_envs: list[str],
380
+ ) -> list[str]:
381
+ """What `peer add` cannot do: settings here, at the issuer, and on the peer (printed)."""
382
+ config = project.config
383
+ client = _client_id(project)
384
+ url_env = api["base_url_env"]
385
+ items = []
386
+ where = "in .env (local runs)"
387
+ if config.deployment_target == "kubernetes":
388
+ missing = [e for e in _environments(project) if e not in cluster_envs]
389
+ if missing:
390
+ where += (
391
+ f" and in values-{{{','.join(missing)}}}.yaml (values.yaml holds a placeholder)"
392
+ )
393
+ items.append(f"set {url_env} (the base URL of {name}) {where}")
394
+ auth = api["auth"]
395
+ audience = (api.get(EXCHANGE_KEY) or {}).get("audience") or api.get("forward_audience")
396
+ if auth == "exchange":
397
+ if first_exchange:
398
+ items.append(
399
+ "set TOKEN_EXCHANGE_URL (the issuer's token endpoint) and TOKEN_EXCHANGE_CLIENT_ID "
400
+ "(this agent's client there) in .env and in the chart's values-<env>.yaml; put "
401
+ "TOKEN_EXCHANGE_CLIENT_SECRET in .env (and .env.<env>, then `graph-agents-cli "
402
+ "secrets apply --env <env>`)"
403
+ )
404
+ items.append(
405
+ f"at the issuer: let this agent's client ({client}) exchange users' tokens for "
406
+ f"audience {audience}, naming it in the exchanged token's act claim"
407
+ )
408
+ items.append(
409
+ f"on {name}: AUTH_JWT_AUDIENCE includes {audience}, and AUTH_ALLOWED_ACTORS "
410
+ f"includes {client} (the actor id {name} sees is the act.sub of the exchanged "
411
+ f"token: if the issuer names this agent otherwise there, for example agent:{client}, "
412
+ "list that id instead, and give it to --relayers below)"
413
+ )
414
+ actorless = (api.get(EXCHANGE_KEY) or {}).get(ALLOW_ACTORLESS_KEY) is True
415
+ if actorless:
416
+ items.append(
417
+ f"on {name} (required: this agent sends tokens that name no actor, "
418
+ f"exchange.allow_actorless): AUTH_JWT_DIRECT_CLIENTS=<the clients people sign in "
419
+ f"with>, and client:{client} in AUTH_ALLOWED_ACTORS; otherwise {name} reads this "
420
+ "agent's calls as the person's own"
421
+ )
422
+ else:
423
+ items.append(
424
+ "if the issuer's exchanged tokens name no actor (no act claim; Keycloak adds "
425
+ "none: decode one to see), this agent refuses them: then add --allow-actorless "
426
+ f"(exchange.allow_actorless) and, on {name}, set AUTH_JWT_DIRECT_CLIENTS=<the "
427
+ f"clients people sign in with> and list client:{client} in AUTH_ALLOWED_ACTORS"
428
+ )
429
+ relayer = f"client:{client}" if actorless else client
430
+ elif auth == "bearer":
431
+ items.append(
432
+ f"put {api['token_env']} (the key {name} accepts, its API_KEY) in .env"
433
+ + (
434
+ " and .env.<env>, then `graph-agents-cli secrets apply --env <env>`"
435
+ if config.deployment_target == "kubernetes"
436
+ else ""
437
+ )
438
+ )
439
+ items.append(SHARED_BEARER_NOTE.format(api=name))
440
+ relayer = client
441
+ else:
442
+ items.append(
443
+ f"make the auth policy give callers attributes['credentials']['{api_name}'] (the "
444
+ f"credential forwarded to {name}), and have {name}'s policy set the actor for it "
445
+ "(a custom policy that forwards users' credentials must, or the callee treats this "
446
+ "agent as the person)"
447
+ )
448
+ relayer = client
449
+ if api.get("approval"):
450
+ items.append(
451
+ f"on {name}, to let this agent relay the person's decisions: `graph-agents-cli api "
452
+ f"approval <its gated API> --decide-with relayed --relayers {relayer}` for each gate "
453
+ f"a relay should reach (a reviewed loosening there; without it the person approves "
454
+ f"at {name} directly)"
455
+ )
456
+ items.append(
457
+ f"set PRINCIPAL_HASH_SALT (a secret): the conversation ids sent to {name} are keyed with it"
458
+ )
459
+ items.append(
460
+ f"then `graph-agents-cli lint`, `graph-agents-cli peer show {name} --check`, and an eval "
461
+ "case at the agent people talk to"
462
+ )
463
+ return items
464
+
465
+
466
+ def _environments(project: _Project) -> list[str]:
467
+ return [
468
+ path.stem.removeprefix("values-")
469
+ for path in chart_values_files(project.root, project.config)[1:]
470
+ ]
471
+
472
+
473
+ def _finish(
474
+ project: _Project,
475
+ plan: Plan,
476
+ *,
477
+ dry_run: bool,
478
+ notes: list[str],
479
+ todos: list[str],
480
+ ) -> None:
481
+ console = Console()
482
+ if not plan.effective:
483
+ console.print(NOTHING)
484
+ return
485
+ print_diff(plan.diff())
486
+ click.echo()
487
+ for note in notes:
488
+ console.print(f"Note: {escape(note)}", style="yellow", highlight=False)
489
+ if dry_run:
490
+ console.print("Dry run: nothing was written.", style="yellow")
491
+ else:
492
+ plan.write()
493
+ written = ", ".join(change.path for change in plan.effective)
494
+ console.print(f"Wrote {escape(written)}.", style="green")
495
+ left = [*plan.left_for_you, *todos]
496
+ if left:
497
+ console.print("Left for you:", style="bold")
498
+ for item in left:
499
+ console.print(f" - {escape(item)}", highlight=False)
500
+
501
+
502
+ @peer_group.command("add")
503
+ @click.argument("name")
504
+ @click.option("--api-name", "api_name", default=None, help="The API's name (default <NAME>_agent).")
505
+ @click.option(
506
+ "--url-env", "url_env", default=None, help="Base URL variable (default <NAME>_AGENT_URL)."
507
+ )
508
+ @click.option(
509
+ "--path",
510
+ "path",
511
+ default=None,
512
+ help="The peer's A2A endpoint (default /a2a/<NAME>: its agent directory or A2A_NAME).",
513
+ )
514
+ @click.option(
515
+ "--auth",
516
+ "auth",
517
+ type=click.Choice(["exchange", "forward", "bearer"]),
518
+ default=None,
519
+ help="Default by the auth policy: jwt exchange, custom forward, shared-bearer bearer.",
520
+ )
521
+ @click.option(
522
+ "--audience",
523
+ "audience",
524
+ default=None,
525
+ help="exchange: the audience the issuer mints for (default NAME); forward (jwt): forward_audience.",
526
+ )
527
+ @click.option("--scope", "scope", default=None, help="exchange: the scopes to ask for.")
528
+ @click.option("--resource", "resource", default=None, help="exchange: the resource indicator.")
529
+ @click.option(
530
+ "--allow-actorless",
531
+ "allow_actorless",
532
+ is_flag=True,
533
+ default=False,
534
+ help=(
535
+ "exchange: accept exchanged tokens that name no actor; the peer must then set "
536
+ "AUTH_JWT_DIRECT_CLIENTS and list client:<this agent's client id>."
537
+ ),
538
+ )
539
+ @click.option(
540
+ "--token-env",
541
+ "token_env",
542
+ default=None,
543
+ help="bearer: the key's variable (default <NAME>_AGENT_KEY).",
544
+ )
545
+ @click.option(
546
+ "--description",
547
+ "description",
548
+ default=None,
549
+ help=f"What the peer does, for the model's roster (at most {DESCRIPTION_MAX_CHARS} characters).",
550
+ )
551
+ @click.option(
552
+ "--card",
553
+ "card",
554
+ default=None,
555
+ metavar="URL|FILE",
556
+ help="Read the description, the path and origin support from the peer's agent card.",
557
+ )
558
+ @click.option(
559
+ "--calls",
560
+ "calls",
561
+ default=",".join(DEFAULT_CALLS),
562
+ show_default=True,
563
+ help="What the agent may send: ask (SendMessage), status (GetTask), cancel (CancelTask).",
564
+ )
565
+ @click.option(
566
+ "--approvals",
567
+ "approvals",
568
+ type=click.Choice([RELAY, DENY]),
569
+ default=RELAY,
570
+ show_default=True,
571
+ help=(
572
+ "relay: messages that approve the peer's pending approvals wait for the person here "
573
+ "(gated: requester); deny: this agent never sends them."
574
+ ),
575
+ )
576
+ @click.option(
577
+ "--approval-timeout-s",
578
+ type=click.IntRange(MIN_APPROVAL_TIMEOUT_S, MAX_APPROVAL_TIMEOUT_S),
579
+ default=DEFAULT_APPROVAL_TIMEOUT_S,
580
+ show_default=True,
581
+ )
582
+ @click.option(
583
+ "--max-calls-per-run", type=click.IntRange(min=1), default=DEFAULT_MAX_CALLS, show_default=True
584
+ )
585
+ @click.option(
586
+ "--read-timeout-ms",
587
+ type=click.IntRange(min=1),
588
+ default=DEFAULT_READ_TIMEOUT_MS,
589
+ show_default=True,
590
+ help="A peer runs a model: its answers take time.",
591
+ )
592
+ @click.option(
593
+ "--max-response-bytes",
594
+ type=click.IntRange(1, MAX_RESPONSE_BYTES_LIMIT),
595
+ default=DEFAULT_MAX_RESPONSE_BYTES,
596
+ show_default=True,
597
+ )
598
+ @click.option(
599
+ "--cluster-url",
600
+ "cluster_url",
601
+ default=None,
602
+ metavar="TEMPLATE",
603
+ help=(
604
+ "The peer's URL per environment, with {env}, for values-<env>.yaml "
605
+ "(e.g. http://orders-agent.orders-agent-{env}.svc.cluster.local)."
606
+ ),
607
+ )
608
+ @_dry_run_option
609
+ def cmd_add(
610
+ name: str,
611
+ api_name: str | None,
612
+ url_env: str | None,
613
+ path: str | None,
614
+ auth: str | None,
615
+ audience: str | None,
616
+ scope: str | None,
617
+ resource: str | None,
618
+ allow_actorless: bool,
619
+ token_env: str | None,
620
+ description: str | None,
621
+ card: str | None,
622
+ calls: str,
623
+ approvals: str,
624
+ approval_timeout_s: int,
625
+ max_calls_per_run: int,
626
+ read_timeout_ms: int,
627
+ max_response_bytes: int,
628
+ cluster_url: str | None,
629
+ dry_run: bool,
630
+ ) -> None:
631
+ """Add another agent this agent asks (a protocol: a2a API), and regenerate tools/a2a_peers.py.
632
+
633
+ Defaults fit a peer made with graph-agents-cli: its endpoint /a2a/NAME, the credential
634
+ by this project's auth policy (jwt: a token exchanged for the user's, audience NAME),
635
+ SendMessage and GetTask, the person's approval relayed through a gate here, 12 calls per
636
+ run, a 120 s read timeout and a 1 MiB answer cap. It prints what is left to set: here,
637
+ at the issuer, and on the peer (AUTH_ALLOWED_ACTORS, and the `api approval
638
+ --decide-with relayed --relayers` line that lets this agent relay there).
639
+ """
640
+ if not gen.PEER_NAME_RE.fullmatch(name):
641
+ raise click.UsageError(
642
+ f"peer name {name!r}: lowercase letters, digits and underscores, starting with a "
643
+ "letter, at most 26 characters"
644
+ )
645
+ parsed_calls = _parse_calls(calls)
646
+ project = _project()
647
+ plan = Plan(project.root)
648
+ added = plan_add(
649
+ project,
650
+ plan,
651
+ PeerOptions(
652
+ name=name,
653
+ api_name=api_name,
654
+ url_env=url_env,
655
+ path=path,
656
+ auth=auth,
657
+ audience=audience,
658
+ scope=scope,
659
+ resource=resource,
660
+ allow_actorless=allow_actorless,
661
+ token_env=token_env,
662
+ description=description,
663
+ card=card,
664
+ calls=tuple(parsed_calls),
665
+ approvals=approvals,
666
+ approval_timeout_s=approval_timeout_s,
667
+ max_calls_per_run=max_calls_per_run,
668
+ read_timeout_ms=read_timeout_ms,
669
+ max_response_bytes=max_response_bytes,
670
+ cluster_url=cluster_url,
671
+ ),
672
+ )
673
+ _finish(project, plan, dry_run=dry_run, notes=added.notes, todos=added.todos)
674
+
675
+
676
+ @dataclass
677
+ class PeerOptions:
678
+ """What `peer add` is asked for (its options), for `plan_add`."""
679
+
680
+ name: str
681
+ api_name: str | None = None
682
+ url_env: str | None = None
683
+ path: str | None = None
684
+ auth: str | None = None
685
+ audience: str | None = None
686
+ scope: str | None = None
687
+ resource: str | None = None
688
+ allow_actorless: bool = False
689
+ token_env: str | None = None
690
+ description: str | None = None
691
+ card: str | None = None
692
+ calls: tuple[str, ...] = DEFAULT_CALLS
693
+ approvals: str = RELAY
694
+ approval_timeout_s: int = DEFAULT_APPROVAL_TIMEOUT_S
695
+ max_calls_per_run: int = DEFAULT_MAX_CALLS
696
+ read_timeout_ms: int = DEFAULT_READ_TIMEOUT_MS
697
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES
698
+ cluster_url: str | None = None
699
+ # The peer's URL in .env.example (a local run).
700
+ local_url: str = PEER_LOCAL_URL
701
+
702
+
703
+ @dataclass
704
+ class PeerAdded:
705
+ """What `plan_add` planned: the project as it will be, and what to tell the user."""
706
+
707
+ project: _Project # its policy text and document with the peer in
708
+ api_name: str
709
+ api: dict[str, Any]
710
+ notes: list[str]
711
+ todos: list[str]
712
+ names: dict[str, str] # API -> peer name for the generated module, this peer's included
713
+
714
+
715
+ # An existing peer's settings `plan_add(keep_tuning=True)` keeps when it rewrites the entry
716
+ # (`system apply`): what the system file does not say. The rest follows the file.
717
+ TUNED_KEYS = ("timeouts_ms", "limits", "forward_header")
718
+
719
+
720
+ def _retuned(api: dict[str, Any], existing: dict[str, Any]) -> dict[str, Any]:
721
+ """``api`` with the tuning of the ``existing`` entry it replaces carried over."""
722
+ new = copy.deepcopy(api)
723
+ for key in TUNED_KEYS:
724
+ if key in existing:
725
+ new[key] = copy.deepcopy(existing[key])
726
+ old_exchange = existing.get(EXCHANGE_KEY) or {}
727
+ if EXCHANGE_KEY in new and "resource" in old_exchange and "resource" not in new[EXCHANGE_KEY]:
728
+ new[EXCHANGE_KEY]["resource"] = old_exchange["resource"]
729
+ old_rules, new_rules = existing.get("approval"), new.get("approval")
730
+ if isinstance(old_rules, list) and isinstance(new_rules, list):
731
+ for old_rule, new_rule in zip(old_rules, new_rules, strict=False):
732
+ if isinstance(old_rule, dict) and "timeout_s" in old_rule:
733
+ new_rule["timeout_s"] = old_rule["timeout_s"]
734
+ if DESCRIPTION_KEY not in new and existing.get(DESCRIPTION_KEY):
735
+ new[DESCRIPTION_KEY] = existing[DESCRIPTION_KEY]
736
+ # The schema's key order, as every command writes an entry.
737
+ rank = {key: index for index, key in enumerate(_API_KEY_ORDER)}
738
+ return {key: new[key] for key in sorted(new, key=lambda k: rank.get(k, len(rank)))}
739
+
740
+
741
+ def plan_add(
742
+ project: _Project,
743
+ plan: Plan,
744
+ options: PeerOptions,
745
+ *,
746
+ names: dict[str, str] | None = None,
747
+ keep_tuning: bool = False,
748
+ ) -> PeerAdded:
749
+ """Plan a peer's entry and every file that follows it into ``plan``.
750
+
751
+ ``project`` holds the policy as earlier plans left it, so several peers can be
752
+ planned into one ``plan`` (``names``: the peer names planned so far, API -> name).
753
+ An entry of the same name that differs is refused, unless ``keep_tuning``
754
+ (``system apply``): a peer's entry is then rewritten to the options, keeping the
755
+ settings the options do not decide (``TUNED_KEYS``, the approval timeout, the
756
+ exchange resource and a description the options do not give).
757
+ """
758
+ config = project.config
759
+ name = options.name
760
+ if name in (config.agent_directory, config.project_name):
761
+ raise click.UsageError(f"an agent cannot be its own peer ({name})")
762
+ auth = options.auth or AUTH_BY_POLICY.get(config.auth_policy, "bearer")
763
+ if auth != "exchange" and (options.scope or options.resource or options.allow_actorless):
764
+ raise click.UsageError("--scope, --resource and --allow-actorless go with --auth exchange")
765
+ if auth != "bearer" and options.token_env:
766
+ raise click.UsageError("--token-env goes with --auth bearer")
767
+ notes: list[str] = []
768
+ path, description = options.path, options.description
769
+ if options.card is not None:
770
+ card = options.card
771
+ try:
772
+ data = _read_card(card)
773
+ except Exception as exc:
774
+ notes.append(f"the card {card} could not be read ({exc}); continuing without it")
775
+ data = None
776
+ if data is not None:
777
+ interface = _card_interface(data)
778
+ if path is None and interface is not None:
779
+ import urllib.parse
780
+
781
+ path = urllib.parse.urlsplit(str(interface.get("url"))).path.rstrip("/") or None
782
+ if description is None and isinstance(data.get("description"), str):
783
+ description = data["description"]
784
+ notes.append(
785
+ f"{name}'s card "
786
+ + (
787
+ "declares the origin extension: the user's own words go with each request"
788
+ if _card_reads_origin(data)
789
+ else "does not declare the origin extension: the user's words stay here"
790
+ )
791
+ )
792
+ api_name = options.api_name or f"{name}{gen.AGENT_SUFFIX}"
793
+ url_env = options.url_env or f"{name.upper()}_AGENT_URL"
794
+ path = path or f"/a2a/{name}"
795
+ token_env = options.token_env or (f"{name.upper()}_AGENT_KEY" if auth == "bearer" else None)
796
+ if description is not None:
797
+ description = " ".join(description.split())
798
+ if len(description) > DESCRIPTION_MAX_CHARS:
799
+ description = description[: DESCRIPTION_MAX_CHARS - 3].rstrip() + "..."
800
+ notes.append(f"the description was cut to {DESCRIPTION_MAX_CHARS} characters")
801
+ api = _entry(
802
+ description=description,
803
+ path=path,
804
+ url_env=url_env,
805
+ auth=auth,
806
+ token_env=token_env,
807
+ audience=options.audience or name,
808
+ scope=options.scope,
809
+ resource=options.resource,
810
+ allow_actorless=options.allow_actorless,
811
+ forward_audience=auth == "forward"
812
+ and (options.audience is not None or config.auth_policy == "jwt"),
813
+ calls=list(options.calls),
814
+ approvals=options.approvals,
815
+ approval_timeout_s=options.approval_timeout_s,
816
+ max_calls_per_run=options.max_calls_per_run,
817
+ read_timeout_ms=options.read_timeout_ms,
818
+ max_response_bytes=options.max_response_bytes,
819
+ )
820
+ existing = (project.document or {}).get("apis", {}).get(api_name)
821
+ if existing is not None and existing != api:
822
+ if keep_tuning and api_protocol(existing) == PROTOCOL_A2A:
823
+ api = _retuned(api, existing)
824
+ elif _same_peer(existing, path):
825
+ raise PeerCommandError(
826
+ f"peer {name} exists with other settings; change it with `graph-agents-cli api "
827
+ f"...` ({api_name}), or `peer remove {name}` then `peer add {name}`"
828
+ )
829
+ else:
830
+ raise PeerCommandError(f"API {api_name} exists; pick --api-name")
831
+ if DESCRIPTION_KEY not in api:
832
+ notes.append(
833
+ f"no --description: the model's roster says {name} has none (lint warns); give one "
834
+ "with --description or --card"
835
+ )
836
+ document = ch.with_api(project.document, api_name, api)
837
+ _validate(project, document)
838
+ errors, matrix_notes = auth_policy_findings({"apis": {api_name: api}}, config.auth_policy)
839
+ if errors:
840
+ lines = "\n".join(f" - {error}" for error in errors)
841
+ raise PeerCommandError(f"{name} cannot work in this project; nothing was written:\n{lines}")
842
+ extra = {**(names or {}), api_name: name}
843
+ named = {p.api: p.name for p in gen.peers_of(document, _names(project, extra))}
844
+ if named.get(api_name) != name:
845
+ clash = next((api for api, n in named.items() if n == name and api != api_name), "?")
846
+ raise PeerCommandError(f"another peer is already called {name} (API {clash})")
847
+ text = project.text
848
+ if existing != api:
849
+ if project.text is None:
850
+ lines = [*_policy_header(config), "apis:", *block_lines({api_name: api}, 2)]
851
+ text = "\n".join(lines) + "\n"
852
+ else:
853
+ editor = project.editor()
854
+ verb = "add" if existing is None else "rewrite"
855
+ _edit(lambda: editor.set(("apis", api_name), api), f"{verb} apis.{api_name}")
856
+ text = editor.text
857
+ if YamlText(text).data != document:
858
+ raise PeerCommandError(f"could not write {POLICY_FILENAME} (internal check)")
859
+ plan.set_text(POLICY_FILENAME, read_text(project.root / POLICY_FILENAME), text)
860
+ # The same peer again: nothing in the policy changes, and the files that follow it are
861
+ # brought back in step (each edit below finds its work done when it is).
862
+ sync_manifest(plan, config, document=document, previous=project.document)
863
+ env_example_add(plan, api_name, api, peer=name, base_url=options.local_url)
864
+ values_add(plan, config, document, api_name)
865
+ values_exchange_add(plan, config, document, api_name)
866
+ cluster_envs = (
867
+ values_env_set(plan, config, url_env, options.cluster_url) if options.cluster_url else []
868
+ )
869
+ _sync_module(plan, project, document, extra)
870
+ previous = copy.deepcopy(project.document)
871
+ if previous is not None and existing is not None:
872
+ del previous["apis"][api_name]
873
+ first_exchange = auth == "exchange" and not (
874
+ previous is not None and uses_exchange(summarize(previous))
875
+ )
876
+ todos = left_for_you(
877
+ project, name, api_name, api, first_exchange=first_exchange, cluster_envs=cluster_envs
878
+ )
879
+ notes.extend(matrix_notes)
880
+ if options.approvals == RELAY:
881
+ notes.append(
882
+ f"messages that approve {name}'s pending approvals wait for the person here (gated: "
883
+ "requester): this agent relays their decision, never its own"
884
+ )
885
+ return PeerAdded(
886
+ project=_Project(project.root, config, text, document),
887
+ api_name=api_name,
888
+ api=api,
889
+ notes=notes,
890
+ todos=todos,
891
+ names=extra,
892
+ )
893
+
894
+
895
+ # ---------------------------------------------------------------------------
896
+ # remove, sync
897
+ # ---------------------------------------------------------------------------
898
+
899
+
900
+ @peer_group.command("remove")
901
+ @click.argument("name")
902
+ @_dry_run_option
903
+ def cmd_remove(name: str, dry_run: bool) -> None:
904
+ """Remove a peer (its API and variables), and regenerate or delete tools/a2a_peers.py."""
905
+ project = _project()
906
+ plan = Plan(project.root)
907
+ removed = plan_remove(project, plan, name)
908
+ _finish(project, plan, dry_run=dry_run, notes=removed.notes, todos=[])
909
+
910
+
911
+ @dataclass
912
+ class PeerRemoved:
913
+ """What `plan_remove` planned: the project as it will be, and what to tell the user."""
914
+
915
+ project: _Project
916
+ notes: list[str]
917
+
918
+
919
+ def plan_remove(
920
+ project: _Project, plan: Plan, name: str, *, names: dict[str, str] | None = None
921
+ ) -> PeerRemoved:
922
+ """Plan removing peer ``name`` and the files that follow it into ``plan`` (``project``
923
+ as earlier plans left it; ``names``: the peer names planned so far, API -> name)."""
924
+ peers = {p.name: p for p in gen.peers_of(project.document, _names(project, names))}
925
+ if name not in peers:
926
+ known = ", ".join(peers) or "none"
927
+ raise PeerCommandError(f"no peer {name!r} in {POLICY_FILENAME} (peers: {known})")
928
+ peer = peers[name]
929
+ assert project.document is not None
930
+ api = project.document["apis"][peer.api]
931
+ document = copy.deepcopy(project.document)
932
+ del document["apis"][peer.api]
933
+ after: dict[str, Any] | None = document if document["apis"] else None
934
+ stored = read_text(project.root / POLICY_FILENAME)
935
+ text: str | None
936
+ if after is None:
937
+ text = None
938
+ else:
939
+ editor = project.editor()
940
+ _edit(lambda: editor.delete(("apis", peer.api)), f"delete apis.{peer.api}")
941
+ text = editor.text
942
+ plan.set_text(POLICY_FILENAME, stored, text)
943
+ sync_manifest(plan, project.config, document=after, previous=project.document)
944
+ others = list(document["apis"].values())
945
+ keep = {str(o["base_url_env"]) for o in others} | {
946
+ str(o["token_env"]) for o in others if o.get("token_env")
947
+ }
948
+ env_example_remove(plan, peer.api, api, keep, document["apis"])
949
+ if api["base_url_env"] not in keep:
950
+ values_remove(
951
+ plan, project.config, api["base_url_env"], {str(o["base_url_env"]) for o in others}
952
+ )
953
+ if api["auth"] == EXCHANGE_KEY and not (after and uses_exchange(summarize(after))):
954
+ values_exchange_remove(plan, project.config)
955
+ _sync_module(plan, project, after, names)
956
+ notes = []
957
+ if peer.approvals == RELAY:
958
+ notes.append(
959
+ f"approvals this agent is relaying to {name} and still has pending will fail when "
960
+ "decided (the API they call is gone)"
961
+ )
962
+ if after is None:
963
+ notes.append(f"{name} was the only API: {POLICY_FILENAME} goes")
964
+ return PeerRemoved(_Project(project.root, project.config, text, after), notes)
965
+
966
+
967
+ @peer_group.command("sync")
968
+ @_dry_run_option
969
+ def cmd_sync(dry_run: bool) -> None:
970
+ """Regenerate tools/a2a_peers.py from api-policy.yaml (after `scaffold upgrade`, or `api` edits)."""
971
+ project = _project()
972
+ plan = Plan(project.root)
973
+ _sync_module(plan, project, project.document)
974
+ _finish(project, plan, dry_run=dry_run, notes=[], todos=[])
975
+
976
+
977
+ # ---------------------------------------------------------------------------
978
+ # list, show
979
+ # ---------------------------------------------------------------------------
980
+
981
+
982
+ def _url_of(project: _Project, variable: str) -> str | None:
983
+ """The peer's URL from the environment or .env (a URL only; nothing else is read)."""
984
+ import os
985
+
986
+ value = os.environ.get(variable)
987
+ if value:
988
+ return value
989
+ env_file = project.root / ".env"
990
+ if env_file.is_file():
991
+ from dotenv import dotenv_values
992
+
993
+ try:
994
+ found = dotenv_values(env_file).get(variable)
995
+ except Exception:
996
+ return None
997
+ return found or None
998
+ return None
999
+
1000
+
1001
+ def _row(project: _Project, peer: gen.Peer) -> dict[str, Any]:
1002
+ assert project.document is not None
1003
+ api = project.document["apis"][peer.api]
1004
+ exchange = api.get(EXCHANGE_KEY) or {}
1005
+ return {
1006
+ "name": peer.name,
1007
+ "api": peer.api,
1008
+ "url_env": api["base_url_env"],
1009
+ "url": _url_of(project, api["base_url_env"]),
1010
+ "path": peer.path,
1011
+ "auth": api["auth"],
1012
+ "audience": exchange.get("audience") or api.get("forward_audience"),
1013
+ "approvals": peer.approvals,
1014
+ "calls": [
1015
+ " ".join(
1016
+ str(part)
1017
+ for part in (
1018
+ c.get(RPC_METHOD_KEY) or c.get("operation_id"),
1019
+ c.get(A2A_OPERATION_KEY),
1020
+ )
1021
+ if part
1022
+ )
1023
+ for c in peer.calls
1024
+ ],
1025
+ "limits": api.get("limits") or {},
1026
+ "description": peer.description,
1027
+ }
1028
+
1029
+
1030
+ @peer_group.command("list")
1031
+ @click.option("--json", "as_json", is_flag=True, default=False, help="Print JSON.")
1032
+ def cmd_list(as_json: bool) -> None:
1033
+ """List the peers: name, API, URL, auth, audience, approvals and limits."""
1034
+ project = _project()
1035
+ rows = [_row(project, peer) for peer in _peers(project).values()]
1036
+ if as_json:
1037
+ click.echo(json.dumps(rows, indent=2))
1038
+ return
1039
+ console = Console()
1040
+ if not rows:
1041
+ console.print("No peers: add one with `graph-agents-cli peer add NAME`.")
1042
+ return
1043
+ table = Table(title="A2A peers")
1044
+ for header in ("Peer", "API", "URL", "Auth", "Audience", "Approvals", "Limits"):
1045
+ table.add_column(header, overflow="fold")
1046
+ for row in rows:
1047
+ limits = ", ".join(f"{k} {v}" for k, v in row["limits"].items())
1048
+ table.add_row(
1049
+ escape(row["name"]),
1050
+ escape(row["api"]),
1051
+ escape(f"{row['url_env']}={row['url'] or '(unset)'}"),
1052
+ row["auth"],
1053
+ escape(str(row["audience"] or "-")),
1054
+ row["approvals"],
1055
+ escape(limits or "-"),
1056
+ )
1057
+ print_table(console, table)
1058
+ if project.config.auth_policy == "shared-bearer":
1059
+ console.print(
1060
+ "Note: under shared-bearer, any holder of API_KEY (another agent included) can "
1061
+ "decide requester gates at a peer.",
1062
+ style="yellow",
1063
+ )
1064
+
1065
+
1066
+ def check_card(url: str, path: str, *, transport: Any = None) -> tuple[bool, list[str]]:
1067
+ """GET a peer's card (no credential) and check it; `(ok, lines to print)`."""
1068
+ import httpx
1069
+
1070
+ card_url = f"{url.rstrip('/')}{path}{gen.CARD_SUFFIX}"
1071
+ try:
1072
+ with httpx.Client(transport=transport, timeout=CARD_TIMEOUT_S) as client:
1073
+ response = client.get(card_url, headers={"A2A-Version": "1.0"})
1074
+ except httpx.HTTPError as exc:
1075
+ return False, [f"unreachable ({type(exc).__name__}): {card_url}"]
1076
+ if response.status_code == 401:
1077
+ return True, ["reachable (401: the card needs a credential; KI-120)"]
1078
+ if response.status_code != 200:
1079
+ return False, [f"unreachable (HTTP {response.status_code}): {card_url}"]
1080
+ try:
1081
+ card = response.json()
1082
+ except ValueError:
1083
+ return False, ["reachable, but its card is not JSON"]
1084
+ lines = ["reachable"]
1085
+ interface = _card_interface(card) if isinstance(card, dict) else None
1086
+ if interface is None:
1087
+ return False, [*lines, "its card offers no A2A 1.x JSON-RPC interface"]
1088
+ lines.append(f"protocol {interface.get('protocolVersion')}")
1089
+ expected = f"{url.rstrip('/')}{path}".rstrip("/")
1090
+ if str(interface.get("url", "")).rstrip("/") != expected:
1091
+ return False, [
1092
+ *lines,
1093
+ f"foreign endpoint: the card names {interface.get('url')}, not {expected} (set the "
1094
+ "peer's APP_URL, appUrl in its chart values)",
1095
+ ]
1096
+ name = path.rstrip("/").rsplit("/", 1)[-1]
1097
+ if card.get("name") != name:
1098
+ return False, [*lines, f"the card is agent {card.get('name')!r}, not {name!r}"]
1099
+ lines.append(
1100
+ "declares the origin extension"
1101
+ if _card_reads_origin(card)
1102
+ else "does not declare the origin extension (the user's words stay here)"
1103
+ )
1104
+ return True, lines
1105
+
1106
+
1107
+ @peer_group.command("show")
1108
+ @click.argument("name")
1109
+ @click.option("--json", "as_json", is_flag=True, default=False, help="Print JSON.")
1110
+ @click.option(
1111
+ "--check",
1112
+ "check",
1113
+ is_flag=True,
1114
+ default=False,
1115
+ help="Read the peer's card (no credential) and check its endpoint; exit 1 when it fails.",
1116
+ )
1117
+ def cmd_show(name: str, as_json: bool, check: bool) -> None:
1118
+ """Show a peer's entry and what is left to set; --check reads its card."""
1119
+ project = _project()
1120
+ peer = _peer(project, name)
1121
+ assert project.document is not None
1122
+ api = project.document["apis"][peer.api]
1123
+ todos = left_for_you(project, name, peer.api, api, first_exchange=False, cluster_envs=[])
1124
+ result: dict[str, Any] = {"peer": _row(project, peer), "entry": api, "left_for_you": todos}
1125
+ ok = True
1126
+ if check:
1127
+ url = _url_of(project, api["base_url_env"])
1128
+ if not url:
1129
+ ok, lines = False, [f"{api['base_url_env']} is not set (in the environment or .env)"]
1130
+ else:
1131
+ ok, lines = check_card(url, peer.path)
1132
+ result["check"] = {"ok": ok, "lines": lines}
1133
+ if as_json:
1134
+ click.echo(json.dumps(result, indent=2))
1135
+ else:
1136
+ console = Console()
1137
+ console.print(f"{escape(name)} ({escape(peer.api)}):", style="bold")
1138
+ click.echo(yaml_text({peer.api: api}))
1139
+ console.print("Left for you:", style="bold")
1140
+ for item in todos:
1141
+ console.print(f" - {escape(item)}", highlight=False)
1142
+ if check:
1143
+ console.print("Check:", style="bold")
1144
+ for line in result["check"]["lines"]:
1145
+ console.print(f" {escape(line)}", style="green" if ok else "red", highlight=False)
1146
+ if not ok:
1147
+ raise SystemExit(1)
1148
+
1149
+
1150
+ def yaml_text(value: dict[str, Any]) -> str:
1151
+ return "\n".join(block_lines(value, 2))