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,1352 @@
|
|
|
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
|
+
"""Template engine: four-layer cookiecutter rendering and conditional files.
|
|
17
|
+
|
|
18
|
+
Layers, later ones overwriting earlier ones: ``base_templates/_shared`` ->
|
|
19
|
+
``base_templates/python`` -> ``deployment_targets/<target>/{_shared,python}``
|
|
20
|
+
-> ``agents/<name>`` overlay. Runtime-specific files are selected with
|
|
21
|
+
``CONDITIONAL_FILES`` and ``select_runtime_files``, not with a fifth layer.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import pathlib
|
|
30
|
+
import re
|
|
31
|
+
import shlex
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import yaml
|
|
40
|
+
from cookiecutter.main import cookiecutter
|
|
41
|
+
from rich.prompt import Confirm, IntPrompt
|
|
42
|
+
|
|
43
|
+
from graph_agents_cli import _defaults
|
|
44
|
+
from graph_agents_cli._api_policy import POLICY_FILENAME as API_POLICY_FILENAME
|
|
45
|
+
from graph_agents_cli._api_policy import ApiSummary, ExampleCall, secret_envs
|
|
46
|
+
from graph_agents_cli._defaults import (
|
|
47
|
+
DEFAULT_AGENT_GUIDANCE_FILENAME,
|
|
48
|
+
DEFAULT_AUTH_POLICY,
|
|
49
|
+
DEFAULT_CD,
|
|
50
|
+
DEFAULT_MODEL_PROVIDER,
|
|
51
|
+
DEFAULT_MODELS,
|
|
52
|
+
PROVIDER_KEY_VARS,
|
|
53
|
+
auth_policy_implemented_default,
|
|
54
|
+
default_secret_keys,
|
|
55
|
+
)
|
|
56
|
+
from graph_agents_cli._output import Console
|
|
57
|
+
|
|
58
|
+
from .lock_utils import LOCK_FILENAMES, lock_filename, replace_lock_project_name
|
|
59
|
+
from .remote_template import get_base_template_name
|
|
60
|
+
from .version import cli_install_spec, get_current_version
|
|
61
|
+
|
|
62
|
+
# Root of the scaffold package: agents/, base_templates/, deployment_targets/.
|
|
63
|
+
# Module-level so tests can point the engine at a scratch tree.
|
|
64
|
+
SCAFFOLD_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
65
|
+
|
|
66
|
+
TEMPLATE_CONFIG_FILE = "templateconfig.yaml"
|
|
67
|
+
MANIFEST_FILENAME = "graph-agents-cli-manifest.yaml"
|
|
68
|
+
EXAMPLE_API_TOOL = "{agent_directory}/tools/example_api.py"
|
|
69
|
+
LANGGRAPH_SERVER_DOCKERFILE = "Dockerfile.langgraph-server"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def agents_dir() -> pathlib.Path:
|
|
73
|
+
return SCAFFOLD_ROOT / "agents"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def base_templates_dir() -> pathlib.Path:
|
|
77
|
+
return SCAFFOLD_ROOT / "base_templates"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def deployment_targets_dir() -> pathlib.Path:
|
|
81
|
+
return SCAFFOLD_ROOT / "deployment_targets"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# =============================================================================
|
|
85
|
+
# Choice tables (values from graph_agents_cli._defaults)
|
|
86
|
+
# =============================================================================
|
|
87
|
+
|
|
88
|
+
DEPLOYMENT_TARGETS: dict[str, dict[str, str]] = {
|
|
89
|
+
"kubernetes": {
|
|
90
|
+
"display_name": "kubernetes",
|
|
91
|
+
"description": "Helm chart on your Kubernetes cluster",
|
|
92
|
+
},
|
|
93
|
+
"none": {
|
|
94
|
+
"display_name": "none",
|
|
95
|
+
"description": "No deployment (local development only)",
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
assert tuple(DEPLOYMENT_TARGETS) == _defaults.DEPLOYMENT_TARGETS
|
|
99
|
+
|
|
100
|
+
RUNTIMES: dict[str, dict[str, str]] = {
|
|
101
|
+
"fastapi": {
|
|
102
|
+
"display_name": "fastapi",
|
|
103
|
+
"description": "uvicorn + FastAPI; checkpointer bound in the app; no license",
|
|
104
|
+
},
|
|
105
|
+
"langgraph-server": {
|
|
106
|
+
"display_name": "langgraph-server",
|
|
107
|
+
"description": "LangGraph Server image; native Assistants/Threads API; needs Redis",
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
assert tuple(RUNTIMES) == _defaults.RUNTIMES
|
|
111
|
+
|
|
112
|
+
MODEL_PROVIDERS: dict[str, dict[str, str]] = {
|
|
113
|
+
"openai": {"display_name": "openai", "description": "OpenAI API (OPENAI_API_KEY)"},
|
|
114
|
+
"anthropic": {"display_name": "anthropic", "description": "Anthropic API (ANTHROPIC_API_KEY)"},
|
|
115
|
+
"gemini": {"display_name": "gemini", "description": "Google AI Studio (GOOGLE_API_KEY)"},
|
|
116
|
+
"openai-compatible": {
|
|
117
|
+
"display_name": "openai-compatible",
|
|
118
|
+
"description": "Ollama, vLLM, TGI... (MODEL_API_KEY, OPENAI_BASE_URL)",
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
assert tuple(MODEL_PROVIDERS) == _defaults.MODEL_PROVIDERS
|
|
122
|
+
|
|
123
|
+
CHECKPOINTERS: dict[str, dict[str, str]] = {
|
|
124
|
+
"memory": {"display_name": "memory", "description": "In-process; state lost on restart"},
|
|
125
|
+
"postgres": {
|
|
126
|
+
"display_name": "postgres",
|
|
127
|
+
"description": "Postgres in the cluster (deployed default)",
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
assert tuple(CHECKPOINTERS) == _defaults.CHECKPOINTERS
|
|
131
|
+
|
|
132
|
+
CD_MODES: dict[str, dict[str, str]] = {
|
|
133
|
+
"argocd": {
|
|
134
|
+
"display_name": "argocd",
|
|
135
|
+
"description": "GitOps: CI commits the image tag, Argo CD syncs",
|
|
136
|
+
},
|
|
137
|
+
"helm-push": {
|
|
138
|
+
"display_name": "helm-push",
|
|
139
|
+
"description": "CI runs helm upgrade from a self-hosted runner",
|
|
140
|
+
},
|
|
141
|
+
"skip": {"display_name": "skip", "description": "No CD; deploy from a workstation"},
|
|
142
|
+
}
|
|
143
|
+
assert tuple(CD_MODES) == _defaults.CD_MODES
|
|
144
|
+
|
|
145
|
+
AUTH_POLICIES: dict[str, dict[str, str]] = {
|
|
146
|
+
"shared-bearer": {
|
|
147
|
+
"display_name": "shared-bearer",
|
|
148
|
+
"description": "Authorization: Bearer <API_KEY>",
|
|
149
|
+
},
|
|
150
|
+
"jwt": {
|
|
151
|
+
"display_name": "jwt",
|
|
152
|
+
"description": "Per-user principals from a verified OIDC/JWT bearer token",
|
|
153
|
+
},
|
|
154
|
+
"custom": {
|
|
155
|
+
"display_name": "custom",
|
|
156
|
+
"description": "Your own policy in app/policies/custom.py (fail-closed stub until implemented)",
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
assert tuple(AUTH_POLICIES) == _defaults.AUTH_POLICIES
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# =============================================================================
|
|
163
|
+
# Combination table (runtime x checkpointer x target)
|
|
164
|
+
# =============================================================================
|
|
165
|
+
|
|
166
|
+
# (runtime, checkpointer, deployment_target) -> (valid, note). An in-memory
|
|
167
|
+
# checkpointer is refused on kubernetes: replicas and restarts would lose state.
|
|
168
|
+
COMBINATIONS: dict[tuple[str, str, str], tuple[bool, str]] = {
|
|
169
|
+
("fastapi", "memory", "none"): (True, "local dev under uvicorn; state lost on restart"),
|
|
170
|
+
("fastapi", "memory", "kubernetes"): (
|
|
171
|
+
False,
|
|
172
|
+
"refused; multi-replica and restarts lose state",
|
|
173
|
+
),
|
|
174
|
+
("fastapi", "postgres", "none"): (True, "local dev against a local or docker Postgres"),
|
|
175
|
+
("fastapi", "postgres", "kubernetes"): (True, "default for kubernetes"),
|
|
176
|
+
("langgraph-server", "memory", "none"): (
|
|
177
|
+
True,
|
|
178
|
+
"`langgraph dev` in-memory server only; not deployable",
|
|
179
|
+
),
|
|
180
|
+
("langgraph-server", "memory", "kubernetes"): (
|
|
181
|
+
False,
|
|
182
|
+
"refused; the deployed LangGraph Server keeps runs and threads in Postgres "
|
|
183
|
+
"(with Redis), an in-memory server loses them on restart and cannot run replicas",
|
|
184
|
+
),
|
|
185
|
+
("langgraph-server", "postgres", "kubernetes"): (
|
|
186
|
+
True,
|
|
187
|
+
"chart adds Redis; server owns persistence",
|
|
188
|
+
),
|
|
189
|
+
("langgraph-server", "postgres", "none"): (
|
|
190
|
+
True,
|
|
191
|
+
"`run` and `playground` use `langgraph dev` (in-memory) locally; postgres is only "
|
|
192
|
+
"the recorded deployed default",
|
|
193
|
+
),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def default_checkpointer(deployment_target: str) -> str:
|
|
198
|
+
"""``none`` (local only) defaults to memory, kubernetes to postgres."""
|
|
199
|
+
return "postgres" if deployment_target == "kubernetes" else "memory"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def validate_combination(
|
|
203
|
+
runtime: str,
|
|
204
|
+
checkpointer: str,
|
|
205
|
+
deployment_target: str,
|
|
206
|
+
cd: str | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Enforce the combination table and the ``--cd`` rule; raise ValueError with the reason."""
|
|
209
|
+
key = (runtime, checkpointer, deployment_target)
|
|
210
|
+
entry = COMBINATIONS.get(key)
|
|
211
|
+
if entry is None:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"Unknown combination runtime={runtime} checkpointer={checkpointer} "
|
|
214
|
+
f"deployment_target={deployment_target}."
|
|
215
|
+
)
|
|
216
|
+
valid, note = entry
|
|
217
|
+
if not valid:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Invalid combination: runtime={runtime} checkpointer={checkpointer} "
|
|
220
|
+
f"deployment_target={deployment_target} ({note}).\n"
|
|
221
|
+
" Valid: fastapi or langgraph-server with postgres on kubernetes; any "
|
|
222
|
+
"checkpointer with --deployment-target none."
|
|
223
|
+
)
|
|
224
|
+
if cd is not None and cd != "skip" and deployment_target != "kubernetes":
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"--cd {cd} requires --deployment-target kubernetes "
|
|
227
|
+
f"(got deployment_target={deployment_target})."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# =============================================================================
|
|
232
|
+
# Conditional files
|
|
233
|
+
# =============================================================================
|
|
234
|
+
# Maps a path in the rendered project to its inclusion condition. Paths that
|
|
235
|
+
# fail their condition are renamed to unused_* and removed afterwards.
|
|
236
|
+
#
|
|
237
|
+
# The config dict carries: deployment_target, runtime, cd, has_api_policy,
|
|
238
|
+
# has_example_api (the policy's first API allows an operation the example can make).
|
|
239
|
+
# `deployment/argocd` is listed before `deployment` so its own rule is applied
|
|
240
|
+
# even when the whole directory is kept.
|
|
241
|
+
|
|
242
|
+
CONDITIONAL_FILES: dict[str, Any] = {
|
|
243
|
+
".github/workflows/staging.yaml": lambda c: c.get("cd", "skip") != "skip",
|
|
244
|
+
".github/workflows/promote-to-prod.yaml": lambda c: c.get("cd", "skip") != "skip",
|
|
245
|
+
"deployment/argocd": lambda c: c.get("cd") == "argocd",
|
|
246
|
+
"deployment": lambda c: c.get("deployment_target") == "kubernetes",
|
|
247
|
+
API_POLICY_FILENAME: lambda c: bool(c.get("has_api_policy", False)),
|
|
248
|
+
EXAMPLE_API_TOOL: lambda c: bool(c.get("has_example_api", False)),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def apply_conditional_files(
|
|
253
|
+
project_path: pathlib.Path,
|
|
254
|
+
config: dict[str, Any],
|
|
255
|
+
agent_directory: str = "app",
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Rename every conditional path whose condition is False to ``unused_*``.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
project_path: Path to the generated project directory
|
|
261
|
+
config: dict with deployment_target, runtime, cd, has_api_policy, has_example_api
|
|
262
|
+
agent_directory: replaces the ``{agent_directory}`` placeholder in paths
|
|
263
|
+
"""
|
|
264
|
+
for rel_path_template, condition_fn in CONDITIONAL_FILES.items():
|
|
265
|
+
rel_path = rel_path_template.replace("{agent_directory}", agent_directory)
|
|
266
|
+
file_path = project_path / rel_path
|
|
267
|
+
|
|
268
|
+
if not file_path.exists():
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
if condition_fn(config):
|
|
272
|
+
logging.debug("Conditional file '%s' condition True, keeping", rel_path)
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
unused_path = file_path.parent / f"unused_{file_path.name}"
|
|
276
|
+
logging.debug(
|
|
277
|
+
"Conditional file '%s' condition False, renaming to %s", rel_path, unused_path.name
|
|
278
|
+
)
|
|
279
|
+
if unused_path.exists():
|
|
280
|
+
if unused_path.is_dir():
|
|
281
|
+
shutil.rmtree(unused_path)
|
|
282
|
+
else:
|
|
283
|
+
unused_path.unlink()
|
|
284
|
+
file_path.rename(unused_path)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _remove_unused_paths(project_path: pathlib.Path) -> None:
|
|
288
|
+
"""Delete the ``unused_*`` files and directories left by conditional templates.
|
|
289
|
+
|
|
290
|
+
``rglob`` (not ``glob.glob``) so entries under dot-directories such as
|
|
291
|
+
``.github/workflows`` are found too.
|
|
292
|
+
"""
|
|
293
|
+
# Longest paths first so a directory is removed after anything inside it.
|
|
294
|
+
for unused_path in sorted(project_path.rglob("unused_*"), key=lambda p: -len(p.parts)):
|
|
295
|
+
if unused_path.is_dir():
|
|
296
|
+
shutil.rmtree(unused_path)
|
|
297
|
+
logging.debug("Deleted unused directory: %s", unused_path)
|
|
298
|
+
elif unused_path.exists():
|
|
299
|
+
unused_path.unlink()
|
|
300
|
+
logging.debug("Deleted unused file: %s", unused_path)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def select_runtime_files(project_path: pathlib.Path, runtime: str, project_name: str) -> None:
|
|
304
|
+
"""Pick the runtime's Dockerfile and bundled lock.
|
|
305
|
+
|
|
306
|
+
The template ships ``Dockerfile`` (fastapi) and ``Dockerfile.langgraph-server``;
|
|
307
|
+
under ``langgraph-server`` the latter replaces the former, otherwise it is
|
|
308
|
+
deleted. Of ``uv-fastapi.lock`` and ``uv-langgraph-server.lock`` the matching
|
|
309
|
+
one becomes ``uv.lock`` (project-name placeholder filled in) and the other is
|
|
310
|
+
deleted. A template that ships neither bundled lock gets a warning.
|
|
311
|
+
"""
|
|
312
|
+
console = Console()
|
|
313
|
+
dockerfile = project_path / "Dockerfile"
|
|
314
|
+
server_dockerfile = project_path / LANGGRAPH_SERVER_DOCKERFILE
|
|
315
|
+
|
|
316
|
+
if runtime == "langgraph-server":
|
|
317
|
+
if server_dockerfile.exists():
|
|
318
|
+
if dockerfile.exists():
|
|
319
|
+
dockerfile.unlink()
|
|
320
|
+
server_dockerfile.rename(dockerfile)
|
|
321
|
+
logging.debug("Selected %s as Dockerfile", LANGGRAPH_SERVER_DOCKERFILE)
|
|
322
|
+
else:
|
|
323
|
+
console.print(
|
|
324
|
+
f"⚠️ The template ships no {LANGGRAPH_SERVER_DOCKERFILE}; keeping Dockerfile as is.",
|
|
325
|
+
style="yellow",
|
|
326
|
+
)
|
|
327
|
+
elif server_dockerfile.exists():
|
|
328
|
+
server_dockerfile.unlink()
|
|
329
|
+
logging.debug("Removed %s (runtime %s)", LANGGRAPH_SERVER_DOCKERFILE, runtime)
|
|
330
|
+
|
|
331
|
+
wanted = lock_filename(runtime)
|
|
332
|
+
wanted_path = project_path / wanted
|
|
333
|
+
uv_lock = project_path / "uv.lock"
|
|
334
|
+
if wanted_path.exists():
|
|
335
|
+
uv_lock.write_text(
|
|
336
|
+
replace_lock_project_name(wanted_path.read_text(encoding="utf-8"), project_name),
|
|
337
|
+
encoding="utf-8",
|
|
338
|
+
)
|
|
339
|
+
wanted_path.unlink()
|
|
340
|
+
logging.debug("Selected %s as uv.lock", wanted)
|
|
341
|
+
elif uv_lock.exists():
|
|
342
|
+
logging.debug("Template ships its own uv.lock; no bundled runtime lock to select")
|
|
343
|
+
else:
|
|
344
|
+
console.print(
|
|
345
|
+
f"⚠️ No bundled lock ({wanted}) in the template; run `uv lock` in the project.",
|
|
346
|
+
style="yellow",
|
|
347
|
+
)
|
|
348
|
+
for other in LOCK_FILENAMES.values():
|
|
349
|
+
if other != wanted:
|
|
350
|
+
(project_path / other).unlink(missing_ok=True)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# =============================================================================
|
|
354
|
+
# Dependencies helper (remote templates inheriting base-template deps)
|
|
355
|
+
# =============================================================================
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _add_dependencies(
|
|
359
|
+
project_path: pathlib.Path,
|
|
360
|
+
dependencies: list[str],
|
|
361
|
+
success_message: str,
|
|
362
|
+
auto_approve: bool = False,
|
|
363
|
+
interactive: bool = False,
|
|
364
|
+
) -> bool:
|
|
365
|
+
"""Add dependencies with ``uv add``, confirming first in interactive mode."""
|
|
366
|
+
if not dependencies:
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
console = Console()
|
|
370
|
+
deps_str = shlex.join(dependencies)
|
|
371
|
+
|
|
372
|
+
should_add = True
|
|
373
|
+
if interactive:
|
|
374
|
+
should_add = Confirm.ask("\n? Add these dependencies automatically?", default=True)
|
|
375
|
+
|
|
376
|
+
if not should_add:
|
|
377
|
+
console.print("\n⚠️ Skipped dependency installation.", style="yellow")
|
|
378
|
+
console.print(" To add them manually later, run:", style="dim")
|
|
379
|
+
console.print(f" cd {project_path.name}", style="dim")
|
|
380
|
+
console.print(f" uv add {deps_str}\n", style="dim")
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
if auto_approve:
|
|
385
|
+
console.print(
|
|
386
|
+
f"✓ Auto-installing dependencies: {', '.join(dependencies)}",
|
|
387
|
+
style="bold cyan",
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
console.print(f"\n✓ Running: uv add {deps_str}", style="bold cyan")
|
|
391
|
+
|
|
392
|
+
from graph_agents_cli._runner import run_resolved
|
|
393
|
+
from graph_agents_cli._tools import ToolNotFoundError
|
|
394
|
+
|
|
395
|
+
cmd = ["uv", "add", *dependencies]
|
|
396
|
+
result = run_resolved(
|
|
397
|
+
cmd,
|
|
398
|
+
cwd=project_path,
|
|
399
|
+
capture_output=True,
|
|
400
|
+
text=True,
|
|
401
|
+
check=True,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
if not auto_approve:
|
|
405
|
+
output_lines = result.stderr.strip().split("\n")
|
|
406
|
+
for line in output_lines:
|
|
407
|
+
if "Resolved" in line or "Installed" in line:
|
|
408
|
+
console.print(f" {line}", style="dim")
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
console.print(f"✓ {success_message}\n", style="bold green")
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
except subprocess.CalledProcessError as e:
|
|
415
|
+
console.print(f"\n✗ Failed to add dependencies: {e.stderr.strip()}", style="bold red")
|
|
416
|
+
console.print(" You can add them manually:", style="yellow")
|
|
417
|
+
console.print(f" cd {project_path.name}", style="dim")
|
|
418
|
+
console.print(f" uv add {deps_str}\n", style="dim")
|
|
419
|
+
return False
|
|
420
|
+
except ToolNotFoundError:
|
|
421
|
+
console.print("\n✗ uv command not found. Please install uv first.", style="bold red")
|
|
422
|
+
console.print(" Install from: https://docs.astral.sh/uv/", style="dim")
|
|
423
|
+
console.print("\n To add dependencies manually:", style="yellow")
|
|
424
|
+
console.print(f" cd {project_path.name}", style="dim")
|
|
425
|
+
console.print(f" uv add {deps_str}\n", style="dim")
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def add_base_template_dependencies(
|
|
430
|
+
project_path: pathlib.Path,
|
|
431
|
+
base_dependencies: list[str],
|
|
432
|
+
base_template_name: str,
|
|
433
|
+
auto_approve: bool = False,
|
|
434
|
+
interactive: bool = False,
|
|
435
|
+
) -> bool:
|
|
436
|
+
"""Add a base template's ``extra_dependencies`` to a project rendered from a remote template."""
|
|
437
|
+
if not base_dependencies:
|
|
438
|
+
return True
|
|
439
|
+
|
|
440
|
+
console = Console()
|
|
441
|
+
console.print(
|
|
442
|
+
f"\n✓ Ensuring base template '{base_template_name}' dependencies",
|
|
443
|
+
style="bold cyan",
|
|
444
|
+
)
|
|
445
|
+
console.print(" Adding the following dependencies:", style="white")
|
|
446
|
+
for dep in base_dependencies:
|
|
447
|
+
console.print(f" • {dep}", style="yellow")
|
|
448
|
+
|
|
449
|
+
return _add_dependencies(
|
|
450
|
+
project_path=project_path,
|
|
451
|
+
dependencies=base_dependencies,
|
|
452
|
+
success_message="Dependencies added successfully",
|
|
453
|
+
auto_approve=auto_approve,
|
|
454
|
+
interactive=interactive,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# =============================================================================
|
|
459
|
+
# Agent directory validation (Python identifiers)
|
|
460
|
+
# =============================================================================
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def validate_agent_directory_name(
|
|
464
|
+
agent_dir: str, allow_dot: bool = False, language: str = "python"
|
|
465
|
+
) -> None:
|
|
466
|
+
"""Validate that an agent directory name is a valid Python module path.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
agent_dir: The agent directory name to validate
|
|
470
|
+
allow_dot: If True, allows "." as a special value indicating flat structure
|
|
471
|
+
language: Kept for call compatibility; only ``python`` exists
|
|
472
|
+
|
|
473
|
+
Raises:
|
|
474
|
+
ValueError: If the agent directory name is not valid
|
|
475
|
+
"""
|
|
476
|
+
if agent_dir == ".":
|
|
477
|
+
if allow_dot:
|
|
478
|
+
return
|
|
479
|
+
raise ValueError(
|
|
480
|
+
"Agent directory '.' is not valid in this context. "
|
|
481
|
+
"Use '.' only to indicate flat structure templates."
|
|
482
|
+
)
|
|
483
|
+
# Allowlist: one or more path components of letters, digits, hyphens and
|
|
484
|
+
# underscores. This blocks absolute paths, dot-dot, backslashes, tilde,
|
|
485
|
+
# Windows drives, and any other path-traversal payload.
|
|
486
|
+
if not re.match(r"^[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)*$", agent_dir):
|
|
487
|
+
raise ValueError(
|
|
488
|
+
f"Invalid agent directory name '{agent_dir}'. It can only contain "
|
|
489
|
+
"letters, numbers, hyphens, underscores, and forward slashes (no "
|
|
490
|
+
"absolute paths or dot-dot components)."
|
|
491
|
+
)
|
|
492
|
+
if language != "python":
|
|
493
|
+
raise ValueError(f"Unsupported language '{language}': only python templates exist.")
|
|
494
|
+
if "-" in agent_dir:
|
|
495
|
+
raise ValueError(
|
|
496
|
+
f"Agent directory '{agent_dir}' contains hyphens (-) which are not allowed. "
|
|
497
|
+
"Agent directories must be valid Python identifiers since they're used as module names. "
|
|
498
|
+
"Please use underscores (_) or lowercase letters instead."
|
|
499
|
+
)
|
|
500
|
+
for component in agent_dir.split("/"):
|
|
501
|
+
if not component.isidentifier():
|
|
502
|
+
raise ValueError(
|
|
503
|
+
f"Agent directory '{agent_dir}' is not a valid Python identifier. "
|
|
504
|
+
"Agent directories must be valid Python identifiers since they're used as module names. "
|
|
505
|
+
"Please use only lowercase letters, numbers, and underscores, and don't start with a number."
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# =============================================================================
|
|
510
|
+
# Template discovery
|
|
511
|
+
# =============================================================================
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def get_available_agents(
|
|
515
|
+
deployment_target: str | None = None, include_hidden: bool = False
|
|
516
|
+
) -> dict:
|
|
517
|
+
"""Load the bundled agent templates, numbered from 1 for display.
|
|
518
|
+
|
|
519
|
+
Each agent dict includes: name, display_name, description, language, framework.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
deployment_target: Optional deployment target to filter agents
|
|
523
|
+
include_hidden: If True, include agents marked as hidden in templateconfig
|
|
524
|
+
"""
|
|
525
|
+
PRIORITY_ORDER = {"langgraph": 0}
|
|
526
|
+
|
|
527
|
+
agents_list = []
|
|
528
|
+
agents_root = agents_dir()
|
|
529
|
+
if not agents_root.is_dir():
|
|
530
|
+
return {}
|
|
531
|
+
|
|
532
|
+
for agent_dir in sorted(agents_root.iterdir()):
|
|
533
|
+
if not agent_dir.is_dir() or agent_dir.name.startswith("__"):
|
|
534
|
+
continue
|
|
535
|
+
template_config_path = agent_dir / ".template" / TEMPLATE_CONFIG_FILE
|
|
536
|
+
if not template_config_path.exists():
|
|
537
|
+
continue
|
|
538
|
+
try:
|
|
539
|
+
with open(template_config_path, encoding="utf-8") as f:
|
|
540
|
+
config = yaml.safe_load(f) or {}
|
|
541
|
+
agent_name = agent_dir.name
|
|
542
|
+
settings = config.get("settings", {})
|
|
543
|
+
|
|
544
|
+
if not include_hidden and config.get("hidden", False):
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
if deployment_target:
|
|
548
|
+
targets = settings.get("deployment_targets", [])
|
|
549
|
+
if isinstance(targets, str):
|
|
550
|
+
targets = [targets]
|
|
551
|
+
if deployment_target not in targets:
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
language = settings.get("language", "python")
|
|
555
|
+
tags = settings.get("tags", [])
|
|
556
|
+
framework = "langgraph" if "langgraph" in tags else "other"
|
|
557
|
+
|
|
558
|
+
agents_list.append(
|
|
559
|
+
{
|
|
560
|
+
"name": agent_name,
|
|
561
|
+
"display_name": config.get("display_name", agent_name),
|
|
562
|
+
"description": config.get("description", "No description available"),
|
|
563
|
+
"language": language,
|
|
564
|
+
"framework": framework,
|
|
565
|
+
"priority": PRIORITY_ORDER.get(agent_name, 100),
|
|
566
|
+
}
|
|
567
|
+
)
|
|
568
|
+
except Exception as e:
|
|
569
|
+
logging.warning(f"Could not load agent from {agent_dir}: {e}")
|
|
570
|
+
|
|
571
|
+
agents_list.sort(key=lambda agent: (agent["priority"], agent["name"]))
|
|
572
|
+
return {i + 1: agent for i, agent in enumerate(agents_list)}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def get_available_base_templates() -> list[str]:
|
|
576
|
+
"""Names of the bundled templates a remote template may inherit from."""
|
|
577
|
+
agents = get_available_agents(include_hidden=True)
|
|
578
|
+
return sorted(agent_info["name"] for agent_info in agents.values())
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def validate_base_template(base_template: str) -> bool:
|
|
582
|
+
"""True when ``base_template`` names a bundled template."""
|
|
583
|
+
return base_template in get_available_base_templates()
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def load_template_config(template_dir: pathlib.Path) -> dict[str, Any]:
|
|
587
|
+
"""Read ``templateconfig.yaml`` from a ``.template`` directory ({} if absent)."""
|
|
588
|
+
config_file = template_dir / TEMPLATE_CONFIG_FILE
|
|
589
|
+
if not config_file.exists():
|
|
590
|
+
return {}
|
|
591
|
+
|
|
592
|
+
try:
|
|
593
|
+
with open(config_file, encoding="utf-8") as f:
|
|
594
|
+
config = yaml.safe_load(f)
|
|
595
|
+
return config if config else {}
|
|
596
|
+
except Exception as e:
|
|
597
|
+
logging.error(f"Error loading template config: {e}")
|
|
598
|
+
return {}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def get_agent_language(agent_name: str, remote_config: dict[str, Any] | None = None) -> str:
|
|
602
|
+
"""The template language: always ``python``; anything else is refused."""
|
|
603
|
+
if remote_config:
|
|
604
|
+
config = remote_config
|
|
605
|
+
else:
|
|
606
|
+
config = load_template_config(agents_dir() / agent_name / ".template")
|
|
607
|
+
|
|
608
|
+
language = (config or {}).get("settings", {}).get("language", "python")
|
|
609
|
+
if language != "python":
|
|
610
|
+
raise ValueError(
|
|
611
|
+
f"Template '{agent_name}' declares language '{language}'; only python templates "
|
|
612
|
+
"are supported."
|
|
613
|
+
)
|
|
614
|
+
return "python"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def get_deployment_targets(agent_name: str, remote_config: dict[str, Any] | None = None) -> list:
|
|
618
|
+
"""Get available deployment targets for the selected agent."""
|
|
619
|
+
if remote_config:
|
|
620
|
+
config = remote_config
|
|
621
|
+
else:
|
|
622
|
+
config = load_template_config(agents_dir() / agent_name / ".template")
|
|
623
|
+
|
|
624
|
+
if not config:
|
|
625
|
+
return []
|
|
626
|
+
|
|
627
|
+
targets = config.get("settings", {}).get("deployment_targets", [])
|
|
628
|
+
return targets if isinstance(targets, list) else [targets]
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def get_template_path(agent_name: str) -> pathlib.Path:
|
|
632
|
+
"""Get the absolute path to a bundled agent's ``.template`` directory."""
|
|
633
|
+
template_path = agents_dir() / agent_name / ".template"
|
|
634
|
+
logging.debug("Looking for template in: %s", template_path)
|
|
635
|
+
if not template_path.exists():
|
|
636
|
+
raise ValueError(f"Template directory not found at {template_path}")
|
|
637
|
+
return template_path
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# =============================================================================
|
|
641
|
+
# Interactive prompts
|
|
642
|
+
# =============================================================================
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def prompt_choice(
|
|
646
|
+
title: str,
|
|
647
|
+
choices: dict[str, dict[str, str]],
|
|
648
|
+
default_value: str | None = None,
|
|
649
|
+
*,
|
|
650
|
+
header: str | None = None,
|
|
651
|
+
prompt: str = "Enter the number of your choice",
|
|
652
|
+
) -> str:
|
|
653
|
+
"""Numbered menu over ``choices`` (key -> {display_name, description})."""
|
|
654
|
+
console = Console()
|
|
655
|
+
keys = list(choices)
|
|
656
|
+
default_idx = 1
|
|
657
|
+
if default_value and default_value in keys:
|
|
658
|
+
default_idx = keys.index(default_value) + 1
|
|
659
|
+
|
|
660
|
+
console.print(f"\n> {title}")
|
|
661
|
+
if header:
|
|
662
|
+
console.print(f"\n [bold cyan]{header}[/]")
|
|
663
|
+
for idx, key in enumerate(keys, 1):
|
|
664
|
+
info = choices[key]
|
|
665
|
+
name_padded = info.get("display_name", key).ljust(18)
|
|
666
|
+
description = info.get("description", "")
|
|
667
|
+
current = " [dim cyan](current)[/]" if key == default_value else ""
|
|
668
|
+
console.print(f" {idx}. [bold]{name_padded}[/] [dim]{description}[/]{current}")
|
|
669
|
+
|
|
670
|
+
while True:
|
|
671
|
+
choice = IntPrompt.ask(f"\n{prompt}", default=default_idx, show_default=True)
|
|
672
|
+
if 1 <= choice <= len(keys):
|
|
673
|
+
return keys[choice - 1]
|
|
674
|
+
console.print(f"Please enter a number between 1 and {len(keys)}.", style="yellow")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def prompt_deployment_target(
|
|
678
|
+
agent_name: str,
|
|
679
|
+
remote_config: dict[str, Any] | None = None,
|
|
680
|
+
default_value: str | None = None,
|
|
681
|
+
) -> str:
|
|
682
|
+
"""Ask the user to select a deployment target the agent supports."""
|
|
683
|
+
targets = get_deployment_targets(agent_name, remote_config=remote_config)
|
|
684
|
+
if not targets:
|
|
685
|
+
return ""
|
|
686
|
+
choices = {
|
|
687
|
+
t: DEPLOYMENT_TARGETS.get(t, {"display_name": t, "description": ""}) for t in targets
|
|
688
|
+
}
|
|
689
|
+
return prompt_choice(
|
|
690
|
+
"Please select a deployment target:",
|
|
691
|
+
choices,
|
|
692
|
+
default_value,
|
|
693
|
+
header="☁️ Deployment Targets",
|
|
694
|
+
prompt="Enter the number of your deployment target choice",
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def prompt_runtime(default_value: str | None = None) -> str:
|
|
699
|
+
return prompt_choice("Please select a runtime:", RUNTIMES, default_value, header="Runtimes")
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def prompt_model_provider(default_value: str | None = None) -> str:
|
|
703
|
+
return prompt_choice(
|
|
704
|
+
"Please select a model provider:", MODEL_PROVIDERS, default_value, header="Providers"
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def prompt_checkpointer(default_value: str | None = None) -> str:
|
|
709
|
+
return prompt_choice(
|
|
710
|
+
"Please select the deployed checkpointer:",
|
|
711
|
+
CHECKPOINTERS,
|
|
712
|
+
default_value,
|
|
713
|
+
header="Checkpointers",
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def prompt_cd(default_value: str | None = None) -> str:
|
|
718
|
+
return prompt_choice("Please select a CD mode:", CD_MODES, default_value, header="CD Modes")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def prompt_auth_policy(default_value: str | None = None) -> str:
|
|
722
|
+
return prompt_choice(
|
|
723
|
+
"Please select an authentication policy:",
|
|
724
|
+
AUTH_POLICIES,
|
|
725
|
+
default_value,
|
|
726
|
+
header="Auth Policies",
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# =============================================================================
|
|
731
|
+
# Cookiecutter context
|
|
732
|
+
# =============================================================================
|
|
733
|
+
|
|
734
|
+
COPY_WITHOUT_RENDER: list[str] = [
|
|
735
|
+
"deployment/helm/*/templates/*",
|
|
736
|
+
"deployment/helm/*/templates/**/*",
|
|
737
|
+
"*.tpl",
|
|
738
|
+
".github/workflows/*",
|
|
739
|
+
"*.lock",
|
|
740
|
+
"*.ipynb",
|
|
741
|
+
"node_modules/**",
|
|
742
|
+
".venv/**",
|
|
743
|
+
"__pycache__/**",
|
|
744
|
+
".git/*",
|
|
745
|
+
]
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def build_cookiecutter_context(
|
|
749
|
+
*,
|
|
750
|
+
project_name: str,
|
|
751
|
+
agent_name: str,
|
|
752
|
+
deployment_target: str,
|
|
753
|
+
runtime: str,
|
|
754
|
+
model_provider: str = DEFAULT_MODEL_PROVIDER,
|
|
755
|
+
model: str | None = None,
|
|
756
|
+
checkpointer: str | None = None,
|
|
757
|
+
registry: str = "",
|
|
758
|
+
cd: str = DEFAULT_CD,
|
|
759
|
+
auth_policy: str = DEFAULT_AUTH_POLICY,
|
|
760
|
+
has_api_policy: bool = False,
|
|
761
|
+
apis: tuple[ApiSummary, ...] | list[ApiSummary] = (),
|
|
762
|
+
example_call: ExampleCall | None = None,
|
|
763
|
+
process: str | None = None,
|
|
764
|
+
agent_guidance_filename: str = DEFAULT_AGENT_GUIDANCE_FILENAME,
|
|
765
|
+
agent_directory: str = "app",
|
|
766
|
+
template_config: dict[str, Any] | None = None,
|
|
767
|
+
recorded_base_template: str | None = None,
|
|
768
|
+
generated_at: str | None = None,
|
|
769
|
+
auth_policy_implemented: bool | None = None,
|
|
770
|
+
) -> dict[str, Any]:
|
|
771
|
+
"""The variables every template may use.
|
|
772
|
+
|
|
773
|
+
List-valued variables are wrapped in a one-element list because cookiecutter
|
|
774
|
+
treats a bare list as a choice and would keep only its first item. ``apis``
|
|
775
|
+
summarises the declared APIs of ``api-policy.yaml`` (name, base_url_env,
|
|
776
|
+
auth, token_env) and every ``auth: bearer`` API's ``token_env`` joins
|
|
777
|
+
``secret_keys``. ``example_api`` is the call ``tools/example_api.py`` makes
|
|
778
|
+
(``dev.policy_check.example_call``: the first operation the policy's first
|
|
779
|
+
API allows, any method), empty when that API allows none; the example is
|
|
780
|
+
then not rendered.
|
|
781
|
+
``auth_policy_implemented`` is derived from ``auth_policy`` unless a
|
|
782
|
+
recorded value is passed (an in-folder re-render keeps the developer's flip).
|
|
783
|
+
"""
|
|
784
|
+
settings = (template_config or {}).get("settings", {})
|
|
785
|
+
tags = settings.get("tags", []) or []
|
|
786
|
+
model = model or DEFAULT_MODELS.get(model_provider, "")
|
|
787
|
+
checkpointer = checkpointer or default_checkpointer(deployment_target)
|
|
788
|
+
api_summaries = list(apis) if has_api_policy else []
|
|
789
|
+
return {
|
|
790
|
+
"project_name": project_name,
|
|
791
|
+
"agent_name": agent_name,
|
|
792
|
+
"package_version": get_current_version(),
|
|
793
|
+
"generated_at": generated_at or datetime.now(tz=UTC).isoformat(),
|
|
794
|
+
"agent_directory": agent_directory,
|
|
795
|
+
"language": "python",
|
|
796
|
+
"deployment_target": deployment_target,
|
|
797
|
+
"runtime": runtime,
|
|
798
|
+
"model_provider": model_provider,
|
|
799
|
+
"model": model,
|
|
800
|
+
"provider_key_var": PROVIDER_KEY_VARS.get(model_provider, "MODEL_API_KEY"),
|
|
801
|
+
"checkpointer": checkpointer,
|
|
802
|
+
"registry": registry or "",
|
|
803
|
+
"cd": cd,
|
|
804
|
+
"auth_policy": auth_policy,
|
|
805
|
+
# Provided so the manifest template need not derive it.
|
|
806
|
+
"auth_policy_implemented": (
|
|
807
|
+
auth_policy_implemented_default(auth_policy)
|
|
808
|
+
if auth_policy_implemented is None
|
|
809
|
+
else bool(auth_policy_implemented)
|
|
810
|
+
),
|
|
811
|
+
"agent_guidance_filename": agent_guidance_filename,
|
|
812
|
+
"process": process or "",
|
|
813
|
+
"has_api_policy": bool(has_api_policy),
|
|
814
|
+
"apis": [[summary.as_context() for summary in api_summaries]],
|
|
815
|
+
"example_api": (
|
|
816
|
+
example_call.as_context() if has_api_policy and example_call is not None else {}
|
|
817
|
+
),
|
|
818
|
+
"secret_keys": [
|
|
819
|
+
default_secret_keys(
|
|
820
|
+
model_provider,
|
|
821
|
+
runtime,
|
|
822
|
+
secret_envs(api_summaries),
|
|
823
|
+
auth_policy=auth_policy,
|
|
824
|
+
)
|
|
825
|
+
],
|
|
826
|
+
"default_judge_model": model,
|
|
827
|
+
"cli_install_spec": cli_install_spec(),
|
|
828
|
+
"tags": [list(tags)],
|
|
829
|
+
"settings": settings,
|
|
830
|
+
"recorded_base_template": recorded_base_template or agent_name,
|
|
831
|
+
"_copy_without_render": list(COPY_WITHOUT_RENDER),
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# =============================================================================
|
|
836
|
+
# Rendering
|
|
837
|
+
# =============================================================================
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _resolve_agent_directory(
|
|
841
|
+
template_config: dict[str, Any],
|
|
842
|
+
cli_overrides: dict[str, Any] | None,
|
|
843
|
+
remote_template_path: pathlib.Path | None,
|
|
844
|
+
) -> str:
|
|
845
|
+
"""Agent directory from CLI override or template settings; ``.`` means flat structure."""
|
|
846
|
+
agent_dir = None
|
|
847
|
+
if (
|
|
848
|
+
cli_overrides
|
|
849
|
+
and "settings" in cli_overrides
|
|
850
|
+
and "agent_directory" in cli_overrides["settings"]
|
|
851
|
+
):
|
|
852
|
+
agent_dir = cli_overrides["settings"]["agent_directory"]
|
|
853
|
+
else:
|
|
854
|
+
agent_dir = template_config.get("settings", {}).get("agent_directory", "app")
|
|
855
|
+
|
|
856
|
+
if agent_dir == ".":
|
|
857
|
+
if remote_template_path:
|
|
858
|
+
folder_name = remote_template_path.name.replace("-", "_")
|
|
859
|
+
logging.debug(
|
|
860
|
+
"Flat structure (-dir .): deriving target '%s' from folder name", folder_name
|
|
861
|
+
)
|
|
862
|
+
agent_dir = folder_name
|
|
863
|
+
else:
|
|
864
|
+
logging.debug("Flat structure (-dir .): using 'app' as fallback")
|
|
865
|
+
agent_dir = "app"
|
|
866
|
+
|
|
867
|
+
validate_agent_directory_name(agent_dir)
|
|
868
|
+
return agent_dir
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def process_template(
|
|
872
|
+
*,
|
|
873
|
+
agent_name: str,
|
|
874
|
+
template_dir: pathlib.Path,
|
|
875
|
+
project_name: str,
|
|
876
|
+
deployment_target: str,
|
|
877
|
+
runtime: str,
|
|
878
|
+
model_provider: str = DEFAULT_MODEL_PROVIDER,
|
|
879
|
+
model: str | None = None,
|
|
880
|
+
checkpointer: str | None = None,
|
|
881
|
+
registry: str = "",
|
|
882
|
+
cd: str = DEFAULT_CD,
|
|
883
|
+
auth_policy: str = DEFAULT_AUTH_POLICY,
|
|
884
|
+
has_api_policy: bool = False,
|
|
885
|
+
apis: tuple[ApiSummary, ...] | list[ApiSummary] = (),
|
|
886
|
+
example_call: ExampleCall | None = None,
|
|
887
|
+
process: str | None = None,
|
|
888
|
+
output_dir: pathlib.Path | None = None,
|
|
889
|
+
remote_template_path: pathlib.Path | None = None,
|
|
890
|
+
remote_config: dict[str, Any] | None = None,
|
|
891
|
+
in_folder: bool = False,
|
|
892
|
+
overlay_is_project: bool = False,
|
|
893
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
894
|
+
remote_spec: Any | None = None,
|
|
895
|
+
recorded_base_template: str | None = None,
|
|
896
|
+
agent_guidance_filename: str = DEFAULT_AGENT_GUIDANCE_FILENAME,
|
|
897
|
+
auth_policy_implemented: bool | None = None,
|
|
898
|
+
) -> pathlib.Path:
|
|
899
|
+
"""Render the template layers into a new project and return its path.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
agent_name: Name of the agent template to use
|
|
903
|
+
template_dir: The ``.template`` directory of the selected template
|
|
904
|
+
project_name: Name of the project to create
|
|
905
|
+
deployment_target: ``kubernetes`` or ``none``
|
|
906
|
+
runtime, model_provider, model, checkpointer, registry, cd, auth_policy:
|
|
907
|
+
the create parameters (validated by the caller)
|
|
908
|
+
has_api_policy: keep ``api-policy.yaml``
|
|
909
|
+
apis: the APIs the policy declares (see ``build_cookiecutter_context``)
|
|
910
|
+
example_call: the call the example API tool makes; None leaves the tool out
|
|
911
|
+
process: governing process document (path or string), recorded verbatim
|
|
912
|
+
output_dir: Optional output directory path, defaults to current directory
|
|
913
|
+
remote_template_path: Optional path to remote template for overlay
|
|
914
|
+
remote_config: Optional remote template configuration
|
|
915
|
+
in_folder: Render directly into ``output_dir`` instead of a subdirectory
|
|
916
|
+
overlay_is_project: True when the overlay source is the output project
|
|
917
|
+
itself (``--agent local@.``), so its manifest is the project's own
|
|
918
|
+
and is copied. False for any other source, whose manifest describes
|
|
919
|
+
the template and is skipped.
|
|
920
|
+
cli_overrides: CLI override values that take precedence over template config
|
|
921
|
+
remote_spec: the parsed remote spec, when any (unused, kept for callers)
|
|
922
|
+
recorded_base_template: what the manifest records as base_template
|
|
923
|
+
agent_guidance_filename: name of the root guidance file
|
|
924
|
+
auth_policy_implemented: recorded flag to keep on an in-folder re-render (None derives it)
|
|
925
|
+
"""
|
|
926
|
+
logging.debug("Processing template from %s", template_dir)
|
|
927
|
+
logging.debug("Project name: %s", project_name)
|
|
928
|
+
logging.debug("Output directory: %s", output_dir)
|
|
929
|
+
|
|
930
|
+
is_remote = remote_template_path is not None
|
|
931
|
+
|
|
932
|
+
if is_remote:
|
|
933
|
+
base_template_name = get_base_template_name(remote_config or {})
|
|
934
|
+
agent_path = agents_dir() / base_template_name
|
|
935
|
+
logging.debug("Remote template using base: %s", base_template_name)
|
|
936
|
+
elif cli_overrides and cli_overrides.get("base_template"):
|
|
937
|
+
base_template_name = cli_overrides["base_template"]
|
|
938
|
+
agent_path = agents_dir() / base_template_name
|
|
939
|
+
logging.debug("Using base template override: %s", base_template_name)
|
|
940
|
+
else:
|
|
941
|
+
base_template_name = agent_name
|
|
942
|
+
agent_path = pathlib.Path(template_dir).parent
|
|
943
|
+
|
|
944
|
+
logging.debug("agent path: %s", agent_path)
|
|
945
|
+
if not agent_path.exists():
|
|
946
|
+
# Fail here rather than carry on: an unresolvable base silently copies
|
|
947
|
+
# no agent layer and produces a half-built project.
|
|
948
|
+
raise ValueError(
|
|
949
|
+
f"Base template '{base_template_name}' not found at {agent_path}. "
|
|
950
|
+
"A template declares its base in .template/templateconfig.yaml; "
|
|
951
|
+
f"'{base_template_name}' is not one this CLI version provides."
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
template_config = (
|
|
955
|
+
remote_config if remote_config else load_template_config(pathlib.Path(template_dir))
|
|
956
|
+
)
|
|
957
|
+
if not template_config:
|
|
958
|
+
raise ValueError("Could not load template config")
|
|
959
|
+
|
|
960
|
+
get_agent_language(agent_name, template_config)
|
|
961
|
+
agent_directory = _resolve_agent_directory(template_config, cli_overrides, remote_template_path)
|
|
962
|
+
|
|
963
|
+
available_targets = template_config.get("settings", {}).get("deployment_targets", [])
|
|
964
|
+
if isinstance(available_targets, str):
|
|
965
|
+
available_targets = [available_targets]
|
|
966
|
+
if deployment_target not in available_targets:
|
|
967
|
+
raise ValueError(
|
|
968
|
+
f"Invalid deployment target '{deployment_target}'. Available targets: {available_targets}"
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
destination_dir = output_dir if output_dir else pathlib.Path.cwd()
|
|
972
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
973
|
+
|
|
974
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
975
|
+
temp_path = pathlib.Path(temp_dir)
|
|
976
|
+
original_dir = pathlib.Path.cwd()
|
|
977
|
+
|
|
978
|
+
try:
|
|
979
|
+
os.chdir(temp_path)
|
|
980
|
+
|
|
981
|
+
cookiecutter_template = temp_path / "template"
|
|
982
|
+
project_template = cookiecutter_template / "{{cookiecutter.project_name}}"
|
|
983
|
+
project_template.mkdir(parents=True)
|
|
984
|
+
|
|
985
|
+
# 1a. Shared base template files
|
|
986
|
+
shared_base_path = base_templates_dir() / "_shared"
|
|
987
|
+
if shared_base_path.exists():
|
|
988
|
+
copy_files(shared_base_path, project_template, agent_name, overwrite=True)
|
|
989
|
+
logging.debug("1a. Copied shared base template from %s", shared_base_path)
|
|
990
|
+
|
|
991
|
+
# 1b. Python base template files
|
|
992
|
+
language_base_path = base_templates_dir() / "python"
|
|
993
|
+
if language_base_path.exists():
|
|
994
|
+
copy_files(language_base_path, project_template, agent_name, overwrite=True)
|
|
995
|
+
logging.debug("1b. Copied python base template from %s", language_base_path)
|
|
996
|
+
else:
|
|
997
|
+
raise FileNotFoundError(f"Language base template not found: {language_base_path}")
|
|
998
|
+
|
|
999
|
+
# 2. Deployment target layer
|
|
1000
|
+
if deployment_target in DEPLOYMENT_TARGETS:
|
|
1001
|
+
for layer in ("_shared", "python"):
|
|
1002
|
+
layer_path = deployment_targets_dir() / deployment_target / layer
|
|
1003
|
+
if layer_path.exists():
|
|
1004
|
+
copy_files(
|
|
1005
|
+
layer_path, project_template, agent_name=agent_name, overwrite=True
|
|
1006
|
+
)
|
|
1007
|
+
logging.debug("2. Copied deployment files from %s", layer_path)
|
|
1008
|
+
|
|
1009
|
+
# 3. Agent overlay: app/, tests/, deployment/, then the remaining
|
|
1010
|
+
# top-level items so a framework template can own the whole project.
|
|
1011
|
+
if is_remote or (cli_overrides and cli_overrides.get("base_template")):
|
|
1012
|
+
template_agent_directory = "app"
|
|
1013
|
+
else:
|
|
1014
|
+
template_agent_directory = template_config.get("settings", {}).get(
|
|
1015
|
+
"agent_directory", "app"
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
source_agent_folder = agent_path / template_agent_directory
|
|
1019
|
+
if source_agent_folder.exists():
|
|
1020
|
+
logging.debug(
|
|
1021
|
+
"3. Copying agent folder %s -> %s", template_agent_directory, agent_directory
|
|
1022
|
+
)
|
|
1023
|
+
copy_files(
|
|
1024
|
+
source_agent_folder,
|
|
1025
|
+
project_template / agent_directory,
|
|
1026
|
+
agent_name,
|
|
1027
|
+
overwrite=True,
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
other_folders = ["tests", "deployment"]
|
|
1031
|
+
for folder in other_folders:
|
|
1032
|
+
agent_folder = agent_path / folder
|
|
1033
|
+
if agent_folder.exists():
|
|
1034
|
+
logging.debug("3. Copying %s folder with override", folder)
|
|
1035
|
+
copy_files(agent_folder, project_template / folder, agent_name, overwrite=True)
|
|
1036
|
+
|
|
1037
|
+
already_copied = {
|
|
1038
|
+
".template",
|
|
1039
|
+
"__pycache__",
|
|
1040
|
+
template_agent_directory.split("/")[0],
|
|
1041
|
+
*other_folders,
|
|
1042
|
+
}
|
|
1043
|
+
for item in agent_path.iterdir():
|
|
1044
|
+
if item.name in already_copied:
|
|
1045
|
+
continue
|
|
1046
|
+
logging.debug("3c. Overlaying agent-owned %s", item.name)
|
|
1047
|
+
copy_files(item, project_template / item.name, agent_name, overwrite=True)
|
|
1048
|
+
|
|
1049
|
+
# 4. cookiecutter.json
|
|
1050
|
+
cookiecutter_config = build_cookiecutter_context(
|
|
1051
|
+
project_name=project_name,
|
|
1052
|
+
agent_name=agent_name,
|
|
1053
|
+
deployment_target=deployment_target,
|
|
1054
|
+
runtime=runtime,
|
|
1055
|
+
model_provider=model_provider,
|
|
1056
|
+
model=model,
|
|
1057
|
+
checkpointer=checkpointer,
|
|
1058
|
+
registry=registry,
|
|
1059
|
+
cd=cd,
|
|
1060
|
+
auth_policy=auth_policy,
|
|
1061
|
+
has_api_policy=has_api_policy,
|
|
1062
|
+
apis=apis,
|
|
1063
|
+
example_call=example_call,
|
|
1064
|
+
process=process,
|
|
1065
|
+
agent_guidance_filename=agent_guidance_filename,
|
|
1066
|
+
agent_directory=agent_directory,
|
|
1067
|
+
template_config=template_config,
|
|
1068
|
+
recorded_base_template=recorded_base_template,
|
|
1069
|
+
auth_policy_implemented=auth_policy_implemented,
|
|
1070
|
+
)
|
|
1071
|
+
with open(
|
|
1072
|
+
cookiecutter_template / "cookiecutter.json", "w", encoding="utf-8"
|
|
1073
|
+
) as json_file:
|
|
1074
|
+
json.dump(cookiecutter_config, json_file, indent=4)
|
|
1075
|
+
|
|
1076
|
+
logging.debug("Template structure created at %s", cookiecutter_template)
|
|
1077
|
+
|
|
1078
|
+
cookiecutter(
|
|
1079
|
+
str(cookiecutter_template),
|
|
1080
|
+
no_input=True,
|
|
1081
|
+
overwrite_if_exists=True,
|
|
1082
|
+
extra_context={"project_name": project_name, "agent_name": agent_name},
|
|
1083
|
+
)
|
|
1084
|
+
logging.debug("Template processing completed successfully")
|
|
1085
|
+
|
|
1086
|
+
generated_project_dir = temp_path / project_name
|
|
1087
|
+
|
|
1088
|
+
# 4b. Without a call to show, the example API tool renders empty. Drop it
|
|
1089
|
+
# before anything is copied: an in-folder render (enhance --force) must
|
|
1090
|
+
# neither blank nor delete an example_api.py the project already has.
|
|
1091
|
+
has_example_api = has_api_policy and example_call is not None
|
|
1092
|
+
if not has_example_api:
|
|
1093
|
+
example_tool = generated_project_dir / EXAMPLE_API_TOOL.replace(
|
|
1094
|
+
"{agent_directory}", agent_directory
|
|
1095
|
+
)
|
|
1096
|
+
example_tool.unlink(missing_ok=True)
|
|
1097
|
+
|
|
1098
|
+
# 5. Remote overlay (after cookiecutter, so its files are never rendered)
|
|
1099
|
+
if is_remote and remote_template_path:
|
|
1100
|
+
logging.debug(
|
|
1101
|
+
"Copying remote template files from %s to %s",
|
|
1102
|
+
remote_template_path,
|
|
1103
|
+
generated_project_dir,
|
|
1104
|
+
)
|
|
1105
|
+
cli_agent_dir = (
|
|
1106
|
+
cli_overrides.get("settings", {}).get("agent_directory")
|
|
1107
|
+
if cli_overrides
|
|
1108
|
+
else None
|
|
1109
|
+
)
|
|
1110
|
+
is_flat_structure = (cli_agent_dir == ".") or bool(
|
|
1111
|
+
remote_config and remote_config.get("is_flat_structure", False)
|
|
1112
|
+
)
|
|
1113
|
+
if is_flat_structure:
|
|
1114
|
+
copy_flat_structure_agent_files(
|
|
1115
|
+
remote_template_path, generated_project_dir, agent_directory
|
|
1116
|
+
)
|
|
1117
|
+
else:
|
|
1118
|
+
copy_files(
|
|
1119
|
+
remote_template_path,
|
|
1120
|
+
generated_project_dir,
|
|
1121
|
+
agent_name=agent_name,
|
|
1122
|
+
overwrite=True,
|
|
1123
|
+
skip_manifest=not overlay_is_project,
|
|
1124
|
+
guidance_filename=agent_guidance_filename,
|
|
1125
|
+
)
|
|
1126
|
+
logging.debug("Remote template files copied successfully")
|
|
1127
|
+
|
|
1128
|
+
# 6. Move to the final destination
|
|
1129
|
+
if in_folder:
|
|
1130
|
+
final_destination = destination_dir
|
|
1131
|
+
if generated_project_dir.exists():
|
|
1132
|
+
for item in generated_project_dir.iterdir():
|
|
1133
|
+
dest_item = final_destination / item.name
|
|
1134
|
+
if item.is_dir():
|
|
1135
|
+
if dest_item.exists():
|
|
1136
|
+
shutil.rmtree(dest_item)
|
|
1137
|
+
shutil.copytree(item, dest_item, dirs_exist_ok=True)
|
|
1138
|
+
else:
|
|
1139
|
+
shutil.copy2(item, dest_item)
|
|
1140
|
+
logging.debug("Project files copied to %s", final_destination)
|
|
1141
|
+
else:
|
|
1142
|
+
final_destination = destination_dir / project_name
|
|
1143
|
+
if generated_project_dir.exists():
|
|
1144
|
+
if final_destination.exists():
|
|
1145
|
+
shutil.rmtree(final_destination)
|
|
1146
|
+
shutil.copytree(generated_project_dir, final_destination, dirs_exist_ok=True)
|
|
1147
|
+
logging.debug("Project created at %s", final_destination)
|
|
1148
|
+
|
|
1149
|
+
if not final_destination.exists():
|
|
1150
|
+
raise FileNotFoundError(
|
|
1151
|
+
f"Final destination directory not found at {final_destination}"
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
# 7. Conditional files, then runtime-specific Dockerfile and lock
|
|
1155
|
+
conditional_config = {
|
|
1156
|
+
"agent_name": agent_name,
|
|
1157
|
+
"deployment_target": deployment_target,
|
|
1158
|
+
"runtime": runtime,
|
|
1159
|
+
"cd": cd,
|
|
1160
|
+
"has_api_policy": has_api_policy,
|
|
1161
|
+
# In folder, an example_api.py still present is the project's own (4b).
|
|
1162
|
+
"has_example_api": has_example_api or in_folder,
|
|
1163
|
+
}
|
|
1164
|
+
apply_conditional_files(final_destination, conditional_config, agent_directory)
|
|
1165
|
+
_remove_unused_paths(final_destination)
|
|
1166
|
+
select_runtime_files(final_destination, runtime, project_name)
|
|
1167
|
+
|
|
1168
|
+
except Exception as e:
|
|
1169
|
+
logging.error(f"Failed to process template: {e!s}")
|
|
1170
|
+
raise
|
|
1171
|
+
finally:
|
|
1172
|
+
os.chdir(original_dir)
|
|
1173
|
+
|
|
1174
|
+
return final_destination
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
# =============================================================================
|
|
1178
|
+
# File copying
|
|
1179
|
+
# =============================================================================
|
|
1180
|
+
|
|
1181
|
+
_TOOL_CACHES = frozenset({"__pycache__", ".ruff_cache", ".pytest_cache", ".mypy_cache", ".venv"})
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def copy_files(
|
|
1185
|
+
src: pathlib.Path,
|
|
1186
|
+
dst: pathlib.Path,
|
|
1187
|
+
agent_name: str | None = None,
|
|
1188
|
+
overwrite: bool = False,
|
|
1189
|
+
*,
|
|
1190
|
+
skip_manifest: bool = False,
|
|
1191
|
+
guidance_filename: str | None = None,
|
|
1192
|
+
) -> None:
|
|
1193
|
+
"""Copy files with configurable behavior for exclusions and overwrites.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
src: Source path
|
|
1197
|
+
dst: Destination path
|
|
1198
|
+
agent_name: Name of the agent (kept for call compatibility)
|
|
1199
|
+
overwrite: Whether to overwrite existing files (True) or skip them (False)
|
|
1200
|
+
guidance_filename: Write a root AGENTS.md under this name instead, so a
|
|
1201
|
+
template's guide replaces the base one whatever the project calls it.
|
|
1202
|
+
skip_manifest: Skip a root graph-agents-cli-manifest.yaml. Set when copying a
|
|
1203
|
+
fetched template, whose manifest describes the template rather than
|
|
1204
|
+
the project and has already been read for config.
|
|
1205
|
+
"""
|
|
1206
|
+
|
|
1207
|
+
def should_skip(path: pathlib.Path) -> bool:
|
|
1208
|
+
"""Symlinks are never followed (CWE-59): a template could point one at
|
|
1209
|
+
a sensitive host file and the project would end up with its contents."""
|
|
1210
|
+
if path.is_symlink():
|
|
1211
|
+
logging.warning(
|
|
1212
|
+
f"Skipping symlink in template source (symlinks are not allowed): {path}"
|
|
1213
|
+
)
|
|
1214
|
+
return True
|
|
1215
|
+
if path.suffix in [".pyc"]:
|
|
1216
|
+
return True
|
|
1217
|
+
if "__pycache__" in str(path) or path.name in _TOOL_CACHES:
|
|
1218
|
+
return True
|
|
1219
|
+
if ".git" in path.parts:
|
|
1220
|
+
return True
|
|
1221
|
+
if path.is_dir() and path.name == ".template":
|
|
1222
|
+
return True
|
|
1223
|
+
if skip_manifest and path.name == MANIFEST_FILENAME:
|
|
1224
|
+
return True
|
|
1225
|
+
return False
|
|
1226
|
+
|
|
1227
|
+
def log_windows_path_warning(path: pathlib.Path) -> None:
|
|
1228
|
+
if sys.platform == "win32":
|
|
1229
|
+
path_str = str(path.absolute())
|
|
1230
|
+
if len(path_str) >= 260:
|
|
1231
|
+
logging.error(
|
|
1232
|
+
f"Path length ({len(path_str)} chars) may exceed Windows limit. "
|
|
1233
|
+
"Try using a shorter output directory."
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
if src.is_dir():
|
|
1237
|
+
if not dst.exists():
|
|
1238
|
+
try:
|
|
1239
|
+
dst.mkdir(parents=True)
|
|
1240
|
+
logging.debug("Created directory: %s", dst)
|
|
1241
|
+
except OSError as e:
|
|
1242
|
+
logging.error(f"Failed to create directory: {dst}")
|
|
1243
|
+
logging.error(f"Error: {e}")
|
|
1244
|
+
raise
|
|
1245
|
+
for item in src.iterdir():
|
|
1246
|
+
if should_skip(item):
|
|
1247
|
+
logging.debug("Skipping file/directory: %s", item)
|
|
1248
|
+
continue
|
|
1249
|
+
|
|
1250
|
+
# Root only: nested AGENTS.md files are the template's own docs.
|
|
1251
|
+
if guidance_filename and item.is_file() and item.name == "AGENTS.md":
|
|
1252
|
+
d = dst / guidance_filename
|
|
1253
|
+
else:
|
|
1254
|
+
d = dst / item.name
|
|
1255
|
+
if item.is_dir():
|
|
1256
|
+
copy_files(item, d, agent_name, overwrite)
|
|
1257
|
+
else:
|
|
1258
|
+
if overwrite or not d.exists():
|
|
1259
|
+
try:
|
|
1260
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
1261
|
+
logging.debug("Copying file: %s -> %s", item, d)
|
|
1262
|
+
shutil.copy2(item, d)
|
|
1263
|
+
except OSError:
|
|
1264
|
+
logging.error(f"Failed to copy: {item} -> {d}")
|
|
1265
|
+
log_windows_path_warning(d)
|
|
1266
|
+
raise
|
|
1267
|
+
else:
|
|
1268
|
+
logging.debug("Skipping existing file: %s", d)
|
|
1269
|
+
else:
|
|
1270
|
+
if not should_skip(src):
|
|
1271
|
+
if overwrite or not dst.exists():
|
|
1272
|
+
try:
|
|
1273
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1274
|
+
logging.debug("Copying file: %s -> %s", src, dst)
|
|
1275
|
+
shutil.copy2(src, dst)
|
|
1276
|
+
except OSError:
|
|
1277
|
+
logging.error(f"Failed to copy: {src} -> {dst}")
|
|
1278
|
+
log_windows_path_warning(dst)
|
|
1279
|
+
raise
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _assert_path_within(candidate: pathlib.Path, root: pathlib.Path) -> None:
|
|
1283
|
+
"""Raise ValueError if *candidate* is not contained within *root*.
|
|
1284
|
+
|
|
1285
|
+
Both paths are resolved to their real absolute forms before the check so
|
|
1286
|
+
that symbolic links and ``..`` components cannot bypass the boundary.
|
|
1287
|
+
"""
|
|
1288
|
+
try:
|
|
1289
|
+
candidate.resolve().relative_to(root.resolve())
|
|
1290
|
+
except ValueError as e:
|
|
1291
|
+
raise ValueError(
|
|
1292
|
+
f"Security check failed: '{candidate}' would be written "
|
|
1293
|
+
f"outside the project directory '{root}'. "
|
|
1294
|
+
"Aborting to prevent path-traversal exploitation."
|
|
1295
|
+
) from e
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _skip_symlinks(directory: str, names: list[str]) -> set[str]:
|
|
1299
|
+
"""Ignore callback for shutil.copytree to skip symlinks at every nesting level."""
|
|
1300
|
+
return {n for n in names if pathlib.Path(directory, n).is_symlink()}
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def copy_flat_structure_agent_files(
|
|
1304
|
+
src: pathlib.Path,
|
|
1305
|
+
dst: pathlib.Path,
|
|
1306
|
+
agent_directory: str,
|
|
1307
|
+
) -> None:
|
|
1308
|
+
"""Copy agent files from a flat structure template to the agent directory.
|
|
1309
|
+
|
|
1310
|
+
Python files (*.py) in the template root go to the agent directory; other
|
|
1311
|
+
files go to the project root. Symlinks are never followed and every
|
|
1312
|
+
destination is verified to be inside *dst* before any write.
|
|
1313
|
+
"""
|
|
1314
|
+
agent_dst = dst / agent_directory
|
|
1315
|
+
_assert_path_within(agent_dst, dst)
|
|
1316
|
+
agent_dst.mkdir(parents=True, exist_ok=True)
|
|
1317
|
+
|
|
1318
|
+
agent_file_extensions = {".py"}
|
|
1319
|
+
skip_files = {"pyproject.toml", "uv.lock", "README.md", ".gitignore"}
|
|
1320
|
+
|
|
1321
|
+
for item in src.iterdir():
|
|
1322
|
+
if item.name.startswith(".") or item.name in skip_files:
|
|
1323
|
+
continue
|
|
1324
|
+
if item.name == "__pycache__":
|
|
1325
|
+
continue
|
|
1326
|
+
if item.is_symlink():
|
|
1327
|
+
logging.warning(
|
|
1328
|
+
f"Skipping symlink in flat-structure template source "
|
|
1329
|
+
f"(symlinks are not allowed): {item}"
|
|
1330
|
+
)
|
|
1331
|
+
continue
|
|
1332
|
+
|
|
1333
|
+
if item.is_file():
|
|
1334
|
+
if item.suffix in agent_file_extensions:
|
|
1335
|
+
dest_file = agent_dst / item.name
|
|
1336
|
+
_assert_path_within(dest_file, dst)
|
|
1337
|
+
logging.debug(
|
|
1338
|
+
"Flat structure: copying %s -> %s/%s", item.name, agent_directory, item.name
|
|
1339
|
+
)
|
|
1340
|
+
shutil.copy2(item, dest_file)
|
|
1341
|
+
else:
|
|
1342
|
+
dest_file = dst / item.name
|
|
1343
|
+
_assert_path_within(dest_file, dst)
|
|
1344
|
+
logging.debug("Flat structure: copying %s -> %s", item.name, item.name)
|
|
1345
|
+
shutil.copy2(item, dest_file)
|
|
1346
|
+
elif item.is_dir():
|
|
1347
|
+
dest_dir = dst / item.name
|
|
1348
|
+
_assert_path_within(dest_dir, dst)
|
|
1349
|
+
logging.debug("Flat structure: copying directory %s", item.name)
|
|
1350
|
+
if dest_dir.exists():
|
|
1351
|
+
shutil.rmtree(dest_dir)
|
|
1352
|
+
shutil.copytree(item, dest_dir, ignore=_skip_symlinks)
|