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,453 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Resolve extension references to commit SHAs and vendor extension directories."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
from filelock import FileLock, Timeout
|
|
31
|
+
|
|
32
|
+
from graph_agents_cli._runner import run_resolved
|
|
33
|
+
from graph_agents_cli.extension._paths import git_cache_dir_name, git_cache_root
|
|
34
|
+
from graph_agents_cli.extension._refs import ExtensionRef
|
|
35
|
+
from graph_agents_cli.extension._spec import EXTENSION_FILE
|
|
36
|
+
from graph_agents_cli.scaffold.utils.fs import source_ignore_patterns
|
|
37
|
+
|
|
38
|
+
# Only a full 40-char commit SHA is unambiguous. A short/partial hex string
|
|
39
|
+
# could instead be a branch or tag named like hex (e.g. "cafe123"), so anything
|
|
40
|
+
# shorter than 40 goes through ls-remote ref resolution rather than being
|
|
41
|
+
# treated as an already-resolved commit.
|
|
42
|
+
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
43
|
+
# An extension name becomes a directory component under the vendoring root, so it
|
|
44
|
+
# must not contain path separators or traversal — reject anything else.
|
|
45
|
+
_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_extension_name(name: str) -> str:
|
|
49
|
+
"""Return ``name`` if safe as a single path component, else raise.
|
|
50
|
+
|
|
51
|
+
Guards the vendoring path (``dest_root / name``, which ``_replace_tree``
|
|
52
|
+
``rmtree``s and recreates) against a malicious ``name:`` (e.g. ``../../x``)
|
|
53
|
+
read from an upstream extension file or a committed manifest.
|
|
54
|
+
"""
|
|
55
|
+
if name in (".", "..") or not _SAFE_NAME_RE.match(name):
|
|
56
|
+
raise ResolverError(
|
|
57
|
+
f"unsafe extension name {name!r}: use only letters, digits, '.', '_', '-'."
|
|
58
|
+
)
|
|
59
|
+
return name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Seconds to wait for the shared git-cache lock before giving up.
|
|
63
|
+
_CACHE_LOCK_TIMEOUT_SECONDS = 120
|
|
64
|
+
|
|
65
|
+
# Records what a vendored copy was materialized from, so sync can detect a
|
|
66
|
+
# stale or partial copy (dir exists but doesn't match its source) instead of
|
|
67
|
+
# trusting mere directory existence.
|
|
68
|
+
STAMP_FILE = ".graph-agents-cli-extension-sha"
|
|
69
|
+
|
|
70
|
+
# Recorded as the pin of a `local@` extension, which has no commit to point at.
|
|
71
|
+
LOCAL_SHA = "local"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _git_ignored(root: Path) -> frozenset[str]:
|
|
75
|
+
"""POSIX paths, relative to ``root``, that git considers ignored there.
|
|
76
|
+
|
|
77
|
+
Asked of git rather than parsed out of ``.gitignore``, which buys four
|
|
78
|
+
things no pattern match of our own would get right: nested ignore files and
|
|
79
|
+
their negations, ``.git/info/exclude`` and the user's global
|
|
80
|
+
``core.excludesFile``, correct results when ``root`` is a subdirectory of
|
|
81
|
+
the repository whose rules apply (a ``local@…#selector`` ref), and — the one
|
|
82
|
+
that is a correctness bug rather than a gap — force-added files. Those are
|
|
83
|
+
tracked, so they are part of the extension however the patterns read, and
|
|
84
|
+
``--others`` never lists them.
|
|
85
|
+
|
|
86
|
+
Empty when ``root`` is not in a work tree, which is a perfectly ordinary way
|
|
87
|
+
to hold an extension source — and empty, rather than an error, when there is
|
|
88
|
+
no git at all: vendoring a local source has never needed one, and gaining a
|
|
89
|
+
hard dependency on it here would be a regression, not a feature.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
result = _git(
|
|
93
|
+
"-C",
|
|
94
|
+
str(root),
|
|
95
|
+
"ls-files",
|
|
96
|
+
"--others",
|
|
97
|
+
"--ignored",
|
|
98
|
+
"--exclude-standard",
|
|
99
|
+
# Collapse a wholly-ignored directory to its own name: the point is
|
|
100
|
+
# to skip `.venv/` without first enumerating what is inside it.
|
|
101
|
+
"--directory",
|
|
102
|
+
"-z",
|
|
103
|
+
)
|
|
104
|
+
except OSError as e:
|
|
105
|
+
logging.debug("Could not ask git what %s ignores: %s", root, e)
|
|
106
|
+
return frozenset()
|
|
107
|
+
if result.returncode != 0:
|
|
108
|
+
logging.debug("No git ignore rules apply to %s: %s", root, result.stderr.strip())
|
|
109
|
+
return frozenset()
|
|
110
|
+
return frozenset(path.rstrip("/") for path in result.stdout.split("\0") if path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _SourceExclusions:
|
|
114
|
+
"""What to leave behind when reading an extension source.
|
|
115
|
+
|
|
116
|
+
One instance answers for both the fingerprint and the copy, deliberately. If
|
|
117
|
+
the hash counted something the copy leaves out, every change to it would
|
|
118
|
+
stamp a mismatch and provoke a re-copy that produces a byte-identical tree —
|
|
119
|
+
reporting a repair that did not happen.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, root: Path, *, use_gitignore: bool):
|
|
123
|
+
self._root = str(root)
|
|
124
|
+
# Only for a working directory. A git checkout holds tracked files and
|
|
125
|
+
# nothing else, so there is nothing ignored in it to find — and asking
|
|
126
|
+
# anyway would drop a force-added file that the commit genuinely
|
|
127
|
+
# contains.
|
|
128
|
+
self._ignored = _git_ignored(root) if use_gitignore else frozenset()
|
|
129
|
+
|
|
130
|
+
def __call__(self, directory: str, names: list[str]) -> set[str]:
|
|
131
|
+
excluded = source_ignore_patterns(directory, names)
|
|
132
|
+
if not self._ignored:
|
|
133
|
+
return excluded
|
|
134
|
+
rel = os.path.relpath(directory, self._root)
|
|
135
|
+
prefix = "" if rel == os.curdir else f"{Path(rel).as_posix()}/"
|
|
136
|
+
return excluded | {n for n in names if f"{prefix}{n}" in self._ignored}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ResolverError(Exception):
|
|
140
|
+
"""Raised when an extension cannot be resolved or vendored."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def read_stamp(extension_dir: Path) -> str | None:
|
|
144
|
+
"""Return what an extension dir was materialized from, or None if unstamped."""
|
|
145
|
+
stamp = extension_dir / STAMP_FILE
|
|
146
|
+
if not stamp.exists():
|
|
147
|
+
return None
|
|
148
|
+
return stamp.read_text(encoding="utf-8").strip() or None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def expected_stamp(ref: ExtensionRef, sha: str) -> str:
|
|
152
|
+
"""Return the stamp a freshly materialized copy of ``ref`` would carry.
|
|
153
|
+
|
|
154
|
+
For a git ref that is the pinned SHA. A ``local@`` ref has no commit to pin —
|
|
155
|
+
its recorded pin is the constant ``LOCAL_SHA`` — so its copy is stamped with a
|
|
156
|
+
hash of the source tree instead. Comparing that hash is what lets sync notice
|
|
157
|
+
edits to a local source; comparing the pin would only match the sentinel
|
|
158
|
+
against itself and never refresh.
|
|
159
|
+
"""
|
|
160
|
+
if ref.kind != "local":
|
|
161
|
+
return sha
|
|
162
|
+
src = _local_source_dir(ref)
|
|
163
|
+
return _fingerprint(src, _SourceExclusions(src, use_gitignore=True))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _fingerprint(root: Path, exclude: _SourceExclusions) -> str:
|
|
167
|
+
"""Return a hash of the name and contents of every file under ``root``.
|
|
168
|
+
|
|
169
|
+
Walked in sorted order and keyed on POSIX-style relative paths, so a given
|
|
170
|
+
tree hashes identically across runs and platforms.
|
|
171
|
+
|
|
172
|
+
Hashes exactly what the copy would contain: symlinks are skipped (see
|
|
173
|
+
``_skip_symlinks``) and so is everything ``exclude`` drops, files included —
|
|
174
|
+
``.env`` is a file, and hashing one the copy omits would make every edit to
|
|
175
|
+
it force a re-copy that changes nothing.
|
|
176
|
+
|
|
177
|
+
Scanned with ``os.scandir``, whose entries answer "directory or symlink?"
|
|
178
|
+
from the directory read itself: one syscall per directory rather than a
|
|
179
|
+
``stat`` per name. An unreadable directory raises rather than being skipped,
|
|
180
|
+
since a tree walked only in part hashes the same after a change inside the
|
|
181
|
+
part that was missed.
|
|
182
|
+
"""
|
|
183
|
+
digest = hashlib.sha256()
|
|
184
|
+
# (directory, its POSIX path relative to root). A stack, so a deep source
|
|
185
|
+
# tree cannot exhaust the interpreter's recursion limit.
|
|
186
|
+
stack: list[tuple[str, str]] = [(str(root), "")]
|
|
187
|
+
try:
|
|
188
|
+
while stack:
|
|
189
|
+
directory, rel_dir = stack.pop()
|
|
190
|
+
with os.scandir(directory) as scan:
|
|
191
|
+
entries = [entry for entry in scan if not entry.is_symlink()]
|
|
192
|
+
excluded = exclude(directory, [entry.name for entry in entries])
|
|
193
|
+
subdirs: list[os.DirEntry[str]] = []
|
|
194
|
+
files: list[os.DirEntry[str]] = []
|
|
195
|
+
for entry in entries:
|
|
196
|
+
if entry.name in excluded:
|
|
197
|
+
continue
|
|
198
|
+
if entry.is_dir(follow_symlinks=False):
|
|
199
|
+
subdirs.append(entry)
|
|
200
|
+
else:
|
|
201
|
+
files.append(entry)
|
|
202
|
+
prefix = f"{rel_dir}/" if rel_dir else ""
|
|
203
|
+
for entry in sorted(files, key=lambda e: e.name):
|
|
204
|
+
with open(entry.path, "rb") as f:
|
|
205
|
+
contents = hashlib.file_digest(f, "sha256").hexdigest()
|
|
206
|
+
digest.update(f"{prefix}{entry.name}\0{contents}\0".encode())
|
|
207
|
+
# Reversed, so popping visits the subdirectories in sorted order.
|
|
208
|
+
for entry in sorted(subdirs, key=lambda e: e.name, reverse=True):
|
|
209
|
+
stack.append((entry.path, f"{prefix}{entry.name}"))
|
|
210
|
+
except OSError as e:
|
|
211
|
+
raise ResolverError(f"could not read local extension source {root}: {e}") from e
|
|
212
|
+
return digest.hexdigest()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _local_source_dir(ref: ExtensionRef) -> Path:
|
|
216
|
+
"""Return the directory a ``local@`` ref selects, or raise if it is unusable."""
|
|
217
|
+
src = ref.local_path
|
|
218
|
+
if src is None or not src.exists():
|
|
219
|
+
raise ResolverError(f"local extension path not found: {ref.local_path}.")
|
|
220
|
+
sub = src / ref.selector if ref.selector else src
|
|
221
|
+
if not (sub / EXTENSION_FILE).exists():
|
|
222
|
+
raise ResolverError(f"{sub} has no {EXTENSION_FILE}.")
|
|
223
|
+
return sub
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _https_url(repo: str) -> str:
|
|
227
|
+
return f"https://github.com/{repo}.git"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _clone_url(ref: ExtensionRef) -> str:
|
|
231
|
+
"""Return the URL git should clone or query for a non-local ref."""
|
|
232
|
+
if ref.url is not None:
|
|
233
|
+
return ref.url
|
|
234
|
+
if ref.repo is None:
|
|
235
|
+
raise ResolverError(f"cannot resolve ref {ref.raw!r}: repo is not set.")
|
|
236
|
+
return _https_url(ref.repo)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _git(*args: str) -> subprocess.CompletedProcess:
|
|
240
|
+
"""Run a git command with output captured (no shell, no PATH resolution)."""
|
|
241
|
+
return run_resolved(
|
|
242
|
+
["git", *args],
|
|
243
|
+
resolve_executable=False,
|
|
244
|
+
capture_output=True,
|
|
245
|
+
text=True,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def resolve_sha(ref: ExtensionRef) -> str:
|
|
250
|
+
"""Resolve an ExtensionRef to an exact commit SHA.
|
|
251
|
+
|
|
252
|
+
For local refs returns the sentinel ``LOCAL_SHA`` (unpinned).
|
|
253
|
+
For every other ref runs ``git ls-remote`` unless the caller
|
|
254
|
+
already supplied a full hex SHA.
|
|
255
|
+
"""
|
|
256
|
+
if ref.kind == "local":
|
|
257
|
+
return LOCAL_SHA
|
|
258
|
+
if ref.ref and _SHA_RE.match(ref.ref):
|
|
259
|
+
return ref.ref
|
|
260
|
+
target = ref.ref or "HEAD"
|
|
261
|
+
result = _git("ls-remote", _clone_url(ref), target)
|
|
262
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
263
|
+
raise ResolverError(
|
|
264
|
+
f"could not resolve {target!r} in {ref.repo!r}; check the repo and ref."
|
|
265
|
+
)
|
|
266
|
+
return result.stdout.split()[0]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extension_name_from_dir(extension_dir: Path, fallback: str) -> str:
|
|
270
|
+
spec_path = extension_dir / EXTENSION_FILE
|
|
271
|
+
if spec_path.exists():
|
|
272
|
+
try:
|
|
273
|
+
with open(spec_path, encoding="utf-8") as f:
|
|
274
|
+
data: Any = yaml.safe_load(f) or {}
|
|
275
|
+
if isinstance(data, dict) and isinstance(data.get("name"), str):
|
|
276
|
+
return str(data["name"])
|
|
277
|
+
except (OSError, RecursionError, UnicodeDecodeError, yaml.YAMLError):
|
|
278
|
+
# Best-effort name detection only: fall back to the caller's name if
|
|
279
|
+
# the file is unreadable/malformed. The loader reports it properly —
|
|
280
|
+
# warned about and skipped on load, fatal for `add`/`update`.
|
|
281
|
+
pass
|
|
282
|
+
return fallback
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _skip_symlinks(directory: str, names: list[str]) -> set[str]:
|
|
286
|
+
"""Names in ``directory`` that are symlinks, for ``copytree(ignore=...)``."""
|
|
287
|
+
skipped = {n for n in names if Path(directory, n).is_symlink()}
|
|
288
|
+
for name in sorted(skipped):
|
|
289
|
+
logging.warning("Skipping symlink in extension source: %s", Path(directory, name))
|
|
290
|
+
return skipped
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _ignored_names(
|
|
294
|
+
exclude: _SourceExclusions,
|
|
295
|
+
) -> Callable[[str, list[str]], set[str]]:
|
|
296
|
+
"""Return the ``copytree(ignore=...)`` callback for an extension source."""
|
|
297
|
+
|
|
298
|
+
def ignore(directory: str, names: list[str]) -> set[str]:
|
|
299
|
+
return _skip_symlinks(directory, names) | exclude(directory, names)
|
|
300
|
+
|
|
301
|
+
return ignore
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _replace_tree(src: Path, dest: Path, exclude: _SourceExclusions) -> None:
|
|
305
|
+
"""Replace ``dest`` with a copy of ``src``, leaving it alone if the copy fails."""
|
|
306
|
+
# Copy alongside and swap, so an unreadable source cannot delete the working
|
|
307
|
+
# copy on its way to failing. shutil.Error is an OSError.
|
|
308
|
+
staged = dest.parent / f".{dest.name}.incoming"
|
|
309
|
+
try:
|
|
310
|
+
shutil.rmtree(staged, ignore_errors=True)
|
|
311
|
+
# Never follow symlinks out of an extension: copytree would otherwise
|
|
312
|
+
# dereference `notes.md -> ~/.ssh/id_rsa` into a real file in the
|
|
313
|
+
# project, where it gets committed (CWE-59).
|
|
314
|
+
shutil.copytree(src, staged, ignore=_ignored_names(exclude))
|
|
315
|
+
if dest.exists():
|
|
316
|
+
shutil.rmtree(dest)
|
|
317
|
+
staged.rename(dest)
|
|
318
|
+
except OSError as e:
|
|
319
|
+
shutil.rmtree(staged, ignore_errors=True)
|
|
320
|
+
raise ResolverError(f"could not copy {src} into {dest}: {e}") from e
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _write_stamp(extension_dir: Path, sha: str) -> None:
|
|
324
|
+
"""Record the SHA the copy was materialized from (written last, post-copy)."""
|
|
325
|
+
(extension_dir / STAMP_FILE).write_text(sha, encoding="utf-8")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def materialize(
|
|
329
|
+
ref: ExtensionRef,
|
|
330
|
+
sha: str,
|
|
331
|
+
dest_root: Path,
|
|
332
|
+
*,
|
|
333
|
+
name: str | None = None,
|
|
334
|
+
) -> tuple[str, Path]:
|
|
335
|
+
"""Vendor an extension directory into ``dest_root/<extension_name>``.
|
|
336
|
+
|
|
337
|
+
Returns ``(extension_name, extension_dir)``.
|
|
338
|
+
For ``local@`` refs, copies from the local path.
|
|
339
|
+
For git refs, clones/reuses the cache and checks out ``sha``.
|
|
340
|
+
|
|
341
|
+
When ``name`` is provided the vendored directory is placed at
|
|
342
|
+
``dest_root/<name>`` (the caller's stable id) and that name is returned,
|
|
343
|
+
regardless of what the upstream ``graph-agents-cli-extension.yaml`` declares. This
|
|
344
|
+
prevents an extension rename upstream from silently relocating (and losing) the
|
|
345
|
+
vendored copy.
|
|
346
|
+
"""
|
|
347
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
|
|
349
|
+
if ref.kind == "local":
|
|
350
|
+
sub = _local_source_dir(ref)
|
|
351
|
+
spec_name = _extension_name_from_dir(sub, ref.selector or sub.name)
|
|
352
|
+
resolved_name = validate_extension_name(name if name is not None else spec_name)
|
|
353
|
+
extension_dir = dest_root / resolved_name
|
|
354
|
+
exclude = _SourceExclusions(sub, use_gitignore=True)
|
|
355
|
+
# Fingerprinted before the copy, so a source edited while it is being
|
|
356
|
+
# copied leaves the older stamp and the next sync redoes the copy,
|
|
357
|
+
# rather than blessing a tree that is half of each.
|
|
358
|
+
stamp = _fingerprint(sub, exclude)
|
|
359
|
+
_replace_tree(sub, extension_dir, exclude)
|
|
360
|
+
_write_stamp(extension_dir, stamp)
|
|
361
|
+
return resolved_name, extension_dir
|
|
362
|
+
|
|
363
|
+
checkout = _checkout(ref, sha)
|
|
364
|
+
if ref.kind == "first_party":
|
|
365
|
+
# First-party extensions live under extensions/<selector>/ in the repo.
|
|
366
|
+
sub = checkout / "extensions" / ref.selector if ref.selector else checkout
|
|
367
|
+
else:
|
|
368
|
+
sub = checkout / ref.selector if ref.selector else checkout
|
|
369
|
+
if not (sub / EXTENSION_FILE).exists():
|
|
370
|
+
raise ResolverError(
|
|
371
|
+
f"{ref.repo}#{ref.selector or ''} has no {EXTENSION_FILE} at the pinned commit."
|
|
372
|
+
)
|
|
373
|
+
spec_name = _extension_name_from_dir(
|
|
374
|
+
sub, ref.selector or (ref.repo.split("/")[-1] if ref.repo else "extension")
|
|
375
|
+
)
|
|
376
|
+
resolved_name = validate_extension_name(name if name is not None else spec_name)
|
|
377
|
+
extension_dir = dest_root / resolved_name
|
|
378
|
+
_replace_tree(sub, extension_dir, _SourceExclusions(sub, use_gitignore=False))
|
|
379
|
+
_write_stamp(extension_dir, sha)
|
|
380
|
+
return resolved_name, extension_dir
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _checkout(ref: ExtensionRef, sha: str) -> Path:
|
|
384
|
+
"""Clone (or reuse) the repo in the shared cache and check out ``sha``.
|
|
385
|
+
|
|
386
|
+
Returns the worktree root path.
|
|
387
|
+
"""
|
|
388
|
+
cache = git_cache_root()
|
|
389
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
if ref.repo is None:
|
|
391
|
+
raise ResolverError(f"cannot checkout ref {ref.raw!r}: repo is not set.")
|
|
392
|
+
repo_dir = cache / git_cache_dir_name(ref.repo, ref.url)
|
|
393
|
+
url = _clone_url(ref)
|
|
394
|
+
|
|
395
|
+
# The cache is shared across processes and checkout mutates a single working
|
|
396
|
+
# tree, so serialize all git operations on this repo with a per-repo lock.
|
|
397
|
+
try:
|
|
398
|
+
lock = FileLock(
|
|
399
|
+
str(cache / f"{repo_dir.name}.lock"),
|
|
400
|
+
timeout=_CACHE_LOCK_TIMEOUT_SECONDS,
|
|
401
|
+
)
|
|
402
|
+
with lock:
|
|
403
|
+
return _checkout_locked(repo_dir, url, ref.repo, sha)
|
|
404
|
+
except Timeout as e:
|
|
405
|
+
raise ResolverError(
|
|
406
|
+
f"timed out waiting for the extension cache lock for {ref.repo}; "
|
|
407
|
+
"another graph-agents-cli process may be resolving extensions."
|
|
408
|
+
) from e
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _clone(repo_dir: Path, url: str, repo: str) -> None:
|
|
412
|
+
"""Clone ``url`` into ``repo_dir``, fetching as little as possible.
|
|
413
|
+
|
|
414
|
+
Only the pinned commit is ever checked out, so the default branch's file
|
|
415
|
+
contents are pure waste: ``--no-checkout`` skips materializing them and
|
|
416
|
+
``--filter=blob:none`` skips downloading them, leaving git to fetch blobs
|
|
417
|
+
lazily for the commit that is actually wanted.
|
|
418
|
+
|
|
419
|
+
``--depth=1`` is deliberately not used. The caller checks out an arbitrary
|
|
420
|
+
SHA, which a shallow clone can only reach if the server allows
|
|
421
|
+
``uploadpack.allowReachableSHA1InWant``, and the existing ``fetch --all``
|
|
422
|
+
fallback does not unshallow.
|
|
423
|
+
"""
|
|
424
|
+
base = ["clone", "--quiet", "--no-checkout"]
|
|
425
|
+
res = _git(*base, "--filter=blob:none", url, str(repo_dir))
|
|
426
|
+
if res.returncode == 0:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
# A server that does not set uploadpack.allowFilter needs no handling here:
|
|
430
|
+
# measured against git 2.51, the client warns ("filtering not recognized by
|
|
431
|
+
# server, ignoring") and completes a full clone, exit 0. What does fail is a
|
|
432
|
+
# client older than the flag (git < 2.19), which rejects it as an unknown
|
|
433
|
+
# option — so retry without it rather than breaking `extension add` outright.
|
|
434
|
+
logging.debug("Blobless clone of %s failed, retrying in full: %s", repo, res.stderr)
|
|
435
|
+
shutil.rmtree(repo_dir, ignore_errors=True)
|
|
436
|
+
res = _git(*base, url, str(repo_dir))
|
|
437
|
+
if res.returncode != 0:
|
|
438
|
+
raise ResolverError(f"git clone failed for {repo}: {res.stderr.strip()}")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _checkout_locked(repo_dir: Path, url: str, repo: str, sha: str) -> Path:
|
|
442
|
+
if not (repo_dir / ".git").exists():
|
|
443
|
+
_clone(repo_dir, url, repo)
|
|
444
|
+
|
|
445
|
+
fetch = _git("-C", str(repo_dir), "fetch", "--quiet", "origin", sha)
|
|
446
|
+
# Some hosts reject fetching an arbitrary SHA; fall back to a full fetch.
|
|
447
|
+
if fetch.returncode != 0:
|
|
448
|
+
_git("-C", str(repo_dir), "fetch", "--quiet", "--all")
|
|
449
|
+
|
|
450
|
+
checkout = _git("-C", str(repo_dir), "checkout", "--quiet", sha)
|
|
451
|
+
if checkout.returncode != 0:
|
|
452
|
+
raise ResolverError(f"git checkout {sha} failed: {checkout.stderr.strip()}")
|
|
453
|
+
return repo_dir
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""JSON Schema for graph-agents-cli-extension.yaml, generated from the wire models.
|
|
16
|
+
|
|
17
|
+
``uv run python -m graph_agents_cli.extension._schema`` rewrites the checked-in
|
|
18
|
+
copy (``schemas/`` at the repository root); a unit test fails if the two disagree.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel
|
|
28
|
+
|
|
29
|
+
from graph_agents_cli.extension._spec import V1ALPHA1, ExtensionManifest
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _models(model: type[BaseModel], seen: set[type[BaseModel]] | None = None) -> set:
|
|
33
|
+
"""Every model reachable from `model`, so nothing has to be registered by hand."""
|
|
34
|
+
seen = seen if seen is not None else set()
|
|
35
|
+
if model in seen:
|
|
36
|
+
return seen
|
|
37
|
+
seen.add(model)
|
|
38
|
+
for field in model.model_fields.values():
|
|
39
|
+
stack = [field.annotation]
|
|
40
|
+
while stack:
|
|
41
|
+
current = stack.pop()
|
|
42
|
+
if isinstance(current, type) and issubclass(current, BaseModel):
|
|
43
|
+
_models(current, seen)
|
|
44
|
+
stack.extend(getattr(current, "__args__", ()))
|
|
45
|
+
return seen
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SCHEMA_FILE = "graph-agents-cli-extension-v1alpha1.schema.json"
|
|
49
|
+
# Repo-only: generated for editors and the published contract, not read at runtime.
|
|
50
|
+
# <repo>/src/graph_agents_cli/extension/_schema.py -> <repo>/schemas/<file>
|
|
51
|
+
SCHEMA_PATH = Path(__file__).resolve().parents[3] / "schemas" / SCHEMA_FILE
|
|
52
|
+
SCHEMA_ID = f"https://raw.githubusercontent.com/ss7172/graph-agents-cli/main/schemas/{SCHEMA_FILE}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _allow_empty_keys(schema: dict[str, Any]) -> None:
|
|
56
|
+
"""Let any optional property be null, matching how the loader reads YAML.
|
|
57
|
+
|
|
58
|
+
A key written with nothing under it parses as null and the loader treats it
|
|
59
|
+
as absent, so the document has to accept it or an editor flags something
|
|
60
|
+
that installs fine. Driven off the models because pydantic emits no
|
|
61
|
+
`default` for a `default_factory` field.
|
|
62
|
+
"""
|
|
63
|
+
defs = schema.get("$defs", {})
|
|
64
|
+
for model in _models(ExtensionManifest):
|
|
65
|
+
block = schema if model is ExtensionManifest else defs.get(model.__name__)
|
|
66
|
+
if not block:
|
|
67
|
+
continue
|
|
68
|
+
for name, field in model.model_fields.items():
|
|
69
|
+
if field.is_required():
|
|
70
|
+
continue
|
|
71
|
+
prop = block.get("properties", {}).get(field.alias or name)
|
|
72
|
+
if prop is None or any(v.get("type") == "null" for v in prop.get("anyOf", [])):
|
|
73
|
+
continue
|
|
74
|
+
keep = {k: prop.pop(k) for k in list(prop) if k in ("default", "title")}
|
|
75
|
+
inner = prop.pop("anyOf", None) or [dict(prop)]
|
|
76
|
+
prop.clear()
|
|
77
|
+
prop.update(keep)
|
|
78
|
+
prop["anyOf"] = [*inner, {"type": "null"}]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_json_schema() -> dict[str, Any]:
|
|
82
|
+
"""The manifest schema as a JSON Schema 2020-12 document."""
|
|
83
|
+
schema = ExtensionManifest.model_json_schema(by_alias=True)
|
|
84
|
+
_allow_empty_keys(schema)
|
|
85
|
+
# Our header wins over pydantic's class-derived title/description.
|
|
86
|
+
return {
|
|
87
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
88
|
+
"$id": SCHEMA_ID,
|
|
89
|
+
**schema,
|
|
90
|
+
"title": f"graph-agents-cli extension manifest ({V1ALPHA1})",
|
|
91
|
+
"description": (
|
|
92
|
+
"Manifest for an graph-agents-cli extension. Generated from the CLI's own "
|
|
93
|
+
"models - edit those, not this file. Experimental: this format may "
|
|
94
|
+
"still change in a breaking way."
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render() -> str:
|
|
100
|
+
"""The document as it is written to disk (trailing newline included)."""
|
|
101
|
+
return json.dumps(build_json_schema(), indent=2, sort_keys=False) + "\n"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
SCHEMA_PATH.write_text(render(), encoding="utf-8")
|
|
106
|
+
print(f"wrote {SCHEMA_PATH}")
|