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,768 @@
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
+ """Comment-preserving edits of YAML and env files, key by key.
16
+
17
+ ``graph-agents-cli api`` changes ``api-policy.yaml``, the manifest, the chart's
18
+ ``values.yaml`` and ``.env.example``, all files people edit and review. A
19
+ round trip through a YAML library would reformat them and drop comments, so
20
+ this module edits the text at the positions the parser reports, the way
21
+ :mod:`keymerge` applies a template change:
22
+
23
+ * a scalar or flow value (``[GET, POST]``, ``{connect: 2000}``) is replaced
24
+ where it stands, so the comment after it stays;
25
+ * a new key is inserted after its neighbour, at the indentation of the
26
+ mapping, and a new list item after the last item, at the list's indentation;
27
+ * a removed key or list item takes its own lines and nothing else.
28
+
29
+ Every edit is checked: the new text is parsed again (a repeated key is
30
+ refused) and must equal the old document with exactly that change, at that
31
+ one key. The old document is compared as a copy in which nothing is shared,
32
+ so an edit that would also change another key through a YAML anchor and
33
+ alias (``orders: &o ...`` / ``billing: *o``) or a merge key is refused rather
34
+ than widening that other key silently. When the check fails, or the text uses
35
+ a shape the edit cannot handle safely (a merge key in the edited mapping, a
36
+ multi-line flow value, ...), :class:`EditError` is raised and nothing changes.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import copy
42
+ import re
43
+ from collections.abc import Callable, Mapping
44
+ from typing import Any
45
+
46
+ import yaml
47
+
48
+ from .keymerge import (
49
+ _OTHER_BREAKS,
50
+ _last_line,
51
+ _lines,
52
+ _newline,
53
+ )
54
+ from .merge3 import _parse_env, _StrictLoader
55
+
56
+
57
+ class EditError(Exception):
58
+ """The change cannot be made safely in this text; nothing was changed."""
59
+
60
+
61
+ Path = tuple[Any, ...]
62
+
63
+ _MERGE_TAG = "tag:yaml.org,2002:merge"
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Rendering values
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _is_collection(value: Any) -> bool:
72
+ return isinstance(value, dict | list)
73
+
74
+
75
+ def inline_ok(value: Any) -> bool:
76
+ """True when ``value`` is written on its key's line: a scalar, or a flat list or mapping."""
77
+ if isinstance(value, dict):
78
+ return not any(_is_collection(v) for v in value.values())
79
+ if isinstance(value, list):
80
+ return not any(_is_collection(v) for v in value)
81
+ return True
82
+
83
+
84
+ def inline(value: Any) -> str:
85
+ """``value`` as YAML on one line (flow style for collections)."""
86
+ text = yaml.safe_dump(
87
+ value, default_flow_style=True, width=1 << 30, sort_keys=False, allow_unicode=True
88
+ )
89
+ if text.endswith("\n...\n"):
90
+ text = text[: -len("\n...\n")]
91
+ return text.strip()
92
+
93
+
94
+ def block_lines(value: Any, column: int, step: int = 2) -> list[str]:
95
+ """``value`` (a mapping or a list) as block YAML lines starting at ``column``.
96
+
97
+ Flat collections inside it stay on their key's line (``methods: [GET]``);
98
+ list items that are mappings are written as block mappings
99
+ (``- operationId: x`` then `` path: /y``).
100
+ """
101
+ if isinstance(value, dict):
102
+ lines: list[str] = []
103
+ for key, item in value.items():
104
+ if inline_ok(item):
105
+ lines.append(f"{' ' * column}{inline(key)}: {inline(item)}")
106
+ else:
107
+ lines.append(f"{' ' * column}{inline(key)}:")
108
+ lines.extend(block_lines(item, column + step, step))
109
+ return lines
110
+ if isinstance(value, list):
111
+ lines = []
112
+ for item in value:
113
+ if isinstance(item, dict) and item:
114
+ inner = block_lines(item, column + 2, step)
115
+ lines.append(f"{' ' * column}- {inner[0][column + 2 :]}")
116
+ lines.extend(inner[1:])
117
+ elif _is_collection(item) and not inline_ok(item):
118
+ raise EditError("a list nested directly in a list")
119
+ else:
120
+ lines.append(f"{' ' * column}- {inline(item)}")
121
+ return lines
122
+ raise EditError(f"{value!r} is not a mapping or a list")
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # The document
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _parse(text: str) -> Any:
131
+ return yaml.load(text, Loader=_StrictLoader)
132
+
133
+
134
+ def _unshared(value: Any, _open: frozenset[int] = frozenset()) -> Any:
135
+ """``value`` rebuilt so that no mapping or list appears twice in it.
136
+
137
+ YAML aliases make the parser hand back one object at every place that
138
+ repeats it, and ``copy.deepcopy`` keeps that sharing: an edit applied to
139
+ such a copy would reach the other places too, and the check would accept
140
+ a text that changes them. A recursive alias cannot be rebuilt: EditError.
141
+ """
142
+ if not isinstance(value, dict | list):
143
+ return value
144
+ if id(value) in _open:
145
+ raise EditError("a recursive YAML alias")
146
+ inner = _open | {id(value)}
147
+ if isinstance(value, dict):
148
+ return {key: _unshared(item, inner) for key, item in value.items()}
149
+ return [_unshared(item, inner) for item in value]
150
+
151
+
152
+ def _uses_aliases(text: str) -> bool:
153
+ """True when the text repeats a node through an alias (``*name``, merge keys included)."""
154
+ try:
155
+ events = yaml.parse(text, Loader=yaml.SafeLoader)
156
+ return any(isinstance(event, yaml.AliasEvent) for event in events)
157
+ except yaml.YAMLError:
158
+ return False
159
+
160
+
161
+ class _Doc:
162
+ """One parse of the text: nodes with their positions."""
163
+
164
+ def __init__(self, text: str) -> None:
165
+ self.text = text
166
+ self.lines = _lines(text)
167
+ self.newline = _newline(text)
168
+ try:
169
+ self.root = yaml.compose(text, Loader=yaml.SafeLoader)
170
+ except yaml.YAMLError as exc:
171
+ raise EditError(f"not valid YAML: {exc}") from exc
172
+ self._loader = yaml.SafeLoader("")
173
+
174
+ def key_of(self, node: yaml.Node) -> Any:
175
+ return self._loader.construct_object(node, deep=True)
176
+
177
+ def pairs(self, node: yaml.MappingNode) -> list[tuple[yaml.Node, yaml.Node]]:
178
+ pairs = []
179
+ for key_node, value_node in node.value:
180
+ if key_node.tag == _MERGE_TAG or not isinstance(key_node, yaml.ScalarNode):
181
+ raise EditError("a merge key or a complex key")
182
+ pairs.append((key_node, value_node))
183
+ return pairs
184
+
185
+ def pair(self, node: yaml.MappingNode, key: Any) -> tuple[yaml.Node, yaml.Node] | None:
186
+ for key_node, value_node in self.pairs(node):
187
+ if self.key_of(key_node) == key:
188
+ return key_node, value_node
189
+ return None
190
+
191
+ def node(self, path: Path) -> yaml.Node | None:
192
+ node = self.root
193
+ for part in path:
194
+ if isinstance(node, yaml.MappingNode):
195
+ pair = self.pair(node, part)
196
+ if pair is None:
197
+ return None
198
+ node = pair[1]
199
+ elif isinstance(node, yaml.SequenceNode) and isinstance(part, int):
200
+ if not 0 <= part < len(node.value):
201
+ return None
202
+ node = node.value[part]
203
+ else:
204
+ return None
205
+ return node
206
+
207
+ def entry_lines(self, key_node: yaml.Node, value_node: yaml.Node) -> tuple[int, int]:
208
+ """First and last line of a block mapping entry."""
209
+ return key_node.start_mark.line, max(_last_line(value_node), key_node.end_mark.line)
210
+
211
+ def step(self) -> int:
212
+ """The indentation step of the first nested block mapping (2 when there is none)."""
213
+ pending = [self.root]
214
+ while pending:
215
+ node = pending.pop(0)
216
+ if not isinstance(node, yaml.MappingNode) or node.flow_style or not node.value:
217
+ continue
218
+ column = node.value[0][0].start_mark.column
219
+ for _key, child in node.value:
220
+ if isinstance(child, yaml.MappingNode) and not child.flow_style and child.value:
221
+ inner = child.value[0][0].start_mark.column
222
+ if inner > column:
223
+ return inner - column
224
+ pending.append(child)
225
+ return 2
226
+
227
+ def splice(self, start: int, end: int, replacement: str) -> str:
228
+ return self.text[:start] + replacement + self.text[end:]
229
+
230
+ def replace_lines(self, first: int, last: int, new: list[str]) -> str:
231
+ """Lines ``first..last`` (inclusive) replaced by ``new`` (each without a newline)."""
232
+ block = [line + self.newline for line in new]
233
+ tail = self.lines[last + 1 :]
234
+ if not tail and self.lines and not self.lines[last].endswith("\n") and block:
235
+ block[-1] = block[-1].rstrip("\r\n")
236
+ return "".join([*self.lines[:first], *block, *tail])
237
+
238
+ def after_trailing_comments(self, at: int, column: int, *, last: bool = False) -> int:
239
+ """``at`` moved past the comment lines that close the block above it.
240
+
241
+ Comments indented deeper than ``column`` belong to the entry above; so
242
+ do comments at ``column`` itself when that entry is the last of its
243
+ mapping (``# approval: ...`` at the end of an API). A new entry goes
244
+ after them. A comment at ``column`` before a following entry heads that
245
+ entry, so it stays below the new one.
246
+ """
247
+ while at < len(self.lines):
248
+ line = self.lines[at]
249
+ indent = len(line) - len(line.lstrip(" "))
250
+ if not line.lstrip(" ").startswith("#"):
251
+ break
252
+ if indent < column or (indent == column and not last):
253
+ break
254
+ at += 1
255
+ return at
256
+
257
+ def splice_value(self, node: yaml.Node, replacement: str) -> str:
258
+ """``node``'s text replaced, keeping a comment after it at its column when possible."""
259
+ start, end = node.start_mark.index, node.end_mark.index
260
+ rest = self.text[end:]
261
+ match = re.match(r"([ \t]+)#", rest)
262
+ if match:
263
+ gap = len(match.group(1)) + (end - start) - len(replacement)
264
+ spaces = " " * max(gap, 1)
265
+ return self.text[:start] + replacement + spaces + rest[len(match.group(1)) :]
266
+ return self.text[:start] + replacement + rest
267
+
268
+ def insert_lines(self, at: int, new: list[str]) -> str:
269
+ lines = list(self.lines)
270
+ if at > 0 and at == len(lines) and not lines[-1].endswith("\n"):
271
+ lines[-1] = lines[-1] + self.newline
272
+ block = [line + self.newline for line in new]
273
+ return "".join([*lines[:at], *block, *lines[at:]])
274
+
275
+
276
+ def _flow_single_line(node: yaml.Node) -> bool:
277
+ return node.start_mark.line == node.end_mark.line
278
+
279
+
280
+ def _is_flow(node: yaml.Node) -> bool:
281
+ return isinstance(node, yaml.CollectionNode) and bool(node.flow_style)
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Data-side changes (what the edited text must mean)
286
+ # ---------------------------------------------------------------------------
287
+
288
+
289
+ def _data_parent(data: Any, path: Path) -> Any:
290
+ for part in path[:-1]:
291
+ data = data[part]
292
+ return data
293
+
294
+
295
+ def _data_set(data: Any, path: Path, value: Any) -> None:
296
+ parent = data
297
+ for part in path[:-1]:
298
+ if isinstance(parent, dict) and part not in parent:
299
+ parent[part] = {}
300
+ parent = parent[part]
301
+ parent[path[-1]] = copy.deepcopy(value)
302
+
303
+
304
+ def _data_delete(data: Any, path: Path) -> None:
305
+ parent = _data_parent(data, path)
306
+ del parent[path[-1]]
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Edits
311
+ # ---------------------------------------------------------------------------
312
+
313
+
314
+ class YamlText:
315
+ """A YAML document's text with checked, comment-preserving edits.
316
+
317
+ Each method edits ``self.text`` in place and raises :class:`EditError`
318
+ (leaving the text unchanged) when the edit cannot be made safely.
319
+ """
320
+
321
+ def __init__(self, text: str) -> None:
322
+ if _OTHER_BREAKS.search(text):
323
+ raise EditError("the text uses line breaks other than \\n and \\r\\n")
324
+ try:
325
+ self.data = _parse(text)
326
+ except yaml.YAMLError as exc:
327
+ raise EditError(f"not valid YAML: {exc}") from exc
328
+ if not isinstance(self.data, dict):
329
+ raise EditError("the document is not a mapping")
330
+ _unshared(self.data) # refuses a recursive alias up front
331
+ self.text = text
332
+
333
+ # -- plumbing -----------------------------------------------------------
334
+
335
+ def _commit(self, new_text: str, change: Callable[[Any], None]) -> None:
336
+ # Unshared: the change lands at its one path, so a text in which it also
337
+ # reaches another key through an alias does not match.
338
+ expected = _unshared(self.data)
339
+ change(expected)
340
+ try:
341
+ parsed = _parse(new_text)
342
+ except yaml.YAMLError as exc:
343
+ raise EditError(f"the edited text is not valid YAML: {exc}") from exc
344
+ if _unshared(parsed) != expected:
345
+ if _uses_aliases(self.text):
346
+ raise EditError(
347
+ "the edit would also change another key that repeats the edited part "
348
+ "through a YAML alias (*name) or merge key (<<)"
349
+ )
350
+ raise EditError("the edited text would not mean exactly the intended change")
351
+ self.text, self.data = new_text, parsed
352
+
353
+ def get(self, path: Path, default: Any = None) -> Any:
354
+ data = self.data
355
+ for part in path:
356
+ if isinstance(data, dict) and part in data:
357
+ data = data[part]
358
+ elif isinstance(data, list) and isinstance(part, int) and 0 <= part < len(data):
359
+ data = data[part]
360
+ else:
361
+ return default
362
+ return data
363
+
364
+ # -- set ----------------------------------------------------------------
365
+
366
+ def set(
367
+ self,
368
+ path: Path,
369
+ value: Any,
370
+ *,
371
+ after: Any = None,
372
+ comment: str | None = None,
373
+ block: bool = False,
374
+ ) -> None:
375
+ """Set the key at ``path`` to ``value`` (missing parent mappings are created).
376
+
377
+ A new key goes after the key ``after`` of its mapping when that exists,
378
+ else after the last key; ``comment`` (without ``#``) is written on the
379
+ line above a new key, and ``block`` writes a new mapping or list value
380
+ in block style even when it would fit on the key's line.
381
+ """
382
+ if self.get(path, _ABSENT) == value:
383
+ return
384
+ doc = _Doc(self.text)
385
+ parent_path, key = path[:-1], path[-1]
386
+ parent = doc.node(parent_path)
387
+ if parent is None or (isinstance(parent, yaml.ScalarNode) and parent.tag.endswith(":null")):
388
+ if not parent_path:
389
+ raise EditError("the document is empty")
390
+ # Create the missing parent with the key inside it.
391
+ self.set(parent_path, {key: value}, comment=comment)
392
+ return
393
+ if not isinstance(parent, yaml.MappingNode):
394
+ raise EditError(f"{'.'.join(map(str, parent_path))} is not a mapping")
395
+ if parent.flow_style:
396
+ if not _flow_single_line(parent):
397
+ raise EditError("a flow mapping written over several lines")
398
+ new_parent = dict(self.get(parent_path))
399
+ new_parent[key] = value
400
+ text = doc.splice(parent.start_mark.index, parent.end_mark.index, inline(new_parent))
401
+ self._commit(text, lambda d: _data_set(d, path, value))
402
+ return
403
+ pair = doc.pair(parent, key)
404
+ if pair is None:
405
+ text = self._insert_key(
406
+ doc, parent, key, value, after=after, comment=comment, block=block
407
+ )
408
+ else:
409
+ text = self._replace_value(doc, pair[0], pair[1], value)
410
+ self._commit(text, lambda d: _data_set(d, path, value))
411
+
412
+ def _insert_key(
413
+ self,
414
+ doc: _Doc,
415
+ parent: yaml.MappingNode,
416
+ key: Any,
417
+ value: Any,
418
+ *,
419
+ after: Any,
420
+ comment: str | None,
421
+ block: bool = False,
422
+ ) -> str:
423
+ pairs = doc.pairs(parent)
424
+ if not pairs:
425
+ raise EditError("an empty block mapping")
426
+ column = pairs[0][0].start_mark.column
427
+ anchor = pairs[-1]
428
+ if after is not None:
429
+ anchor = doc.pair(parent, after) or anchor
430
+ at = doc.after_trailing_comments(
431
+ doc.entry_lines(*anchor)[1] + 1, column, last=anchor[0] is pairs[-1][0]
432
+ )
433
+ lines = [f"{' ' * column}# {comment}"] if comment else []
434
+ if inline_ok(value) and not (block and _is_collection(value) and value):
435
+ lines.append(f"{' ' * column}{inline(key)}: {inline(value)}")
436
+ else:
437
+ lines.append(f"{' ' * column}{inline(key)}:")
438
+ lines.extend(block_lines(value, column + doc.step(), doc.step()))
439
+ return doc.insert_lines(at, lines)
440
+
441
+ def _replace_value(
442
+ self, doc: _Doc, key_node: yaml.Node, value_node: yaml.Node, value: Any
443
+ ) -> str:
444
+ column = key_node.start_mark.column
445
+ scalar = isinstance(value_node, yaml.ScalarNode) and value_node.style not in ("|", ">")
446
+ if scalar or _is_flow(value_node):
447
+ if not _flow_single_line(value_node):
448
+ raise EditError("a value written over several lines")
449
+ empty_flow = _is_flow(value_node) and not value_node.value
450
+ if inline_ok(value) or not (scalar or empty_flow):
451
+ text = inline(value)
452
+ if value_node.start_mark.index == value_node.end_mark.index:
453
+ text = " " + text # `key:` with nothing after it
454
+ return doc.splice_value(value_node, text)
455
+ # `key: []` (or a scalar) becomes a block collection under the key;
456
+ # a comment after the value stays on the key's line, at its column.
457
+ line_no = key_node.start_mark.line
458
+ if value_node.start_mark.line != line_no:
459
+ raise EditError("a value on the line after its key")
460
+ line = doc.lines[line_no].rstrip("\r\n")
461
+ start = value_node.start_mark.column
462
+ end = value_node.end_mark.column
463
+ head, tail = line[:start].rstrip(), line[end:]
464
+ if tail.strip():
465
+ head = head + " " * (len(line[:end]) - len(head.rstrip()))
466
+ new_line = head + tail
467
+ else:
468
+ new_line = head
469
+ return doc.replace_lines(
470
+ line_no,
471
+ line_no,
472
+ [new_line, *block_lines(value, column + doc.step(), doc.step())],
473
+ )
474
+ # A block collection: rewrite its lines below the key.
475
+ first = value_node.start_mark.line
476
+ last = _last_line(value_node)
477
+ if first <= key_node.start_mark.line:
478
+ raise EditError("a block collection on its key's line")
479
+ if _is_collection(value) and not value:
480
+ # Empty: `key: []` / `key: {}` on the key's line.
481
+ line_no = key_node.start_mark.line
482
+ line = doc.lines[line_no].rstrip("\r\n")
483
+ colon = line.index(":", key_node.end_mark.column)
484
+ inserted = " " + inline(value)
485
+ rest = line[colon + 1 :]
486
+ match = re.match(r"( +)#", rest)
487
+ if match and len(match.group(1)) > len(inserted):
488
+ rest = rest[len(inserted) :] # the comment keeps its column
489
+ new_line = line[: colon + 1] + inserted + rest
490
+ return doc.replace_lines(line_no, last, [new_line])
491
+ if not _is_collection(value):
492
+ raise EditError("a block collection replaced by a scalar")
493
+ if isinstance(value_node, yaml.SequenceNode):
494
+ kept = _scalar_list_lines(doc, value_node, value)
495
+ if kept is not None:
496
+ return doc.replace_lines(first, last, kept)
497
+ inner_column = value_node.start_mark.column
498
+ else:
499
+ inner_column = value_node.value[0][0].start_mark.column
500
+ return doc.replace_lines(first, last, block_lines(value, inner_column, doc.step()))
501
+
502
+ # -- delete -------------------------------------------------------------
503
+
504
+ def delete(self, path: Path) -> None:
505
+ """Remove the key at ``path`` (and its lines); its mapping must keep another key."""
506
+ if self.get(path, _ABSENT) is _ABSENT:
507
+ return
508
+ doc = _Doc(self.text)
509
+ parent = doc.node(path[:-1])
510
+ if not isinstance(parent, yaml.MappingNode):
511
+ raise EditError("the parent is not a mapping")
512
+ if len(parent.value) < 2:
513
+ raise EditError(f"{path[-1]} is the only key of its mapping")
514
+ if parent.flow_style:
515
+ if not _flow_single_line(parent):
516
+ raise EditError("a flow mapping written over several lines")
517
+ new_parent = {k: v for k, v in self.get(path[:-1]).items() if k != path[-1]}
518
+ text = doc.splice(parent.start_mark.index, parent.end_mark.index, inline(new_parent))
519
+ else:
520
+ pair = doc.pair(parent, path[-1])
521
+ assert pair is not None
522
+ first, last = doc.entry_lines(*pair)
523
+ line = doc.lines[first]
524
+ if pair[0].start_mark.column != len(line) - len(line.lstrip(" ")):
525
+ # `- key: value`: the line also opens a list item.
526
+ raise EditError("the key shares its line with a list item's dash")
527
+ text = doc.replace_lines(first, last, [])
528
+ self._commit(text, lambda d: _data_delete(d, path))
529
+
530
+ # -- lists --------------------------------------------------------------
531
+
532
+ def append(self, path: Path, item: Any, *, after: Any = None) -> None:
533
+ """Append ``item`` to the list at ``path`` (created after the key ``after`` when absent)."""
534
+ current = self.get(path, _ABSENT)
535
+ if current is _ABSENT:
536
+ self.set(path, [item], after=after)
537
+ return
538
+ if not isinstance(current, list):
539
+ raise EditError(f"{'.'.join(map(str, path))} is not a list")
540
+ doc = _Doc(self.text)
541
+ node = doc.node(path)
542
+ assert node is not None
543
+ new_list = [*current, item]
544
+ if _is_flow(node) or not current:
545
+ self.set(path, new_list)
546
+ return
547
+ dash = node.start_mark.column
548
+ gap = node.value[0].start_mark.column - dash # "- " is 2; "- " is 4
549
+ lines = block_lines([item], dash, doc.step())
550
+ if gap > 2:
551
+ extra = " " * (gap - 2)
552
+ lines = [lines[0][: dash + 1] + extra + lines[0][dash + 1 :]] + [
553
+ extra + line for line in lines[1:]
554
+ ]
555
+ text = doc.insert_lines(doc.after_trailing_comments(_last_line(node) + 1, dash + 1), lines)
556
+ self._commit(text, lambda d: _data_set(d, path, new_list))
557
+
558
+ def remove_item(self, path: Path, index: int) -> None:
559
+ """Remove item ``index`` of the list at ``path`` (an emptied list becomes ``[]``)."""
560
+ current = self.get(path, _ABSENT)
561
+ if not isinstance(current, list) or not 0 <= index < len(current):
562
+ raise EditError(f"{'.'.join(map(str, path))} has no item {index}")
563
+ new_list = [item for i, item in enumerate(current) if i != index]
564
+ doc = _Doc(self.text)
565
+ node = doc.node(path)
566
+ assert isinstance(node, yaml.SequenceNode)
567
+ if _is_flow(node) or not new_list:
568
+ self.set(path, new_list)
569
+ return
570
+ item_node = node.value[index]
571
+ first = item_node.start_mark.line
572
+ last = _last_line(item_node)
573
+ # Items share no lines in a block list; the dash sits on the item's first line.
574
+ text = doc.replace_lines(first, last, [])
575
+ self._commit(text, lambda d: _data_set(d, path, new_list))
576
+
577
+ def wrap_in_list(self, path: Path) -> None:
578
+ """Make the mapping at ``path`` the only item of a list, keeping its lines and comments.
579
+
580
+ A block mapping is indented under a dash where it stands (``approval:``
581
+ then `` required_for: ...`` becomes ``approval:`` then
582
+ `` - required_for: ...``, every line of it two columns deeper, comment
583
+ lines inside it included); a one-line flow mapping moves to its own
584
+ item line below the key, and a comment after it stays on the key's line.
585
+ """
586
+ current = self.get(path, _ABSENT)
587
+ if not isinstance(current, dict) or not current or not path:
588
+ raise EditError(f"{'.'.join(map(str, path))} is not a non-empty mapping")
589
+ doc = _Doc(self.text)
590
+ parent = doc.node(path[:-1])
591
+ if not isinstance(parent, yaml.MappingNode) or parent.flow_style:
592
+ raise EditError("the mapping's parent is not a block mapping")
593
+ pair = doc.pair(parent, path[-1])
594
+ assert pair is not None
595
+ key_node, value_node = pair
596
+ line_no = key_node.start_mark.line
597
+ if _is_flow(value_node):
598
+ if not _flow_single_line(value_node) or value_node.start_mark.line != line_no:
599
+ raise EditError("a flow mapping written over several lines, or below its key")
600
+ line = doc.lines[line_no].rstrip("\r\n")
601
+ flow = doc.text[value_node.start_mark.index : value_node.end_mark.index]
602
+ end = value_node.end_mark.column
603
+ head, tail = line[: value_node.start_mark.column].rstrip(), line[end:]
604
+ # A comment after the value keeps its column on the key's line.
605
+ key_line = head + " " * (end - len(head)) + tail if tail.strip() else head
606
+ column = key_node.start_mark.column + doc.step()
607
+ text = doc.replace_lines(line_no, line_no, [key_line, f"{' ' * column}- {flow}"])
608
+ else:
609
+ if not isinstance(value_node, yaml.MappingNode) or not value_node.value:
610
+ raise EditError("not a block mapping")
611
+ first = value_node.start_mark.line
612
+ last = _last_line(value_node)
613
+ if first <= line_no:
614
+ raise EditError("a block mapping on its key's line")
615
+ column = value_node.value[0][0].start_mark.column
616
+ lines: list[str] = []
617
+ for number in range(first, last + 1):
618
+ line = doc.lines[number].rstrip("\r\n")
619
+ if number == first:
620
+ if line[:column].strip():
621
+ raise EditError("the mapping shares its first line")
622
+ lines.append(f"{line[:column]}- {line[column:]}")
623
+ else:
624
+ lines.append(f" {line}" if line.strip() else line)
625
+ text = doc.replace_lines(first, last, lines)
626
+ self._commit(text, lambda d: _data_set(d, path, [copy.deepcopy(current)]))
627
+
628
+
629
+ _ABSENT: Any = type("Absent", (), {"__repr__": lambda self: "<absent>"})()
630
+
631
+
632
+ def _scalar_list_lines(doc: _Doc, node: yaml.SequenceNode, value: Any) -> list[str] | None:
633
+ """A block list of scalars rewritten item by item, or None when it is another shape.
634
+
635
+ An item that stays keeps its own line, with the comment after it and the
636
+ comment lines above it (``- GET # reads``); a new item gets a line like
637
+ the first one's; an item that goes takes its comment lines with it.
638
+ """
639
+ if not isinstance(value, list) or not value or any(_is_collection(v) for v in value):
640
+ return None
641
+ items = node.value
642
+ for item in items:
643
+ if not isinstance(item, yaml.ScalarNode) or item.style in ("|", ">"):
644
+ return None
645
+ line = doc.lines[item.start_mark.line]
646
+ if (
647
+ item.end_mark.line != item.start_mark.line
648
+ or line[: item.start_mark.column].strip() != "-"
649
+ ):
650
+ return None # the item is not on its dash's line, or spans several lines
651
+ dash = node.start_mark.column
652
+ gap = items[0].start_mark.column - dash
653
+ groups: dict[Any, list[list[str]]] = {}
654
+ start = node.start_mark.line
655
+ for item in items:
656
+ group = [line.rstrip("\r\n") for line in doc.lines[start : item.end_mark.line + 1]]
657
+ groups.setdefault(doc.key_of(item), []).append(group)
658
+ start = item.end_mark.line + 1
659
+ lines: list[str] = []
660
+ for item in value:
661
+ reusable = groups.get(item)
662
+ if reusable:
663
+ lines.extend(reusable.pop(0))
664
+ else:
665
+ lines.append(f"{' ' * dash}-{' ' * (gap - 1)}{inline(item)}")
666
+ return lines
667
+
668
+
669
+ # ---------------------------------------------------------------------------
670
+ # env files
671
+ # ---------------------------------------------------------------------------
672
+
673
+ _ENV_NAME = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
674
+
675
+
676
+ def env_names(text: str) -> set[str]:
677
+ """The variables an env file assigns (``NAME=value`` lines)."""
678
+ return set(_parse_env(text))
679
+
680
+
681
+ def env_insert(
682
+ text: str,
683
+ lines: list[str],
684
+ *,
685
+ section: re.Pattern[str] | None = None,
686
+ before: re.Pattern[str] | None = None,
687
+ drop: list[str] | None = None,
688
+ ) -> str:
689
+ """``text`` with ``lines`` inserted; :class:`EditError` when that is not safe.
690
+
691
+ The lines go at the end of the section whose header line matches
692
+ ``section`` (the section ends at the next ``# ---`` header), before the
693
+ first line of that section matching ``before``; without the section, at
694
+ the end of the file. ``drop`` lines (exact, stripped) inside the section
695
+ are removed (a note that no longer applies). The result must assign
696
+ exactly the old variables plus the new ones.
697
+ """
698
+ if _OTHER_BREAKS.search(text):
699
+ raise EditError("the text uses line breaks other than \\n and \\r\\n")
700
+ old = _parse_env(text)
701
+ added = _parse_env("\n".join(lines))
702
+ if set(added) & set(old):
703
+ raise EditError(f"already set: {', '.join(sorted(set(added) & set(old)))}")
704
+ newline = _newline(text)
705
+ current = _lines(text)
706
+ start = next(
707
+ (i for i, line in enumerate(current) if section is not None and section.match(line)), None
708
+ )
709
+ if start is None:
710
+ at = len(current)
711
+ block = [*lines]
712
+ if current and current[-1].strip():
713
+ block.insert(0, "")
714
+ dropped: set[int] = set()
715
+ else:
716
+ end = next(
717
+ (i for i in range(start + 1, len(current)) if current[i].startswith("# ---")),
718
+ len(current),
719
+ )
720
+ at = end
721
+ while at > start + 1 and not current[at - 1].strip():
722
+ at -= 1 # before the blank lines that close the section
723
+ if before is not None:
724
+ at = next((i for i in range(start + 1, end) if before.match(current[i])), at)
725
+ wanted = {line.strip() for line in drop or []}
726
+ dropped = {i for i in range(start + 1, end) if current[i].strip() in wanted}
727
+ block = list(lines)
728
+ if at == len(current) and current and not current[-1].endswith("\n"):
729
+ current[-1] += newline
730
+ out = [line for i, line in enumerate(current[:at]) if i not in dropped]
731
+ out += [line + newline for line in block]
732
+ out += [line for i, line in enumerate(current[at:], start=at) if i not in dropped]
733
+ new_text = "".join(out)
734
+ if _parse_env(new_text) != {**old, **added}:
735
+ raise EditError("the edited env file would not mean exactly the intended change")
736
+ return new_text
737
+
738
+
739
+ def env_remove(text: str, names: list[str], *, comments: list[str] | None = None) -> str:
740
+ """``text`` without the lines assigning ``names`` (and the ``comments`` lines right above).
741
+
742
+ ``comments`` are exact lines (stripped) removed only when they sit
743
+ directly above a removed assignment.
744
+ """
745
+ if _OTHER_BREAKS.search(text):
746
+ raise EditError("the text uses line breaks other than \\n and \\r\\n")
747
+ old = _parse_env(text)
748
+ current = _lines(text)
749
+ remove: set[int] = set()
750
+ wanted = {line.strip() for line in comments or []}
751
+ for i, line in enumerate(current):
752
+ match = _ENV_NAME.match(line)
753
+ if match and match.group(1) in names:
754
+ remove.add(i)
755
+ j = i - 1
756
+ while j >= 0 and current[j].strip() in wanted:
757
+ remove.add(j)
758
+ j -= 1
759
+ new_text = "".join(line for i, line in enumerate(current) if i not in remove)
760
+ expected = {k: v for k, v in old.items() if k not in names}
761
+ if _parse_env(new_text) != expected:
762
+ raise EditError("the edited env file would not mean exactly the intended change")
763
+ return new_text
764
+
765
+
766
+ def mapping_keys(value: Any) -> list[Any]:
767
+ """The keys of a mapping value (empty for anything else)."""
768
+ return list(value) if isinstance(value, Mapping) else []