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.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC
|
|
2
|
+
# Modifications Copyright 2026 graph-agents-cli contributors
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""graph-agents-cli login — preflight for provider keys, tracing, and kubeconfig.
|
|
17
|
+
|
|
18
|
+
The CLI stores no credentials, so ``login`` is a preflight check. The
|
|
19
|
+
command reads the project manifest when run inside a project, inspects the
|
|
20
|
+
process environment and the project's ``.env``, and reports whether:
|
|
21
|
+
|
|
22
|
+
* the model provider key variable (``_defaults.PROVIDER_KEY_VARS``) is set, or,
|
|
23
|
+
for ``openai-compatible``, whether ``OPENAI_BASE_URL`` is set and answers
|
|
24
|
+
``GET /models`` (3 s timeout; reachability is advisory);
|
|
25
|
+
* ``LANGSMITH_API_KEY`` (or an OTLP endpoint) is present when
|
|
26
|
+
``TRACING_ENABLED=true``;
|
|
27
|
+
* ``kubectl config current-context`` resolves (and, with ``--cluster``,
|
|
28
|
+
``kubectl cluster-info`` succeeds);
|
|
29
|
+
* under the ``shared-bearer`` auth policy, ``API_KEY`` is set (the local
|
|
30
|
+
server answers 503 to every request without it);
|
|
31
|
+
* under ``jwt``, a verification key is set (``AUTH_JWT_JWKS_URL`` or
|
|
32
|
+
``AUTH_JWT_PUBLIC_KEY``; ``auth dev-token`` makes a local one) and
|
|
33
|
+
``GRAPH_AGENTS_CLI_API_KEY`` holds the token ``run`` and ``eval`` send;
|
|
34
|
+
* ``.env`` is not open to other users;
|
|
35
|
+
* under ``--profile disconnected``, nothing hosted is configured: no
|
|
36
|
+
hosted model provider, no LangSmith, no GitHub-hosted CI, ``fastapi`` runtime.
|
|
37
|
+
|
|
38
|
+
``--write-env`` prompts for the missing keys and writes them to ``.env``
|
|
39
|
+
without echoing the values (a blank ``KEY=`` line copied from ``.env.example``
|
|
40
|
+
is filled in place, anything else is appended; the file is kept at mode 0600);
|
|
41
|
+
inside a ``shared-bearer`` project it also generates a missing ``API_KEY`` (as
|
|
42
|
+
``secrets apply`` does). It always leaves ``.env`` at mode 0600, even when
|
|
43
|
+
nothing is missing. The CLI itself stores nothing.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import contextlib
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
import subprocess
|
|
53
|
+
import tempfile
|
|
54
|
+
from collections.abc import Iterator
|
|
55
|
+
from dataclasses import asdict, dataclass, field
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import Any
|
|
58
|
+
|
|
59
|
+
import click
|
|
60
|
+
import yaml
|
|
61
|
+
|
|
62
|
+
from graph_agents_cli._defaults import (
|
|
63
|
+
DEFAULT_AUTH_POLICY,
|
|
64
|
+
DEFAULT_MODEL_PROVIDER,
|
|
65
|
+
DEFAULT_RUNTIME,
|
|
66
|
+
FAKE_PROVIDER,
|
|
67
|
+
MODEL_PROVIDERS,
|
|
68
|
+
PROVIDER_KEY_VARS,
|
|
69
|
+
normalize_auth_policy,
|
|
70
|
+
)
|
|
71
|
+
from graph_agents_cli._output import Console
|
|
72
|
+
from graph_agents_cli._project import ManifestError, find_project_root
|
|
73
|
+
from graph_agents_cli._remote import API_KEY_ENV
|
|
74
|
+
from graph_agents_cli._runner import run_resolved
|
|
75
|
+
from graph_agents_cli._skills_check import NO_UPDATE_CHECK_ENV
|
|
76
|
+
from graph_agents_cli._tools import ToolNotFoundError, install_hint
|
|
77
|
+
|
|
78
|
+
MANIFEST_FILENAME = "graph-agents-cli-manifest.yaml"
|
|
79
|
+
PROFILES = ("default", "disconnected")
|
|
80
|
+
HOSTED_PROVIDERS = frozenset(p for p in MODEL_PROVIDERS if p != "openai-compatible")
|
|
81
|
+
|
|
82
|
+
# Reachability probe for openai-compatible servers (self-hosted models).
|
|
83
|
+
MODELS_PROBE_TIMEOUT_S = 3.0
|
|
84
|
+
KUBECTL_TIMEOUT_S = 5
|
|
85
|
+
CLUSTER_INFO_TIMEOUT_S = 15
|
|
86
|
+
|
|
87
|
+
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
88
|
+
# GitHub-hosted runner labels (disconnected: only an on-network GHES keeps CD in profile).
|
|
89
|
+
_HOSTED_RUNNER_RE = re.compile(
|
|
90
|
+
r"runs-on:\s*\[?\s*['\"]?(ubuntu|windows|macos)-(latest|\d+(\.\d+)?)", re.IGNORECASE
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Status values, ordered by severity for the summary.
|
|
94
|
+
OK, WARN, FAIL, SKIP = "ok", "warn", "fail", "skip"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Check:
|
|
99
|
+
"""One preflight result. ``detail`` never contains a secret value."""
|
|
100
|
+
|
|
101
|
+
name: str
|
|
102
|
+
status: str
|
|
103
|
+
detail: str
|
|
104
|
+
hint: str = ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ProjectInfo:
|
|
109
|
+
"""What `login` learned from the manifest (all fields optional)."""
|
|
110
|
+
|
|
111
|
+
root: str | None = None
|
|
112
|
+
name: str | None = None
|
|
113
|
+
provider: str = DEFAULT_MODEL_PROVIDER
|
|
114
|
+
provider_source: str = "default"
|
|
115
|
+
runtime: str = DEFAULT_RUNTIME
|
|
116
|
+
deployment_target: str = "kubernetes"
|
|
117
|
+
cd: str = "skip"
|
|
118
|
+
auth_policy: str = DEFAULT_AUTH_POLICY
|
|
119
|
+
environments: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class EnvView:
|
|
124
|
+
"""Merged view of the process environment and the project's .env.
|
|
125
|
+
|
|
126
|
+
The process environment wins over the file. ``sources`` records where each
|
|
127
|
+
present key came from so the report can say "set (environment)" without
|
|
128
|
+
printing the value.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
values: dict[str, str]
|
|
132
|
+
sources: dict[str, str]
|
|
133
|
+
env_file: Path
|
|
134
|
+
|
|
135
|
+
def get(self, key: str) -> str:
|
|
136
|
+
return self.values.get(key, "")
|
|
137
|
+
|
|
138
|
+
def has(self, key: str) -> bool:
|
|
139
|
+
return bool(self.values.get(key, "").strip())
|
|
140
|
+
|
|
141
|
+
def where(self, key: str) -> str:
|
|
142
|
+
return self.sources.get(key, "unset")
|
|
143
|
+
|
|
144
|
+
def truthy(self, key: str) -> bool:
|
|
145
|
+
return self.get(key).strip().lower() in _TRUE_VALUES
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── Inputs ──────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _read_manifest(root: Path) -> dict[str, Any]:
|
|
152
|
+
path = root / MANIFEST_FILENAME
|
|
153
|
+
try:
|
|
154
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
155
|
+
except (OSError, yaml.YAMLError) as e:
|
|
156
|
+
raise ManifestError(f"Could not read {path}: {e}") from e
|
|
157
|
+
if not isinstance(data, dict):
|
|
158
|
+
raise ManifestError(f"malformed {MANIFEST_FILENAME}")
|
|
159
|
+
return data
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_project_info(cwd: Path | None = None) -> ProjectInfo:
|
|
163
|
+
"""Read the manifest of the enclosing project, if any."""
|
|
164
|
+
root = find_project_root(cwd)
|
|
165
|
+
if root is None:
|
|
166
|
+
return ProjectInfo()
|
|
167
|
+
data = _read_manifest(root)
|
|
168
|
+
params = data.get("create_params") or {}
|
|
169
|
+
if not isinstance(params, dict):
|
|
170
|
+
params = {}
|
|
171
|
+
info = ProjectInfo(root=str(root), name=data.get("name"))
|
|
172
|
+
provider = params.get("model_provider")
|
|
173
|
+
if provider:
|
|
174
|
+
info.provider = str(provider)
|
|
175
|
+
info.provider_source = "manifest"
|
|
176
|
+
info.runtime = str(params.get("runtime") or DEFAULT_RUNTIME)
|
|
177
|
+
info.deployment_target = str(params.get("deployment_target") or "kubernetes")
|
|
178
|
+
info.cd = str(params.get("cd") or "skip")
|
|
179
|
+
info.auth_policy = normalize_auth_policy(params.get("auth_policy"), warn=False)
|
|
180
|
+
envs = data.get("environments") or {}
|
|
181
|
+
if isinstance(envs, dict):
|
|
182
|
+
info.environments = {
|
|
183
|
+
str(k): {str(a): str(b or "") for a, b in (v or {}).items()}
|
|
184
|
+
for k, v in envs.items()
|
|
185
|
+
if isinstance(v, dict)
|
|
186
|
+
}
|
|
187
|
+
return info
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_env(env_file: Path) -> EnvView:
|
|
191
|
+
"""Merge ``env_file`` (when present) under the process environment."""
|
|
192
|
+
from dotenv import dotenv_values
|
|
193
|
+
|
|
194
|
+
values: dict[str, str] = {}
|
|
195
|
+
sources: dict[str, str] = {}
|
|
196
|
+
if env_file.is_file():
|
|
197
|
+
try:
|
|
198
|
+
for key, value in dotenv_values(env_file).items():
|
|
199
|
+
if value is None:
|
|
200
|
+
continue
|
|
201
|
+
values[key] = value
|
|
202
|
+
sources[key] = env_file.name
|
|
203
|
+
except Exception as e: # dotenv raises plain exceptions on bad files
|
|
204
|
+
raise click.ClickException(f"Could not parse {env_file}: {e}") from e
|
|
205
|
+
for key, value in os.environ.items():
|
|
206
|
+
if value:
|
|
207
|
+
values[key] = value
|
|
208
|
+
sources[key] = "environment"
|
|
209
|
+
return EnvView(values=values, sources=sources, env_file=env_file)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def resolve_provider(info: ProjectInfo, env: EnvView) -> tuple[str, str]:
|
|
213
|
+
"""Provider precedence: MODEL_PROVIDER (environment or .env), then the manifest, then the default.
|
|
214
|
+
|
|
215
|
+
The manifest records the provider chosen at scaffold time; the app itself
|
|
216
|
+
reads ``MODEL_PROVIDER`` at runtime, so a value in the
|
|
217
|
+
environment or ``.env`` (for example ``fake`` for a local check) is what the
|
|
218
|
+
preflight must judge.
|
|
219
|
+
"""
|
|
220
|
+
if env.has("MODEL_PROVIDER"):
|
|
221
|
+
return env.get("MODEL_PROVIDER").strip(), env.where("MODEL_PROVIDER")
|
|
222
|
+
if info.provider_source == "manifest":
|
|
223
|
+
return info.provider, "manifest"
|
|
224
|
+
return DEFAULT_MODEL_PROVIDER, "default"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Checks ──────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _models_url(base_url: str) -> str:
|
|
231
|
+
return base_url.rstrip("/") + "/models"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def probe_openai_compatible(base_url: str, api_key: str = "") -> Check:
|
|
235
|
+
"""GET ``<OPENAI_BASE_URL>/models`` with a short timeout. Advisory only."""
|
|
236
|
+
import httpx
|
|
237
|
+
|
|
238
|
+
from graph_agents_cli import _http
|
|
239
|
+
|
|
240
|
+
url = _models_url(base_url)
|
|
241
|
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
242
|
+
try:
|
|
243
|
+
response = _http.get(url, headers=headers, timeout=MODELS_PROBE_TIMEOUT_S)
|
|
244
|
+
except _http.ProxyConfigError as e:
|
|
245
|
+
return Check(
|
|
246
|
+
"openai_base_url",
|
|
247
|
+
WARN,
|
|
248
|
+
f"OPENAI_BASE_URL set but {url} was not probed: {e.message}",
|
|
249
|
+
"Fix the proxy setting it names; the check is advisory.",
|
|
250
|
+
)
|
|
251
|
+
except httpx.HTTPError as e:
|
|
252
|
+
return Check(
|
|
253
|
+
"openai_base_url",
|
|
254
|
+
WARN,
|
|
255
|
+
f"OPENAI_BASE_URL set but {url} is unreachable ({type(e).__name__})",
|
|
256
|
+
"Start the on-network model server or fix OPENAI_BASE_URL; the check is advisory.",
|
|
257
|
+
)
|
|
258
|
+
if response.status_code in (401, 403):
|
|
259
|
+
return Check(
|
|
260
|
+
"openai_base_url",
|
|
261
|
+
WARN,
|
|
262
|
+
f"{url} reachable but rejected the credentials (HTTP {response.status_code})",
|
|
263
|
+
"Set MODEL_API_KEY to a key the server accepts.",
|
|
264
|
+
)
|
|
265
|
+
if response.status_code >= 400:
|
|
266
|
+
return Check(
|
|
267
|
+
"openai_base_url",
|
|
268
|
+
WARN,
|
|
269
|
+
f"{url} answered HTTP {response.status_code}",
|
|
270
|
+
"Check that OPENAI_BASE_URL points at the API root (ending in /v1 for most servers).",
|
|
271
|
+
)
|
|
272
|
+
count = ""
|
|
273
|
+
try:
|
|
274
|
+
data = response.json().get("data")
|
|
275
|
+
if isinstance(data, list):
|
|
276
|
+
count = f", {len(data)} model(s) listed"
|
|
277
|
+
except (ValueError, AttributeError):
|
|
278
|
+
pass
|
|
279
|
+
return Check("openai_base_url", OK, f"{url} reachable (HTTP {response.status_code}{count})")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def check_provider(provider: str, source: str, env: EnvView, *, profile: str) -> list[Check]:
|
|
283
|
+
checks: list[Check] = []
|
|
284
|
+
if provider == FAKE_PROVIDER:
|
|
285
|
+
# Test-only deterministic model: no key, no network,
|
|
286
|
+
# allowed under every profile. Never offered by `create`.
|
|
287
|
+
checks.append(
|
|
288
|
+
Check(
|
|
289
|
+
"provider",
|
|
290
|
+
WARN,
|
|
291
|
+
f"model provider {FAKE_PROVIDER} ({source}) is the test-only fake model",
|
|
292
|
+
"Set MODEL_PROVIDER to a real provider before deploying.",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
checks.append(Check("provider_key", SKIP, "no key needed for the fake provider"))
|
|
296
|
+
return checks
|
|
297
|
+
if provider not in MODEL_PROVIDERS:
|
|
298
|
+
checks.append(
|
|
299
|
+
Check(
|
|
300
|
+
"provider",
|
|
301
|
+
FAIL,
|
|
302
|
+
f"unknown model provider {provider!r} ({source})",
|
|
303
|
+
f"Use one of: {', '.join(MODEL_PROVIDERS)}.",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
return checks
|
|
307
|
+
|
|
308
|
+
if profile == "disconnected" and provider in HOSTED_PROVIDERS:
|
|
309
|
+
checks.append(
|
|
310
|
+
Check(
|
|
311
|
+
"provider",
|
|
312
|
+
FAIL,
|
|
313
|
+
f"model provider {provider} ({source}) is hosted; the disconnected profile "
|
|
314
|
+
"requires openai-compatible",
|
|
315
|
+
"Set MODEL_PROVIDER=openai-compatible and OPENAI_BASE_URL to an on-network server.",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
checks.append(Check("provider", OK, f"model provider {provider} ({source})"))
|
|
320
|
+
|
|
321
|
+
key_var = PROVIDER_KEY_VARS[provider]
|
|
322
|
+
if env.has(key_var):
|
|
323
|
+
checks.append(Check("provider_key", OK, f"{key_var} set ({env.where(key_var)})"))
|
|
324
|
+
elif provider == "openai-compatible":
|
|
325
|
+
checks.append(
|
|
326
|
+
Check(
|
|
327
|
+
"provider_key",
|
|
328
|
+
WARN,
|
|
329
|
+
f"{key_var} not set (optional for servers that do not require a key)",
|
|
330
|
+
f"Set {key_var} in .env or run 'graph-agents-cli login --write-env'.",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
checks.append(
|
|
335
|
+
Check(
|
|
336
|
+
"provider_key",
|
|
337
|
+
FAIL,
|
|
338
|
+
f"{key_var} not set",
|
|
339
|
+
f"Export {key_var}, add it to .env, or run 'graph-agents-cli login --write-env'.",
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if provider == "openai-compatible":
|
|
344
|
+
if env.has("OPENAI_BASE_URL"):
|
|
345
|
+
checks.append(probe_openai_compatible(env.get("OPENAI_BASE_URL"), env.get(key_var)))
|
|
346
|
+
else:
|
|
347
|
+
checks.append(
|
|
348
|
+
Check(
|
|
349
|
+
"openai_base_url",
|
|
350
|
+
FAIL,
|
|
351
|
+
"OPENAI_BASE_URL not set (required for openai-compatible)",
|
|
352
|
+
"Set OPENAI_BASE_URL to the server's API root, e.g. http://ollama:11434/v1.",
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
return checks
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def check_judge(env: EnvView, *, profile: str) -> Check:
|
|
359
|
+
judge_provider = env.get("JUDGE_MODEL_PROVIDER").strip()
|
|
360
|
+
if not judge_provider:
|
|
361
|
+
return Check("judge", SKIP, "judge defaults to the agent's provider and key")
|
|
362
|
+
if judge_provider == FAKE_PROVIDER:
|
|
363
|
+
return Check("judge", OK, f"judge provider {FAKE_PROVIDER} (test-only; no key needed)")
|
|
364
|
+
if judge_provider not in MODEL_PROVIDERS:
|
|
365
|
+
return Check(
|
|
366
|
+
"judge",
|
|
367
|
+
FAIL,
|
|
368
|
+
f"unknown JUDGE_MODEL_PROVIDER {judge_provider!r}",
|
|
369
|
+
f"Use one of: {', '.join(MODEL_PROVIDERS)}.",
|
|
370
|
+
)
|
|
371
|
+
if profile == "disconnected" and judge_provider in HOSTED_PROVIDERS:
|
|
372
|
+
return Check(
|
|
373
|
+
"judge",
|
|
374
|
+
FAIL,
|
|
375
|
+
f"JUDGE_MODEL_PROVIDER={judge_provider} is hosted",
|
|
376
|
+
"Point JUDGE_MODEL_PROVIDER=openai-compatible and JUDGE_BASE_URL at an on-network server.",
|
|
377
|
+
)
|
|
378
|
+
key_var = PROVIDER_KEY_VARS[judge_provider]
|
|
379
|
+
if env.has("JUDGE_API_KEY") or env.has(key_var):
|
|
380
|
+
return Check("judge", OK, f"judge provider {judge_provider}, key present")
|
|
381
|
+
if judge_provider == "openai-compatible":
|
|
382
|
+
return Check("judge", OK, f"judge provider {judge_provider} (no key; optional)")
|
|
383
|
+
return Check(
|
|
384
|
+
"judge",
|
|
385
|
+
WARN,
|
|
386
|
+
f"judge provider {judge_provider} but neither JUDGE_API_KEY nor {key_var} is set",
|
|
387
|
+
"Set JUDGE_API_KEY (defaults to the provider key when the providers match).",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def check_tracing(env: EnvView, *, profile: str) -> Check:
|
|
392
|
+
enabled = env.truthy("TRACING_ENABLED")
|
|
393
|
+
has_langsmith = env.has("LANGSMITH_API_KEY")
|
|
394
|
+
has_otlp = env.has("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
395
|
+
|
|
396
|
+
if profile == "disconnected" and has_langsmith:
|
|
397
|
+
return Check(
|
|
398
|
+
"tracing",
|
|
399
|
+
FAIL,
|
|
400
|
+
f"LANGSMITH_API_KEY set ({env.where('LANGSMITH_API_KEY')}); LangSmith is a hosted dependency",
|
|
401
|
+
"Unset LANGSMITH_API_KEY and export traces over OTLP to an in-cluster collector, or disable tracing.",
|
|
402
|
+
)
|
|
403
|
+
if not enabled:
|
|
404
|
+
return Check("tracing", SKIP, "TRACING_ENABLED is not true; tracing off")
|
|
405
|
+
if profile == "disconnected":
|
|
406
|
+
if has_otlp:
|
|
407
|
+
return Check("tracing", OK, "tracing enabled with OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
408
|
+
return Check(
|
|
409
|
+
"tracing",
|
|
410
|
+
FAIL,
|
|
411
|
+
"TRACING_ENABLED=true without OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
412
|
+
"Set OTEL_EXPORTER_OTLP_ENDPOINT to an on-network collector or set TRACING_ENABLED=false.",
|
|
413
|
+
)
|
|
414
|
+
if has_langsmith:
|
|
415
|
+
return Check(
|
|
416
|
+
"tracing",
|
|
417
|
+
OK,
|
|
418
|
+
f"tracing enabled; LANGSMITH_API_KEY set ({env.where('LANGSMITH_API_KEY')})",
|
|
419
|
+
)
|
|
420
|
+
if has_otlp:
|
|
421
|
+
return Check(
|
|
422
|
+
"tracing", OK, "tracing enabled with OTEL_EXPORTER_OTLP_ENDPOINT (no LangSmith)"
|
|
423
|
+
)
|
|
424
|
+
return Check(
|
|
425
|
+
"tracing",
|
|
426
|
+
FAIL,
|
|
427
|
+
"TRACING_ENABLED=true but neither LANGSMITH_API_KEY nor OTEL_EXPORTER_OTLP_ENDPOINT is set",
|
|
428
|
+
"Set LANGSMITH_API_KEY (or an OTLP endpoint), or set TRACING_ENABLED=false.",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _kubectl(args: list[str], timeout: int) -> subprocess.CompletedProcess:
|
|
433
|
+
return run_resolved(
|
|
434
|
+
["kubectl", *args],
|
|
435
|
+
capture_output=True,
|
|
436
|
+
text=True,
|
|
437
|
+
encoding="utf-8",
|
|
438
|
+
errors="replace",
|
|
439
|
+
timeout=timeout,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def check_kubeconfig(info: ProjectInfo, *, cluster: bool) -> list[Check]:
|
|
444
|
+
if info.root is not None and info.deployment_target == "none":
|
|
445
|
+
return [Check("kubeconfig", SKIP, "deployment target is none")]
|
|
446
|
+
|
|
447
|
+
severity = FAIL if cluster else WARN
|
|
448
|
+
try:
|
|
449
|
+
result = _kubectl(["config", "current-context"], KUBECTL_TIMEOUT_S)
|
|
450
|
+
except ToolNotFoundError:
|
|
451
|
+
return [Check("kubeconfig", severity, "kubectl not found on PATH", install_hint("kubectl"))]
|
|
452
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
453
|
+
return [Check("kubeconfig", severity, f"kubectl failed: {e}")]
|
|
454
|
+
|
|
455
|
+
if result.returncode != 0:
|
|
456
|
+
return [
|
|
457
|
+
Check(
|
|
458
|
+
"kubeconfig",
|
|
459
|
+
severity,
|
|
460
|
+
"no current kube context (kubectl config current-context failed)",
|
|
461
|
+
"Set a context with 'kubectl config use-context <name>' or point KUBECONFIG at a file.",
|
|
462
|
+
)
|
|
463
|
+
]
|
|
464
|
+
context = (result.stdout or "").strip()
|
|
465
|
+
detail = f"current context: {context}"
|
|
466
|
+
expected = {
|
|
467
|
+
env_name: cfg.get("context", "")
|
|
468
|
+
for env_name, cfg in info.environments.items()
|
|
469
|
+
if cfg.get("context")
|
|
470
|
+
}
|
|
471
|
+
matches = [env_name for env_name, ctx in expected.items() if ctx == context]
|
|
472
|
+
if matches:
|
|
473
|
+
detail += f" (manifest environment: {', '.join(matches)})"
|
|
474
|
+
checks = [Check("kubeconfig", OK, detail)]
|
|
475
|
+
|
|
476
|
+
if cluster:
|
|
477
|
+
try:
|
|
478
|
+
result = _kubectl(["cluster-info"], CLUSTER_INFO_TIMEOUT_S)
|
|
479
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
480
|
+
checks.append(Check("cluster", FAIL, f"kubectl cluster-info failed: {e}"))
|
|
481
|
+
return checks
|
|
482
|
+
if result.returncode == 0:
|
|
483
|
+
checks.append(Check("cluster", OK, f"cluster reachable via context {context}"))
|
|
484
|
+
else:
|
|
485
|
+
err = (result.stderr or result.stdout or "").strip().splitlines()
|
|
486
|
+
checks.append(
|
|
487
|
+
Check(
|
|
488
|
+
"cluster",
|
|
489
|
+
FAIL,
|
|
490
|
+
f"cluster unreachable via context {context}: {err[0] if err else 'unknown error'}",
|
|
491
|
+
"Check VPN / kubeconfig credentials; 'kubectl cluster-info' must succeed before deploy.",
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
return checks
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _hosted_ci_indicators(root: Path | None, env: EnvView) -> list[str]:
|
|
498
|
+
"""Return what makes this project depend on GitHub-hosted CI."""
|
|
499
|
+
indicators: list[str] = []
|
|
500
|
+
if env.truthy("GITHUB_ACTIONS"):
|
|
501
|
+
indicators.append("running under GITHUB_ACTIONS")
|
|
502
|
+
if root is None:
|
|
503
|
+
return indicators
|
|
504
|
+
workflows = root / ".github" / "workflows"
|
|
505
|
+
if not workflows.is_dir():
|
|
506
|
+
return indicators
|
|
507
|
+
for path in sorted(workflows.iterdir()):
|
|
508
|
+
if path.suffix not in (".yml", ".yaml"):
|
|
509
|
+
continue
|
|
510
|
+
try:
|
|
511
|
+
text = path.read_text(encoding="utf-8")
|
|
512
|
+
except OSError:
|
|
513
|
+
continue
|
|
514
|
+
if _HOSTED_RUNNER_RE.search(text):
|
|
515
|
+
indicators.append(f"{path.relative_to(root)} uses a GitHub-hosted runner")
|
|
516
|
+
return indicators
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def check_disconnected_profile(info: ProjectInfo, env: EnvView) -> list[Check]:
|
|
520
|
+
"""Extra checks that only apply under ``--profile disconnected``."""
|
|
521
|
+
checks: list[Check] = []
|
|
522
|
+
|
|
523
|
+
if info.root is not None:
|
|
524
|
+
if info.runtime == "fastapi":
|
|
525
|
+
checks.append(Check("profile.runtime", OK, "runtime fastapi"))
|
|
526
|
+
else:
|
|
527
|
+
checks.append(
|
|
528
|
+
Check(
|
|
529
|
+
"profile.runtime",
|
|
530
|
+
FAIL,
|
|
531
|
+
f"runtime {info.runtime} needs a LangSmith license check at startup",
|
|
532
|
+
"Scaffold with --runtime fastapi for the disconnected profile.",
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
indicators = _hosted_ci_indicators(Path(info.root) if info.root else None, env)
|
|
537
|
+
if indicators:
|
|
538
|
+
checks.append(
|
|
539
|
+
Check(
|
|
540
|
+
"profile.ci",
|
|
541
|
+
FAIL,
|
|
542
|
+
"GitHub-hosted CI detected: " + "; ".join(indicators),
|
|
543
|
+
"CI/CD is outside the disconnected profile unless an on-network GitHub Enterprise "
|
|
544
|
+
"Server hosts Actions; use 'cd: skip' with direct-mode deploy.",
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
elif info.root is not None and info.cd != "skip":
|
|
548
|
+
checks.append(
|
|
549
|
+
Check(
|
|
550
|
+
"profile.ci",
|
|
551
|
+
WARN,
|
|
552
|
+
f"cd: {info.cd} needs an on-network GitHub Enterprise Server",
|
|
553
|
+
"Confirm the Actions host is on-network, or switch to 'cd: skip'.",
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
else:
|
|
557
|
+
checks.append(Check("profile.ci", OK, "no GitHub-hosted CI dependency"))
|
|
558
|
+
|
|
559
|
+
if os.environ.get(NO_UPDATE_CHECK_ENV) == "1":
|
|
560
|
+
checks.append(Check("profile.update_check", OK, f"{NO_UPDATE_CHECK_ENV}=1"))
|
|
561
|
+
else:
|
|
562
|
+
checks.append(
|
|
563
|
+
Check(
|
|
564
|
+
"profile.update_check",
|
|
565
|
+
WARN,
|
|
566
|
+
f"{NO_UPDATE_CHECK_ENV} is not 1; the CLI will try the GitHub release check "
|
|
567
|
+
"and npx on each run",
|
|
568
|
+
f"Export {NO_UPDATE_CHECK_ENV}=1.",
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
return checks
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def needs_api_key(info: ProjectInfo, env: EnvView) -> bool:
|
|
575
|
+
"""True inside a project whose effective auth policy is shared-bearer and API_KEY is unset.
|
|
576
|
+
|
|
577
|
+
``AUTH_POLICY`` from the environment or ``.env`` (what the app reads at
|
|
578
|
+
runtime) wins over the manifest's ``create_params.auth_policy``.
|
|
579
|
+
"""
|
|
580
|
+
if info.root is None or env.has("API_KEY"):
|
|
581
|
+
return False
|
|
582
|
+
policy = env.get("AUTH_POLICY").strip() or info.auth_policy
|
|
583
|
+
return policy == "shared-bearer"
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def check_api_key(info: ProjectInfo, env: EnvView) -> Check | None:
|
|
587
|
+
if info.root is None:
|
|
588
|
+
return None
|
|
589
|
+
if needs_api_key(info, env):
|
|
590
|
+
return Check(
|
|
591
|
+
"api_key",
|
|
592
|
+
WARN,
|
|
593
|
+
"API_KEY unset; under AUTH_POLICY=shared-bearer the local server answers 503 "
|
|
594
|
+
"to every request (run, eval, playground)",
|
|
595
|
+
"Run 'graph-agents-cli login --write-env' to generate one into .env.",
|
|
596
|
+
)
|
|
597
|
+
if env.has("API_KEY"):
|
|
598
|
+
return Check("api_key", OK, f"API_KEY set ({env.where('API_KEY')})")
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def effective_auth_policy(info: ProjectInfo, env: EnvView) -> str | None:
|
|
603
|
+
"""The policy the local server runs: ``AUTH_POLICY`` (environment or .env), else the manifest."""
|
|
604
|
+
if info.root is None:
|
|
605
|
+
return None
|
|
606
|
+
return normalize_auth_policy(env.get("AUTH_POLICY").strip() or info.auth_policy, warn=False)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
DEV_TOKEN_EXPORT = (
|
|
610
|
+
'export GRAPH_AGENTS_CLI_API_KEY="$(graph-agents-cli auth dev-token --sub <user>)"'
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def check_jwt(info: ProjectInfo, env: EnvView) -> list[Check]:
|
|
615
|
+
"""Under ``jwt``: can the local server verify tokens, and will run/eval send one?"""
|
|
616
|
+
if effective_auth_policy(info, env) != "jwt":
|
|
617
|
+
return []
|
|
618
|
+
checks: list[Check] = []
|
|
619
|
+
key_vars = [name for name in ("AUTH_JWT_JWKS_URL", "AUTH_JWT_PUBLIC_KEY") if env.has(name)]
|
|
620
|
+
if not key_vars:
|
|
621
|
+
checks.append(
|
|
622
|
+
Check(
|
|
623
|
+
"jwt_key",
|
|
624
|
+
WARN,
|
|
625
|
+
"AUTH_POLICY=jwt without AUTH_JWT_JWKS_URL or AUTH_JWT_PUBLIC_KEY: the local "
|
|
626
|
+
"server answers 503 to every request (run, eval, playground)",
|
|
627
|
+
"For local runs: 'graph-agents-cli auth dev-token --sub <user>' writes a dev "
|
|
628
|
+
"key, issuer and audience to .env and prints a token. Or set AUTH_JWT_JWKS_URL "
|
|
629
|
+
"to your issuer's keys.",
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
else:
|
|
633
|
+
sources = ", ".join(f"{name} ({env.where(name)})" for name in key_vars)
|
|
634
|
+
checks.append(Check("jwt_key", OK, f"verification key: {sources}"))
|
|
635
|
+
missing = [n for n in ("AUTH_JWT_ISSUER", "AUTH_JWT_AUDIENCE") if not env.has(n)]
|
|
636
|
+
if missing and env.get("APP_ENV") != "dev":
|
|
637
|
+
checks.append(
|
|
638
|
+
Check(
|
|
639
|
+
"jwt_claims",
|
|
640
|
+
FAIL,
|
|
641
|
+
f"{' and '.join(missing)} unset: outside APP_ENV=dev the server refuses to "
|
|
642
|
+
"start without them",
|
|
643
|
+
"Set them in .env (APP_ENV=dev relaxes this for local runs).",
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
# run and eval read the token from the process environment only (never .env).
|
|
647
|
+
if os.environ.get(API_KEY_ENV, "").strip():
|
|
648
|
+
checks.append(
|
|
649
|
+
Check("jwt_token", OK, f"{API_KEY_ENV} set (environment): run and eval send it")
|
|
650
|
+
)
|
|
651
|
+
else:
|
|
652
|
+
checks.append(
|
|
653
|
+
Check(
|
|
654
|
+
"jwt_token",
|
|
655
|
+
WARN,
|
|
656
|
+
f"{API_KEY_ENV} is not set: a local run or eval sends no token (401 Missing "
|
|
657
|
+
"bearer token)",
|
|
658
|
+
f"Put a token there (kept out of argv and shell history): {DEV_TOKEN_EXPORT}",
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
return checks
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def env_file_mode_problem(path: Path) -> str | None:
|
|
665
|
+
"""Why ``path`` is open to other users (group/world bits), or None."""
|
|
666
|
+
if os.name == "nt" or not path.is_file():
|
|
667
|
+
return None
|
|
668
|
+
mode = path.stat().st_mode & 0o777
|
|
669
|
+
if mode & 0o077:
|
|
670
|
+
return f"{path.name} is readable or writable by other users (mode {mode:04o})"
|
|
671
|
+
return None
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def check_env_file(env: EnvView) -> Check | None:
|
|
675
|
+
problem = env_file_mode_problem(env.env_file)
|
|
676
|
+
if problem is None:
|
|
677
|
+
return None
|
|
678
|
+
return Check(
|
|
679
|
+
"env_file",
|
|
680
|
+
WARN,
|
|
681
|
+
f"{problem}; it holds keys",
|
|
682
|
+
f"chmod 600 {env.env_file.name} (or run 'graph-agents-cli login --write-env', which "
|
|
683
|
+
"keeps it 0600).",
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def run_preflight(info: ProjectInfo, env: EnvView, *, profile: str, cluster: bool) -> list[Check]:
|
|
688
|
+
provider, source = resolve_provider(info, env)
|
|
689
|
+
checks = check_provider(provider, source, env, profile=profile)
|
|
690
|
+
api_key = check_api_key(info, env)
|
|
691
|
+
if api_key is not None:
|
|
692
|
+
checks.append(api_key)
|
|
693
|
+
checks.extend(check_jwt(info, env))
|
|
694
|
+
env_file = check_env_file(env)
|
|
695
|
+
if env_file is not None:
|
|
696
|
+
checks.append(env_file)
|
|
697
|
+
checks.append(check_judge(env, profile=profile))
|
|
698
|
+
checks.append(check_tracing(env, profile=profile))
|
|
699
|
+
checks.extend(check_kubeconfig(info, cluster=cluster))
|
|
700
|
+
if profile == "disconnected":
|
|
701
|
+
checks.extend(check_disconnected_profile(info, env))
|
|
702
|
+
return checks
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# ── --write-env ─────────────────────────────────────────────────────────────
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def missing_env_keys(info: ProjectInfo, env: EnvView, *, profile: str) -> list[tuple[str, bool]]:
|
|
709
|
+
"""Keys ``--write-env`` should ask for: ``(name, is_secret)`` in prompt order."""
|
|
710
|
+
provider, _ = resolve_provider(info, env)
|
|
711
|
+
wanted: list[tuple[str, bool]] = []
|
|
712
|
+
if provider in PROVIDER_KEY_VARS:
|
|
713
|
+
if provider == "openai-compatible":
|
|
714
|
+
wanted.append(("OPENAI_BASE_URL", False))
|
|
715
|
+
wanted.append((PROVIDER_KEY_VARS[provider], True))
|
|
716
|
+
if env.truthy("TRACING_ENABLED") and profile != "disconnected":
|
|
717
|
+
if not env.has("OTEL_EXPORTER_OTLP_ENDPOINT"):
|
|
718
|
+
wanted.append(("LANGSMITH_API_KEY", True))
|
|
719
|
+
return [(name, secret) for name, secret in wanted if not env.has(name)]
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
_ENV_ASSIGNMENT = re.compile(r"^(?P<lead>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=")
|
|
723
|
+
_BLANK_VALUES = frozenset({"", '""', "''"})
|
|
724
|
+
ENV_FILE_MODE = 0o600
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def write_env(env_file: Path, entries: dict[str, str]) -> None:
|
|
728
|
+
"""Set ``KEY=value`` in ``env_file``: fill blank ``KEY=`` lines in place, append the rest.
|
|
729
|
+
|
|
730
|
+
``cp .env.example .env`` leaves ``API_KEY=`` (and a blank provider key)
|
|
731
|
+
in the file; appending a second line would leave two assignments. Every
|
|
732
|
+
blank assignment of a key gets the value (a later blank one would
|
|
733
|
+
otherwise win), and a key the file does not assign is appended. A key the
|
|
734
|
+
file already sets (another run got there first) is left as it is, never
|
|
735
|
+
assigned twice. The file holds credentials: it is written atomically with
|
|
736
|
+
mode 0600 (a symlinked ``.env`` is written through to its target), and
|
|
737
|
+
concurrent writers take turns (the file is read again under the lock).
|
|
738
|
+
"""
|
|
739
|
+
if not entries:
|
|
740
|
+
return
|
|
741
|
+
target = env_file.resolve() if env_file.is_symlink() else env_file
|
|
742
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
743
|
+
with _env_file_lock(target):
|
|
744
|
+
existing = ""
|
|
745
|
+
if target.is_file():
|
|
746
|
+
# newline="": keep CRLF files CRLF (no universal-newline translation).
|
|
747
|
+
with target.open(encoding="utf-8", newline="") as handle:
|
|
748
|
+
existing = handle.read()
|
|
749
|
+
lines = existing.splitlines(keepends=True)
|
|
750
|
+
pending = dict(entries)
|
|
751
|
+
filled: set[str] = set()
|
|
752
|
+
already_set: set[str] = set()
|
|
753
|
+
for index, line in enumerate(lines):
|
|
754
|
+
match = _ENV_ASSIGNMENT.match(line)
|
|
755
|
+
if not match or match.group("key") not in pending:
|
|
756
|
+
continue
|
|
757
|
+
key = match.group("key")
|
|
758
|
+
value = line[match.end() :].strip()
|
|
759
|
+
# Blank as python-dotenv (every reader) sees it: `KEY=`, `KEY="" # note`.
|
|
760
|
+
# An unquoted `KEY= # note` is the value "# note" to dotenv, so it is set.
|
|
761
|
+
if value.split(" #", 1)[0].strip() not in _BLANK_VALUES:
|
|
762
|
+
already_set.add(key)
|
|
763
|
+
continue
|
|
764
|
+
ending = line[len(line.rstrip("\r\n")) :] # keep the file's own line ending
|
|
765
|
+
lines[index] = f"{match.group('lead')}{key}={pending[key]}{ending}"
|
|
766
|
+
filled.add(key)
|
|
767
|
+
text = "".join(lines)
|
|
768
|
+
appended = [
|
|
769
|
+
f"{k}={v}" for k, v in pending.items() if k not in filled and k not in already_set
|
|
770
|
+
]
|
|
771
|
+
if appended:
|
|
772
|
+
if text and not text.endswith("\n"):
|
|
773
|
+
text += "\n"
|
|
774
|
+
text += "\n".join(appended) + "\n"
|
|
775
|
+
_write_private(target, text)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@contextlib.contextmanager
|
|
779
|
+
def _env_file_lock(target: Path) -> Iterator[None]:
|
|
780
|
+
"""Serialise read-modify-write of ``target`` between processes (POSIX; else no lock).
|
|
781
|
+
|
|
782
|
+
The lock is a file in the project's state directory beside it
|
|
783
|
+
(``.graph-agents-cli/<name>.lock``, git and docker ignored, left in place),
|
|
784
|
+
never the env file itself, which a write replaces.
|
|
785
|
+
"""
|
|
786
|
+
try:
|
|
787
|
+
import fcntl
|
|
788
|
+
except ImportError: # Windows: writes are still atomic, only not serialised
|
|
789
|
+
yield
|
|
790
|
+
return
|
|
791
|
+
lock_dir = target.parent / ".graph-agents-cli"
|
|
792
|
+
lock_dir.mkdir(exist_ok=True)
|
|
793
|
+
lock_path = lock_dir / f"{target.name.lstrip('.') or 'env'}.lock"
|
|
794
|
+
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, ENV_FILE_MODE)
|
|
795
|
+
try:
|
|
796
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
797
|
+
yield
|
|
798
|
+
finally:
|
|
799
|
+
os.close(fd) # closing releases the lock
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _keep_private(path: Path, console: Console) -> None:
|
|
803
|
+
"""Make an existing env file owner-only (0600), as a write would leave it."""
|
|
804
|
+
problem = env_file_mode_problem(path)
|
|
805
|
+
if problem is None:
|
|
806
|
+
return
|
|
807
|
+
target = path.resolve() if path.is_symlink() else path
|
|
808
|
+
try:
|
|
809
|
+
target.chmod(ENV_FILE_MODE)
|
|
810
|
+
except OSError as exc:
|
|
811
|
+
console.print(f" {problem}; could not make it 0600: {exc}", style="yellow")
|
|
812
|
+
return
|
|
813
|
+
console.print(f" {problem}: now 0600 (owner only).", style="green", highlight=False)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _write_private(path: Path, text: str) -> None:
|
|
817
|
+
"""Replace ``path`` with ``text``, readable by the owner only (0600)."""
|
|
818
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
819
|
+
try:
|
|
820
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
|
821
|
+
handle.write(text)
|
|
822
|
+
os.chmod(tmp_name, ENV_FILE_MODE)
|
|
823
|
+
os.replace(tmp_name, path)
|
|
824
|
+
except BaseException:
|
|
825
|
+
with contextlib.suppress(OSError):
|
|
826
|
+
os.unlink(tmp_name)
|
|
827
|
+
raise
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def prompt_and_write_env(info: ProjectInfo, env: EnvView, *, profile: str, console: Console) -> int:
|
|
831
|
+
"""Prompt for each missing key and write the answers to ``env.env_file`` (see ``write_env``).
|
|
832
|
+
|
|
833
|
+
Secret values are read with ``hide_input`` and never printed. An empty
|
|
834
|
+
answer skips that key. A missing ``API_KEY`` under the shared-bearer policy
|
|
835
|
+
is generated rather than prompted for. When stdin runs out (a CI job, a
|
|
836
|
+
pipe), prompting stops and whatever was collected, the generated key
|
|
837
|
+
included, is still written. Returns the number of keys written.
|
|
838
|
+
"""
|
|
839
|
+
from graph_agents_cli.secrets._apply import GENERATED_KEY, generate_api_key
|
|
840
|
+
|
|
841
|
+
missing = missing_env_keys(info, env, profile=profile)
|
|
842
|
+
generate = needs_api_key(info, env)
|
|
843
|
+
if not missing and not generate:
|
|
844
|
+
console.print(" Nothing to write: every required key is already set.", style="dim")
|
|
845
|
+
_keep_private(env.env_file, console)
|
|
846
|
+
return 0
|
|
847
|
+
console.print()
|
|
848
|
+
console.print(f" Writing missing keys to {env.env_file} (leave blank to skip).", style="bold")
|
|
849
|
+
entries: dict[str, str] = {}
|
|
850
|
+
if generate:
|
|
851
|
+
entries[GENERATED_KEY] = generate_api_key()
|
|
852
|
+
console.print(
|
|
853
|
+
f" Generated {GENERATED_KEY} for AUTH_POLICY=shared-bearer (value not shown).",
|
|
854
|
+
style="green",
|
|
855
|
+
)
|
|
856
|
+
skipped: list[str] = []
|
|
857
|
+
for position, (name, secret) in enumerate(missing):
|
|
858
|
+
try:
|
|
859
|
+
value = click.prompt(
|
|
860
|
+
f" {name}",
|
|
861
|
+
default="",
|
|
862
|
+
show_default=False,
|
|
863
|
+
hide_input=secret,
|
|
864
|
+
).strip()
|
|
865
|
+
except click.Abort:
|
|
866
|
+
# EOF on stdin: nothing more to read. Keep what was collected.
|
|
867
|
+
skipped = [n for n, _ in missing[position:]]
|
|
868
|
+
click.echo("", err=True)
|
|
869
|
+
break
|
|
870
|
+
if value:
|
|
871
|
+
entries[name] = value
|
|
872
|
+
write_env(env.env_file, entries)
|
|
873
|
+
if skipped:
|
|
874
|
+
console.print(
|
|
875
|
+
f" No input for {', '.join(skipped)} (stdin closed); set them in "
|
|
876
|
+
f"{env.env_file} or rerun `graph-agents-cli login --write-env` in a terminal.",
|
|
877
|
+
style="yellow",
|
|
878
|
+
)
|
|
879
|
+
if entries:
|
|
880
|
+
console.print(
|
|
881
|
+
f" Wrote {len(entries)} key(s) to {env.env_file} (values not shown).", style="green"
|
|
882
|
+
)
|
|
883
|
+
else:
|
|
884
|
+
console.print(" No keys written.", style="dim")
|
|
885
|
+
return len(entries)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
# ── Report ──────────────────────────────────────────────────────────────────
|
|
889
|
+
|
|
890
|
+
_ICONS = {OK: ("✓", "green"), WARN: ("!", "yellow"), FAIL: ("✗", "red"), SKIP: ("-", "dim")}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def summarize(checks: list[Check]) -> dict[str, int]:
|
|
894
|
+
summary = {OK: 0, WARN: 0, FAIL: 0, SKIP: 0}
|
|
895
|
+
for check in checks:
|
|
896
|
+
summary[check.status] += 1
|
|
897
|
+
return summary
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def build_report(
|
|
901
|
+
info: ProjectInfo, env: EnvView, checks: list[Check], *, profile: str
|
|
902
|
+
) -> dict[str, Any]:
|
|
903
|
+
provider, source = resolve_provider(info, env)
|
|
904
|
+
summary = summarize(checks)
|
|
905
|
+
return {
|
|
906
|
+
"profile": profile,
|
|
907
|
+
"project": {
|
|
908
|
+
"root": info.root,
|
|
909
|
+
"name": info.name,
|
|
910
|
+
"provider": provider,
|
|
911
|
+
"provider_source": source,
|
|
912
|
+
"runtime": info.runtime if info.root else None,
|
|
913
|
+
"deployment_target": info.deployment_target if info.root else None,
|
|
914
|
+
"cd": info.cd if info.root else None,
|
|
915
|
+
},
|
|
916
|
+
"env_file": str(env.env_file),
|
|
917
|
+
"checks": [asdict(c) for c in checks],
|
|
918
|
+
"summary": summary,
|
|
919
|
+
"ok": summary[FAIL] == 0,
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def print_report(report: dict[str, Any], console: Console) -> None:
|
|
924
|
+
project = report["project"]
|
|
925
|
+
console.print()
|
|
926
|
+
console.print("Preflight", style="cyan bold")
|
|
927
|
+
console.print()
|
|
928
|
+
if project["root"]:
|
|
929
|
+
console.print(f" Project: {project['name'] or '?'} ({project['root']})")
|
|
930
|
+
console.print(
|
|
931
|
+
f" Runtime: {project['runtime']} target: {project['deployment_target']}"
|
|
932
|
+
f" cd: {project['cd']}"
|
|
933
|
+
)
|
|
934
|
+
else:
|
|
935
|
+
console.print(" Project: none (not inside a graph-agents-cli project)", style="dim")
|
|
936
|
+
console.print(f" Provider: {project['provider']} ({project['provider_source']})")
|
|
937
|
+
console.print(f" Env file: {report['env_file']}")
|
|
938
|
+
console.print(f" Profile: {report['profile']}")
|
|
939
|
+
console.print()
|
|
940
|
+
for check in report["checks"]:
|
|
941
|
+
icon, style = _ICONS[check["status"]]
|
|
942
|
+
console.print(f" [{style}]{icon}[/] {check['name']}: {check['detail']}", highlight=False)
|
|
943
|
+
if check["hint"] and check["status"] in (WARN, FAIL):
|
|
944
|
+
console.print(f" {check['hint']}", style="dim", highlight=False)
|
|
945
|
+
console.print()
|
|
946
|
+
s = report["summary"]
|
|
947
|
+
console.print(
|
|
948
|
+
f" {s[OK]} ok, {s[WARN]} warning(s), {s[FAIL]} failed, {s[SKIP]} skipped.",
|
|
949
|
+
style="green" if report["ok"] else "red",
|
|
950
|
+
)
|
|
951
|
+
console.print(" Nothing is stored by the CLI.", style="dim")
|
|
952
|
+
console.print()
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
# ── Command ─────────────────────────────────────────────────────────────────
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
@click.command("login")
|
|
959
|
+
@click.option(
|
|
960
|
+
"--profile",
|
|
961
|
+
type=click.Choice(PROFILES),
|
|
962
|
+
default="default",
|
|
963
|
+
show_default=True,
|
|
964
|
+
help="'disconnected' fails on any hosted dependency.",
|
|
965
|
+
)
|
|
966
|
+
@click.option(
|
|
967
|
+
"--cluster",
|
|
968
|
+
is_flag=True,
|
|
969
|
+
default=False,
|
|
970
|
+
help="Also run 'kubectl cluster-info' against the current context (fails when unreachable).",
|
|
971
|
+
)
|
|
972
|
+
@click.option(
|
|
973
|
+
"--write-env",
|
|
974
|
+
is_flag=True,
|
|
975
|
+
default=False,
|
|
976
|
+
help=(
|
|
977
|
+
"Prompt for missing keys and write them to .env (blank KEY= lines are filled in "
|
|
978
|
+
"place; the file is kept 0600; values are never echoed)."
|
|
979
|
+
),
|
|
980
|
+
)
|
|
981
|
+
@click.option(
|
|
982
|
+
"--env-file",
|
|
983
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
984
|
+
default=None,
|
|
985
|
+
help="The .env file to read (and write with --write-env). Default: <project>/.env or ./.env.",
|
|
986
|
+
)
|
|
987
|
+
@click.option(
|
|
988
|
+
"--status",
|
|
989
|
+
is_flag=True,
|
|
990
|
+
default=False,
|
|
991
|
+
help="Print the report and exit 0 even when a check fails.",
|
|
992
|
+
)
|
|
993
|
+
@click.option(
|
|
994
|
+
"--json",
|
|
995
|
+
"as_json",
|
|
996
|
+
is_flag=True,
|
|
997
|
+
default=False,
|
|
998
|
+
help="Emit the report as JSON.",
|
|
999
|
+
)
|
|
1000
|
+
def cmd_login(*, profile, cluster, write_env, env_file, status, as_json):
|
|
1001
|
+
"""Check model provider keys, LangSmith, and kubeconfig; optionally write .env.
|
|
1002
|
+
|
|
1003
|
+
A preflight, not an authentication: nothing is stored. Exit code 1 when
|
|
1004
|
+
any check fails (0 with --status).
|
|
1005
|
+
"""
|
|
1006
|
+
if write_env and as_json:
|
|
1007
|
+
raise click.UsageError(
|
|
1008
|
+
"--write-env prompts interactively and cannot be combined with --json."
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
console = Console()
|
|
1012
|
+
info = load_project_info()
|
|
1013
|
+
base = Path(info.root) if info.root else Path.cwd()
|
|
1014
|
+
env_path = env_file if env_file is not None else base / ".env"
|
|
1015
|
+
env = load_env(env_path)
|
|
1016
|
+
|
|
1017
|
+
if write_env:
|
|
1018
|
+
if prompt_and_write_env(info, env, profile=profile, console=console):
|
|
1019
|
+
env = load_env(env_path)
|
|
1020
|
+
|
|
1021
|
+
checks = run_preflight(info, env, profile=profile, cluster=cluster)
|
|
1022
|
+
report = build_report(info, env, checks, profile=profile)
|
|
1023
|
+
|
|
1024
|
+
if as_json:
|
|
1025
|
+
click.echo(json.dumps(report, indent=2))
|
|
1026
|
+
else:
|
|
1027
|
+
print_report(report, console)
|
|
1028
|
+
|
|
1029
|
+
if not report["ok"] and not status:
|
|
1030
|
+
click.get_current_context().exit(1)
|