platformforge 0.1.0__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.
- platformforge/__init__.py +7 -0
- platformforge/__main__.py +5 -0
- platformforge/_forge_portal.py +913 -0
- platformforge/_graphstudio.py +376 -0
- platformforge/_graphview.py +181 -0
- platformforge/_installkit.py +1706 -0
- platformforge/agents/__init__.py +156 -0
- platformforge/agents/bench.py +61 -0
- platformforge/agents/contextpack.py +137 -0
- platformforge/agents/contracts.py +394 -0
- platformforge/agents/coordinators.py +98 -0
- platformforge/agents/critic.py +113 -0
- platformforge/agents/debate.py +161 -0
- platformforge/agents/executors.py +160 -0
- platformforge/agents/guardian.py +48 -0
- platformforge/agents/mirrors.py +240 -0
- platformforge/agents/orchestrator.py +295 -0
- platformforge/agents/planner.py +116 -0
- platformforge/agents/playbook.py +51 -0
- platformforge/agents/referee.py +132 -0
- platformforge/agents/reviewers.py +113 -0
- platformforge/agents/roster.py +893 -0
- platformforge/agents/runledger.py +85 -0
- platformforge/agents/runstore.py +74 -0
- platformforge/agents/specialists.py +73 -0
- platformforge/agents/taskspec.py +94 -0
- platformforge/agents/uniqueness.py +115 -0
- platformforge/agents/verifier.py +106 -0
- platformforge/aiplat/__init__.py +4 -0
- platformforge/aiplat/models.py +175 -0
- platformforge/analytics/__init__.py +6 -0
- platformforge/analytics/capacity.py +109 -0
- platformforge/analytics/debt.py +92 -0
- platformforge/analytics/dx.py +69 -0
- platformforge/analytics/finops_v4.py +171 -0
- platformforge/analytics/goldenpath.py +131 -0
- platformforge/analytics/history.py +233 -0
- platformforge/analytics/measurement.py +149 -0
- platformforge/analytics/models.py +127 -0
- platformforge/analytics/opsanalytics.py +119 -0
- platformforge/analytics/policyintel.py +139 -0
- platformforge/analytics/reliability.py +106 -0
- platformforge/analytics/soak.py +135 -0
- platformforge/analytics/store.py +254 -0
- platformforge/bench.py +174 -0
- platformforge/cases/__init__.py +13 -0
- platformforge/cases/casefile.py +106 -0
- platformforge/cases/context_audit.py +134 -0
- platformforge/cases/ledgers.py +174 -0
- platformforge/cases/replay.py +142 -0
- platformforge/cases/routing_audit.py +162 -0
- platformforge/caveman/__init__.py +11 -0
- platformforge/caveman/compress.py +156 -0
- platformforge/cicd/__init__.py +16 -0
- platformforge/cicd/backstage.py +51 -0
- platformforge/cicd/dockerfile.py +64 -0
- platformforge/cicd/github_actions.py +122 -0
- platformforge/cicd/gitlab_ci.py +56 -0
- platformforge/cicd/gitops.py +130 -0
- platformforge/cli/__init__.py +0 -0
- platformforge/cli/fleetcmd.py +457 -0
- platformforge/cli/main.py +3832 -0
- platformforge/cloud/__init__.py +8 -0
- platformforge/cloud/aws.py +254 -0
- platformforge/cloud/common.py +69 -0
- platformforge/cloud/multicloud.py +143 -0
- platformforge/collect/__init__.py +11 -0
- platformforge/collect/detect.py +254 -0
- platformforge/context/__init__.py +10 -0
- platformforge/context/capsule.py +60 -0
- platformforge/context/gateway.py +227 -0
- platformforge/context/refs.py +49 -0
- platformforge/context/sufficiency.py +50 -0
- platformforge/core/__init__.py +1 -0
- platformforge/core/hashing.py +39 -0
- platformforge/core/ownership.py +220 -0
- platformforge/core/receipts.py +74 -0
- platformforge/core/redaction.py +97 -0
- platformforge/core/store.py +119 -0
- platformforge/core/workspace.py +89 -0
- platformforge/data/contracts/agent-handoff.schema.json +26 -0
- platformforge/data/contracts/agent-run.schema.json +27 -0
- platformforge/data/contracts/agent-spec.schema.json +43 -0
- platformforge/data/contracts/capability.schema.json +23 -0
- platformforge/data/contracts/change-event.schema.json +22 -0
- platformforge/data/contracts/change-intent.schema.json +62 -0
- platformforge/data/contracts/change-plan.schema.json +43 -0
- platformforge/data/contracts/collector-receipt.schema.json +36 -0
- platformforge/data/contracts/context-pack.schema.json +29 -0
- platformforge/data/contracts/debate.schema.json +31 -0
- platformforge/data/contracts/distribution-manifest-v1.schema.json +81 -0
- platformforge/data/contracts/drift-event.schema.json +21 -0
- platformforge/data/contracts/fact.schema.json +35 -0
- platformforge/data/contracts/finding.schema.json +20 -0
- platformforge/data/contracts/graph-v2.schema.json +67 -0
- platformforge/data/contracts/graph.schema.json +40 -0
- platformforge/data/contracts/install-receipt-v1.schema.json +49 -0
- platformforge/data/contracts/observation.schema.json +93 -0
- platformforge/data/contracts/receipt.schema.json +21 -0
- platformforge/data/contracts/recommendation.schema.json +32 -0
- platformforge/data/contracts/refusal.schema.json +16 -0
- platformforge/data/contracts/slo-contract.schema.json +25 -0
- platformforge/data/contracts/task-spec.schema.json +37 -0
- platformforge/data/contracts/workspace-manifest-v1.schema.json +62 -0
- platformforge/data/evals/cases/agent-budget-exhaustion/case.yaml +6 -0
- platformforge/data/evals/cases/agent-conflicting-specialists/case.yaml +11 -0
- platformforge/data/evals/cases/agent-fleet-optimization/case.yaml +13 -0
- platformforge/data/evals/cases/agent-host-no-subagents/case.yaml +6 -0
- platformforge/data/evals/cases/agent-incident-multi/case.yaml +13 -0
- platformforge/data/evals/cases/agent-missing-evidence/case.yaml +6 -0
- platformforge/data/evals/cases/agent-multi-domain-audit/case.yaml +14 -0
- platformforge/data/evals/cases/agent-production-change/case.yaml +15 -0
- platformforge/data/evals/cases/agent-security-mandatory/case.yaml +12 -0
- platformforge/data/evals/cases/agent-simple-iac/case.yaml +14 -0
- platformforge/data/evals/cases/approval-integrity-seal/case.yaml +6 -0
- platformforge/data/evals/cases/capacity-hot/case.yaml +7 -0
- platformforge/data/evals/cases/capacity-hot/fixture/capacity.json +5 -0
- platformforge/data/evals/cases/catalog-noowner/case.yaml +7 -0
- platformforge/data/evals/cases/catalog-noowner/fixture/catalog-info.yaml +6 -0
- platformforge/data/evals/cases/contract-fact/case.yaml +5 -0
- platformforge/data/evals/cases/contract-fact/fixture/doc.json +2 -0
- platformforge/data/evals/cases/cosign-unverified/case.yaml +8 -0
- platformforge/data/evals/cases/cosign-unverified/fixture/sig.json +33 -0
- platformforge/data/evals/cases/expected-delta-refusal/case.yaml +6 -0
- platformforge/data/evals/cases/finops-noattr/case.yaml +9 -0
- platformforge/data/evals/cases/finops-noattr/fixture/costs.json +7 -0
- platformforge/data/evals/cases/fleet-inv-absent-allocation-unknown/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-centrality-not-criticality/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-correlation-not-causality/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-dx-no-person-metrics/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-federation-restricted-denied/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-federation-secret-denied/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-history-window-deterministic/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-low-util-not-deletable/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-member-key-deterministic/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-missing-denominator/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-partial-coverage-visible/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-plan-changeintent-only/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-report-all-cited/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-small-sample-confidence/case.yaml +7 -0
- platformforge/data/evals/cases/fleet-inv-uncertain-optimization-suppressed/case.yaml +7 -0
- platformforge/data/evals/cases/gha-dangerous/case.yaml +12 -0
- platformforge/data/evals/cases/gha-dangerous/fixture/.github/workflows/ci.yaml +11 -0
- platformforge/data/evals/cases/gitops-v2/case.yaml +11 -0
- platformforge/data/evals/cases/gitops-v2/fixture/argo.yaml +41 -0
- platformforge/data/evals/cases/graph-k8s-blast/case.yaml +7 -0
- platformforge/data/evals/cases/graph-k8s-blast/fixture/deploy.yaml +15 -0
- platformforge/data/evals/cases/iac-module-nopin/case.yaml +8 -0
- platformforge/data/evals/cases/iac-module-nopin/fixture/main.tf +3 -0
- platformforge/data/evals/cases/iac-plan-replace/case.yaml +8 -0
- platformforge/data/evals/cases/iac-plan-replace/fixture/plan.json +12 -0
- platformforge/data/evals/cases/iac-sg-open/case.yaml +8 -0
- platformforge/data/evals/cases/iac-sg-open/fixture/main.tf +9 -0
- platformforge/data/evals/cases/iam-oidc/case.yaml +8 -0
- platformforge/data/evals/cases/iam-oidc/fixture/policy.json +11 -0
- platformforge/data/evals/cases/iam-wildcard-positive/case.yaml +8 -0
- platformforge/data/evals/cases/iam-wildcard-positive/fixture/policy.json +3 -0
- platformforge/data/evals/cases/k8s-bad-actor/case.yaml +19 -0
- platformforge/data/evals/cases/k8s-bad-actor/fixture/app.yaml +97 -0
- platformforge/data/evals/cases/k8s-beta-api/case.yaml +8 -0
- platformforge/data/evals/cases/k8s-beta-api/fixture/cr.yaml +7 -0
- platformforge/data/evals/cases/k8s-dep-api-boundary/case.yaml +15 -0
- platformforge/data/evals/cases/k8s-dep-api-boundary/fixture/app.yaml +25 -0
- platformforge/data/evals/cases/k8s-dep-api-clean/case.yaml +12 -0
- platformforge/data/evals/cases/k8s-dep-api-clean/fixture/app.yaml +29 -0
- platformforge/data/evals/cases/k8s-dep-api-unknown/case.yaml +11 -0
- platformforge/data/evals/cases/k8s-dep-api-unknown/fixture/app.yaml +25 -0
- platformforge/data/evals/cases/k8s-dep-api-v120/case.yaml +12 -0
- platformforge/data/evals/cases/k8s-dep-api-v120/fixture/app.yaml +25 -0
- platformforge/data/evals/cases/k8s-dep-api-v129/case.yaml +9 -0
- platformforge/data/evals/cases/k8s-dep-api-v129/fixture/app.yaml +25 -0
- platformforge/data/evals/cases/k8s-hpa-noreq/case.yaml +8 -0
- platformforge/data/evals/cases/k8s-hpa-noreq/fixture/app.yaml +28 -0
- platformforge/data/evals/cases/k8s-limits-boundary/case.yaml +10 -0
- platformforge/data/evals/cases/k8s-limits-boundary/fixture/app.yaml +15 -0
- platformforge/data/evals/cases/k8s-limits-negative/case.yaml +8 -0
- platformforge/data/evals/cases/k8s-limits-negative/fixture/deploy.yaml +15 -0
- platformforge/data/evals/cases/k8s-limits-positive/case.yaml +8 -0
- platformforge/data/evals/cases/k8s-limits-positive/fixture/deploy.yaml +14 -0
- platformforge/data/evals/cases/k8s-nopdb/case.yaml +7 -0
- platformforge/data/evals/cases/k8s-nopdb/fixture/app.yaml +34 -0
- platformforge/data/evals/cases/knowledge-registry/case.yaml +10 -0
- platformforge/data/evals/cases/kyverno-dep/case.yaml +10 -0
- platformforge/data/evals/cases/kyverno-dep/fixture/pol.yaml +18 -0
- platformforge/data/evals/cases/kyverno-legacy/case.yaml +8 -0
- platformforge/data/evals/cases/kyverno-legacy/fixture/pol.yaml +7 -0
- platformforge/data/evals/cases/live-aws-collect/case.yaml +11 -0
- platformforge/data/evals/cases/live-aws-collect/fixture/aws/ec2_describe-vpcs.json +15 -0
- platformforge/data/evals/cases/live-aws-collect/fixture/aws/iam_list-users.denied.txt +1 -0
- platformforge/data/evals/cases/live-aws-collect/fixture/aws/sts_get-caller-identity.json +5 -0
- platformforge/data/evals/cases/live-drift/case.yaml +8 -0
- platformforge/data/evals/cases/live-drift/fixture/after.json +22 -0
- platformforge/data/evals/cases/live-drift/fixture/before.json +22 -0
- platformforge/data/evals/cases/live-incident/case.yaml +7 -0
- platformforge/data/evals/cases/live-incident/fixture/changes.json +8 -0
- platformforge/data/evals/cases/live-incident/fixture/incident.json +7 -0
- platformforge/data/evals/cases/live-k8s-collect/case.yaml +10 -0
- platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_configmaps.denied.txt +1 -0
- platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_deployments.apps.json +25 -0
- platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_pods.json +17 -0
- platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_secrets.json +13 -0
- platformforge/data/evals/cases/live-multicluster/case.yaml +7 -0
- platformforge/data/evals/cases/live-multicluster/fixture/clusters.json +6 -0
- platformforge/data/evals/cases/live-plan/case.yaml +7 -0
- platformforge/data/evals/cases/live-plan/fixture/drift.json +8 -0
- platformforge/data/evals/cases/live-reconcile/case.yaml +7 -0
- platformforge/data/evals/cases/live-reconcile/fixture/desired.json +7 -0
- platformforge/data/evals/cases/live-reconcile/fixture/observed.json +18 -0
- platformforge/data/evals/cases/live-secrets-boundary/case.yaml +8 -0
- platformforge/data/evals/cases/live-secrets-boundary/fixture/envelope.json +16 -0
- platformforge/data/evals/cases/live-temporal-graph/case.yaml +10 -0
- platformforge/data/evals/cases/live-temporal-graph/fixture/graph.json +22 -0
- platformforge/data/evals/cases/ops-approval/case.yaml +6 -0
- platformforge/data/evals/cases/ops-convergence/case.yaml +6 -0
- platformforge/data/evals/cases/ops-execution-safety/case.yaml +6 -0
- platformforge/data/evals/cases/ops-intent/case.yaml +6 -0
- platformforge/data/evals/cases/ops-planning/case.yaml +6 -0
- platformforge/data/evals/cases/ops-policy/case.yaml +6 -0
- platformforge/data/evals/cases/ops-rollback/case.yaml +6 -0
- platformforge/data/evals/cases/ops-verification/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-argocd-revision/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-blind-inverse/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-git-commit/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-impossible/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-k8s-annotation-restore/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-k8s-scale/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-material-tamper/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-terraform-replan/case.yaml +6 -0
- platformforge/data/evals/cases/rollback-unknown/case.yaml +6 -0
- platformforge/data/evals/cases/routing-specialist/case.yaml +9 -0
- platformforge/data/evals/cases/rtk-roundtrip/case.yaml +5 -0
- platformforge/data/evals/cases/rtk-roundtrip/fixture/verbose.txt +300 -0
- platformforge/data/evals/cases/sbom-vuln/case.yaml +9 -0
- platformforge/data/evals/cases/sbom-vuln/fixture/sbom.json +9 -0
- platformforge/data/evals/cases/secret-prose-mixed/fixture/app.env +1 -0
- platformforge/data/evals/cases/secret-prose-mixed/fixture/policy-notes.md +7 -0
- platformforge/data/evals/cases/secrets-noleak/case.yaml +5 -0
- platformforge/data/evals/cases/secrets-noleak/fixture/app.env +2 -0
- platformforge/data/evals/cases/slo-fastburn/case.yaml +8 -0
- platformforge/data/evals/cases/slo-fastburn/fixture/events.yaml +2 -0
- platformforge/data/evals/cases/slo-fastburn/fixture/slo.yaml +4 -0
- platformforge/data/evals/cases/slo-noinput/case.yaml +8 -0
- platformforge/data/evals/cases/slo-noinput/fixture/events.yaml +1 -0
- platformforge/data/evals/cases/slo-noinput/fixture/slo.yaml +4 -0
- platformforge/data/evals/cases/sot-conflict/case.yaml +6 -0
- platformforge/data/evals/cases/token-budget/case.yaml +6 -0
- platformforge/data/evals/cases/token-budget/fixture/big.py +50 -0
- platformforge/data/evals/cases/xr-delete/case.yaml +8 -0
- platformforge/data/evals/cases/xr-delete/fixture/xr.yaml +10 -0
- platformforge/data/evals/cases/xr-graph-chain/case.yaml +8 -0
- platformforge/data/evals/cases/xr-graph-chain/fixture/xrd.yaml +90 -0
- platformforge/data/evals/cases/xr-negative-crd/case.yaml +8 -0
- platformforge/data/evals/cases/xr-negative-crd/fixture/widget.yaml +19 -0
- platformforge/data/evals/cases/xr-upbound-mr/case.yaml +8 -0
- platformforge/data/evals/cases/xr-upbound-mr/fixture/bucket.yaml +12 -0
- platformforge/data/evals/qpt-bench/change-review/case.yaml +5 -0
- platformforge/data/evals/qpt-bench/change-review/fixture/plan.json +9 -0
- platformforge/data/evals/qpt-bench/gha-security/case.yaml +5 -0
- platformforge/data/evals/qpt-bench/gha-security/fixture/ci.yml +10 -0
- platformforge/data/evals/qpt-bench/iam-investigate/case.yaml +5 -0
- platformforge/data/evals/qpt-bench/iam-investigate/fixture/policy.json +6 -0
- platformforge/data/evals/qpt-bench/incident-analysis/case.yaml +5 -0
- platformforge/data/evals/qpt-bench/incident-analysis/fixture/query.json +5 -0
- platformforge/data/evals/qpt-bench/incident-analysis/fixture/rules.yaml +6 -0
- platformforge/data/evals/qpt-bench/k8s-review/case.yaml +6 -0
- platformforge/data/evals/qpt-bench/k8s-review/fixture/deploy.yaml +15 -0
- platformforge/data/evals/qpt-bench/tf-security/case.yaml +5 -0
- platformforge/data/evals/qpt-bench/tf-security/fixture/main.tf +17 -0
- platformforge/data/knowledge/aws/eks.yaml +20 -0
- platformforge/data/knowledge/aws/iam.yaml +28 -0
- platformforge/data/knowledge/aws/network-exposure.yaml +19 -0
- platformforge/data/knowledge/aws/organizations.yaml +19 -0
- platformforge/data/knowledge/crossplane/composition-providers.yaml +20 -0
- platformforge/data/knowledge/crossplane/managed-resources.yaml +26 -0
- platformforge/data/knowledge/crossplane/v1-v2.yaml +25 -0
- platformforge/data/knowledge/kubernetes/api-deprecations.yaml +33 -0
- platformforge/data/knowledge/kubernetes/autoscaling-rbac.yaml +27 -0
- platformforge/data/knowledge/kubernetes/gateway-api.yaml +20 -0
- platformforge/data/knowledge/kubernetes/pod-security.yaml +26 -0
- platformforge/data/knowledge/sources.yaml +516 -0
- platformforge/data/knowledge/terraform/lifecycle.yaml +18 -0
- platformforge/data/knowledge/terraform/plan-semantics.yaml +26 -0
- platformforge/data/lab/chaos-pod-kill/chaos.yaml +3 -0
- platformforge/data/lab/chaos-pod-kill/facts.json +13 -0
- platformforge/data/lab/fleets/acme/capacity.yaml +35 -0
- platformforge/data/lab/fleets/acme/costs.yaml +25 -0
- platformforge/data/lab/fleets/acme/events.yaml +41 -0
- platformforge/data/lab/fleets/acme/fleet.yaml +51 -0
- platformforge/data/lab/fleets/acme/graph.yaml +45 -0
- platformforge/data/lab/fleets/acme/policies.yaml +28 -0
- platformforge/data/lab/fleets/acme/requests.yaml +24 -0
- platformforge/data/lab/scenarios/argo-bad-rollout/expected.yaml +6 -0
- platformforge/data/lab/scenarios/argo-bad-rollout/fixture/app.yaml +9 -0
- platformforge/data/lab/scenarios/aws-dump/expected.yaml +19 -0
- platformforge/data/lab/scenarios/aws-dump/fixture/compute.json +4 -0
- platformforge/data/lab/scenarios/aws-dump/fixture/iam.json +7 -0
- platformforge/data/lab/scenarios/aws-dump/fixture/network.json +6 -0
- platformforge/data/lab/scenarios/aws-dump/fixture/org.json +4 -0
- platformforge/data/lab/scenarios/aws-dump/fixture/storage.json +4 -0
- platformforge/data/lab/scenarios/container-unsigned/expected.yaml +5 -0
- platformforge/data/lab/scenarios/container-unsigned/fixture/supply.json +11 -0
- platformforge/data/lab/scenarios/cost-idle-workload/expected.yaml +5 -0
- platformforge/data/lab/scenarios/cost-idle-workload/fixture/costs.json +12 -0
- platformforge/data/lab/scenarios/crossplane-platform/expected.yaml +16 -0
- platformforge/data/lab/scenarios/crossplane-platform/fixture/negative.yaml +18 -0
- platformforge/data/lab/scenarios/crossplane-platform/fixture/xrd.yaml +90 -0
- platformforge/data/lab/scenarios/demo-platform/expected.yaml +16 -0
- platformforge/data/lab/scenarios/demo-platform/fixture/.github/workflows/ci.yml +10 -0
- platformforge/data/lab/scenarios/demo-platform/fixture/deploy.yaml +16 -0
- platformforge/data/lab/scenarios/demo-platform/fixture/infra.tf +5 -0
- platformforge/data/lab/scenarios/fleet-ai/expected.yaml +12 -0
- platformforge/data/lab/scenarios/fleet-capacity/expected.yaml +12 -0
- platformforge/data/lab/scenarios/fleet-cost/expected.yaml +18 -0
- platformforge/data/lab/scenarios/fleet-federation/expected.yaml +11 -0
- platformforge/data/lab/scenarios/fleet-golden-path/expected.yaml +18 -0
- platformforge/data/lab/scenarios/fleet-op-hotspot/expected.yaml +11 -0
- platformforge/data/lab/scenarios/fleet-ownership/expected.yaml +17 -0
- platformforge/data/lab/scenarios/fleet-partial/expected.yaml +10 -0
- platformforge/data/lab/scenarios/fleet-policy-friction/expected.yaml +10 -0
- platformforge/data/lab/scenarios/fleet-recurring-incident/expected.yaml +12 -0
- platformforge/data/lab/scenarios/fleet-report/expected.yaml +13 -0
- platformforge/data/lab/scenarios/fleet-risk/expected.yaml +18 -0
- platformforge/data/lab/scenarios/gitops-drift/expected.yaml +6 -0
- platformforge/data/lab/scenarios/gitops-drift/fixture/app.yaml +12 -0
- platformforge/data/lab/scenarios/k8s-bad-readiness/expected.yaml +6 -0
- platformforge/data/lab/scenarios/k8s-bad-readiness/fixture/deploy.yaml +23 -0
- platformforge/data/lab/scenarios/k8s-bad-readiness/fixture/netpol.yaml +9 -0
- platformforge/data/lab/scenarios/k8s-missing-limits/expected.yaml +6 -0
- platformforge/data/lab/scenarios/k8s-missing-limits/fixture/deploy.yaml +26 -0
- platformforge/data/lab/scenarios/k8s-missing-limits/fixture/netpol.yaml +9 -0
- platformforge/data/lab/scenarios/k8s-single-replica/expected.yaml +5 -0
- platformforge/data/lab/scenarios/k8s-single-replica/fixture/deploy.yaml +26 -0
- platformforge/data/lab/scenarios/k8s-single-replica/fixture/netpol.yaml +4 -0
- platformforge/data/lab/scenarios/live-aws-collect/expected.yaml +4 -0
- platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/ec2_describe-vpcs.json +15 -0
- platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/iam_list-users.denied.txt +1 -0
- platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/sts_get-caller-identity.json +5 -0
- platformforge/data/lab/scenarios/live-k8s-collect/expected.yaml +4 -0
- platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_configmaps.denied.txt +1 -0
- platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_deployments.apps.json +25 -0
- platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_pods.json +17 -0
- platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_secrets.json +13 -0
- platformforge/data/lab/scenarios/live-kind/expected.yaml +14 -0
- platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_configmaps.denied.txt +1 -0
- platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_deployments.apps.json +25 -0
- platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_pods.json +17 -0
- platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_secrets.json +13 -0
- platformforge/data/lab/scenarios/network-policy-missing/expected.yaml +6 -0
- platformforge/data/lab/scenarios/network-policy-missing/fixture/deploy.yaml +26 -0
- platformforge/data/lab/scenarios/ops-bad-rollout/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-bad-rollout/fixture/ops.yaml +33 -0
- platformforge/data/lab/scenarios/ops-break-glass/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-break-glass/fixture/ops.yaml +21 -0
- platformforge/data/lab/scenarios/ops-broken-selector/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-broken-selector/fixture/ops.yaml +21 -0
- platformforge/data/lab/scenarios/ops-changed-plan-hash/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-changed-plan-hash/fixture/ops.yaml +20 -0
- platformforge/data/lab/scenarios/ops-conflicting-operation/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-conflicting-operation/fixture/ops.yaml +21 -0
- platformforge/data/lab/scenarios/ops-expired-approval/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-expired-approval/fixture/ops.yaml +20 -0
- platformforge/data/lab/scenarios/ops-partial-failure/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-partial-failure/fixture/ops.yaml +24 -0
- platformforge/data/lab/scenarios/ops-policy-denial/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-policy-denial/fixture/ops.yaml +17 -0
- platformforge/data/lab/scenarios/ops-replica-drift/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-replica-drift/fixture/ops.yaml +28 -0
- platformforge/data/lab/scenarios/ops-rollback-annotate/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-rollback-annotate/fixture/ops.yaml +41 -0
- platformforge/data/lab/scenarios/ops-rollback-gitops/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-rollback-gitops/fixture/ops.yaml +38 -0
- platformforge/data/lab/scenarios/ops-rollback-impossible/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-rollback-impossible/fixture/ops.yaml +34 -0
- platformforge/data/lab/scenarios/ops-rollback-scale/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-rollback-scale/fixture/ops.yaml +40 -0
- platformforge/data/lab/scenarios/ops-rollback-terraform/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-rollback-terraform/fixture/ops.yaml +38 -0
- platformforge/data/lab/scenarios/ops-rollback-terraform/fixture/tf/tfplan.bin +1 -0
- platformforge/data/lab/scenarios/ops-stale-observation/expected.yaml +3 -0
- platformforge/data/lab/scenarios/ops-stale-observation/fixture/ops.yaml +20 -0
- platformforge/data/lab/scenarios/secret-exposure/expected.yaml +5 -0
- platformforge/data/lab/scenarios/secret-exposure/fixture/app.env +2 -0
- platformforge/data/lab/scenarios/slo-burn/expected.yaml +5 -0
- platformforge/data/lab/scenarios/slo-burn/fixture/events.yaml +2 -0
- platformforge/data/lab/scenarios/slo-burn/fixture/slo.yaml +4 -0
- platformforge/data/lab/scenarios/terraform-iam-wildcard/expected.yaml +5 -0
- platformforge/data/lab/scenarios/terraform-iam-wildcard/fixture/policy.json +14 -0
- platformforge/data/lab/scenarios/terraform-public-bucket/expected.yaml +6 -0
- platformforge/data/lab/scenarios/terraform-public-bucket/fixture/main.tf +5 -0
- platformforge/data/portable_skills/platformforge-agents/SKILL.md +47 -0
- platformforge/data/portable_skills/platformforge-change/SKILL.md +41 -0
- platformforge/data/portable_skills/platformforge-core/SKILL.md +62 -0
- platformforge/data/portable_skills/platformforge-economy/SKILL.md +42 -0
- platformforge/data/portable_skills/platformforge-finops/SKILL.md +34 -0
- platformforge/data/portable_skills/platformforge-fleet/SKILL.md +39 -0
- platformforge/data/portable_skills/platformforge-governance/SKILL.md +76 -0
- platformforge/data/portable_skills/platformforge-graph/SKILL.md +46 -0
- platformforge/data/portable_skills/platformforge-lab/SKILL.md +39 -0
- platformforge/data/portable_skills/platformforge-sdd/SKILL.md +39 -0
- platformforge/data/portable_skills/platformforge-security/SKILL.md +38 -0
- platformforge/data/portable_skills/platformforge-sre/SKILL.md +41 -0
- platformforge/data/rules/catalog/finops.yaml +26 -0
- platformforge/data/rules/catalog/gitops.yaml +161 -0
- platformforge/data/rules/catalog/iac.yaml +84 -0
- platformforge/data/rules/catalog/k8s.yaml +322 -0
- platformforge/data/rules/catalog/orchestration.yaml +99 -0
- platformforge/data/rules/catalog/product.yaml +42 -0
- platformforge/data/rules/catalog/routing.yaml +122 -0
- platformforge/data/rules/catalog/security.yaml +159 -0
- platformforge/data/rules/catalog/sre.yaml +67 -0
- platformforge/diagnose/__init__.py +75 -0
- platformforge/distribution/__init__.py +18 -0
- platformforge/distribution/bundle.py +127 -0
- platformforge/distribution/hostflow.py +247 -0
- platformforge/distribution/models.py +80 -0
- platformforge/distribution/service.py +225 -0
- platformforge/economy/__init__.py +27 -0
- platformforge/economy/bench.py +66 -0
- platformforge/economy/budget.py +198 -0
- platformforge/economy/cache.py +287 -0
- platformforge/economy/checkpoint.py +112 -0
- platformforge/economy/collection.py +142 -0
- platformforge/economy/doctor.py +207 -0
- platformforge/economy/engine.py +158 -0
- platformforge/economy/envelope.py +61 -0
- platformforge/economy/fleetpack.py +86 -0
- platformforge/economy/ledger.py +232 -0
- platformforge/economy/plan.py +131 -0
- platformforge/economy/pricing.py +101 -0
- platformforge/economy/qpt.py +286 -0
- platformforge/economy/reconcile.py +86 -0
- platformforge/economy/verifyplan.py +107 -0
- platformforge/economy/waste.py +123 -0
- platformforge/evals/__init__.py +16 -0
- platformforge/evals/coverage.py +140 -0
- platformforge/evals/fleet_invariants.py +260 -0
- platformforge/evals/ops_invariants.py +158 -0
- platformforge/evals/runner.py +541 -0
- platformforge/federation/__init__.py +4 -0
- platformforge/federation/node.py +135 -0
- platformforge/finops/__init__.py +14 -0
- platformforge/finops/alloc.py +31 -0
- platformforge/finops/costs.py +79 -0
- platformforge/finops/focus.py +100 -0
- platformforge/finops/graph_cost.py +51 -0
- platformforge/finops/ingest.py +144 -0
- platformforge/finops/insights.py +193 -0
- platformforge/finops/unit.py +36 -0
- platformforge/fleet/__init__.py +5 -0
- platformforge/fleet/loader.py +73 -0
- platformforge/fleet/models.py +155 -0
- platformforge/fleet/orggraph.py +172 -0
- platformforge/fleet/query.py +154 -0
- platformforge/forge/__init__.py +9 -0
- platformforge/forge/collect.py +88 -0
- platformforge/forge/delegate.py +77 -0
- platformforge/forge/manifest.py +106 -0
- platformforge/freeze/__init__.py +22 -0
- platformforge/freeze/exceptions.py +56 -0
- platformforge/freeze/manifest.py +167 -0
- platformforge/freeze/snapshots.py +108 -0
- platformforge/graph/__init__.py +39 -0
- platformforge/graph/backend.py +174 -0
- platformforge/graph/bench.py +245 -0
- platformforge/graph/build.py +130 -0
- platformforge/graph/delivery.py +183 -0
- platformforge/graph/diff.py +292 -0
- platformforge/graph/events.py +116 -0
- platformforge/graph/migrate.py +44 -0
- platformforge/graph/model.py +264 -0
- platformforge/graph/persist.py +97 -0
- platformforge/graph/query.py +290 -0
- platformforge/graph/temporal.py +78 -0
- platformforge/graph/vocab.py +63 -0
- platformforge/graphview.py +94 -0
- platformforge/iac/__init__.py +7 -0
- platformforge/iac/drift.py +53 -0
- platformforge/iac/plan.py +84 -0
- platformforge/iac/terraform.py +123 -0
- platformforge/install/__init__.py +1 -0
- platformforge/install/service.py +421 -0
- platformforge/k8s/__init__.py +8 -0
- platformforge/k8s/helm.py +152 -0
- platformforge/k8s/hubble.py +87 -0
- platformforge/k8s/manifests.py +513 -0
- platformforge/knowledge/__init__.py +1 -0
- platformforge/knowledge/packs.py +157 -0
- platformforge/knowledge/registry.py +249 -0
- platformforge/knowledge/select.py +127 -0
- platformforge/lab/__init__.py +5 -0
- platformforge/lab/chaos.py +71 -0
- platformforge/lab/fleetlab.py +351 -0
- platformforge/lab/opslab.py +271 -0
- platformforge/lab/runner.py +219 -0
- platformforge/live/__init__.py +36 -0
- platformforge/live/budget.py +92 -0
- platformforge/live/capability.py +80 -0
- platformforge/live/collectors/__init__.py +6 -0
- platformforge/live/collectors/aws.py +370 -0
- platformforge/live/collectors/aws_transport.py +98 -0
- platformforge/live/collectors/k8s.py +389 -0
- platformforge/live/collectors/k8s_transport.py +85 -0
- platformforge/live/cursors.py +48 -0
- platformforge/live/drift.py +130 -0
- platformforge/live/envelope.py +85 -0
- platformforge/live/federation.py +113 -0
- platformforge/live/fixtures.py +168 -0
- platformforge/live/identity.py +258 -0
- platformforge/live/incident.py +200 -0
- platformforge/live/models.py +435 -0
- platformforge/live/reconcile.py +382 -0
- platformforge/live/remediate.py +169 -0
- platformforge/live/store.py +256 -0
- platformforge/live/topology.py +249 -0
- platformforge/mcp/__init__.py +7 -0
- platformforge/mcp/parity.py +69 -0
- platformforge/mcp/registry.py +427 -0
- platformforge/mcp/server.py +84 -0
- platformforge/mcp/tools.py +732 -0
- platformforge/models/__init__.py +21 -0
- platformforge/models/base.py +214 -0
- platformforge/observe/__init__.py +26 -0
- platformforge/observe/capacity.py +39 -0
- platformforge/observe/dr.py +67 -0
- platformforge/observe/grafana.py +76 -0
- platformforge/observe/incident.py +58 -0
- platformforge/observe/otel.py +135 -0
- platformforge/observe/postmortem.py +72 -0
- platformforge/observe/prometheus.py +88 -0
- platformforge/observe/slo.py +125 -0
- platformforge/ops/__init__.py +8 -0
- platformforge/ops/actions.py +215 -0
- platformforge/ops/analytics.py +83 -0
- platformforge/ops/approval.py +275 -0
- platformforge/ops/autorem.py +101 -0
- platformforge/ops/config.py +175 -0
- platformforge/ops/engine.py +758 -0
- platformforge/ops/envelope.py +135 -0
- platformforge/ops/executors/__init__.py +1 -0
- platformforge/ops/executors/base.py +211 -0
- platformforge/ops/executors/git.py +77 -0
- platformforge/ops/executors/gitops.py +134 -0
- platformforge/ops/executors/terraform.py +84 -0
- platformforge/ops/gates.py +135 -0
- platformforge/ops/goldenpath.py +180 -0
- platformforge/ops/material.py +181 -0
- platformforge/ops/models.py +421 -0
- platformforge/ops/operation.py +335 -0
- platformforge/ops/opgraph.py +233 -0
- platformforge/ops/policy.py +275 -0
- platformforge/ops/preconditions.py +109 -0
- platformforge/ops/registry.py +101 -0
- platformforge/ops/risk.py +216 -0
- platformforge/ops/rollback.py +287 -0
- platformforge/ops/rollback_builders.py +163 -0
- platformforge/ops/runbook.py +269 -0
- platformforge/ops/simulate.py +125 -0
- platformforge/ops/source_of_truth.py +198 -0
- platformforge/ops/store.py +157 -0
- platformforge/ops/verify.py +190 -0
- platformforge/optimize/__init__.py +4 -0
- platformforge/optimize/engine.py +109 -0
- platformforge/optimize/models.py +68 -0
- platformforge/optimize/scan.py +196 -0
- platformforge/plan/__init__.py +107 -0
- platformforge/portable_cli.py +199 -0
- platformforge/portal_adapter.py +740 -0
- platformforge/portal_assets/chunk-7_l-b4Tr.js +1 -0
- platformforge/portal_assets/chunk-B0Iad6pD.js +1 -0
- platformforge/portal_assets/chunk-B605XnCF.js +1 -0
- platformforge/portal_assets/chunk-B7N3TXcn.js +1 -0
- platformforge/portal_assets/chunk-BAj6yGem.js +1 -0
- platformforge/portal_assets/chunk-BGUitn2r.js +1 -0
- platformforge/portal_assets/chunk-BVKnCv85.js +1 -0
- platformforge/portal_assets/chunk-BYyM9V_w.js +1 -0
- platformforge/portal_assets/chunk-BaQMhq-G.js +1 -0
- platformforge/portal_assets/chunk-BavOrxVm.js +1 -0
- platformforge/portal_assets/chunk-Bb0C24YV.js +1 -0
- platformforge/portal_assets/chunk-BfCFGPzs.js +1 -0
- platformforge/portal_assets/chunk-C2t4OUVu.js +1 -0
- platformforge/portal_assets/chunk-C3pI4QjU.js +1 -0
- platformforge/portal_assets/chunk-C4gpbtbP.js +1 -0
- platformforge/portal_assets/chunk-CGWeHhKY.js +1 -0
- platformforge/portal_assets/chunk-CJCT86Dr.js +1 -0
- platformforge/portal_assets/chunk-CXcsLDPJ.js +1 -0
- platformforge/portal_assets/chunk-CcjxoLxw.js +1 -0
- platformforge/portal_assets/chunk-D-gBPXHa.js +1 -0
- platformforge/portal_assets/chunk-D3eZogIe.js +1 -0
- platformforge/portal_assets/chunk-DQU0VyoQ.js +1 -0
- platformforge/portal_assets/chunk-DqWNkF1O.js +1 -0
- platformforge/portal_assets/chunk-DuPPkSIE.js +3 -0
- platformforge/portal_assets/chunk-DyIHOW_a.js +1 -0
- platformforge/portal_assets/chunk-Fx4zSGL5.js +1 -0
- platformforge/portal_assets/chunk-UBKt05Rg.js +1 -0
- platformforge/portal_assets/chunk-htnJDQEY.js +1 -0
- platformforge/portal_assets/chunk-kXRFMGqe.js +1 -0
- platformforge/portal_assets/chunk-qe3u763a.js +1 -0
- platformforge/portal_assets/favicon.ico +0 -0
- platformforge/portal_assets/index.html +13 -0
- platformforge/portal_assets/main-QDFVZK3T.js +5 -0
- platformforge/portal_assets/styles-MQEBDFDX.css +1 -0
- platformforge/product/__init__.py +10 -0
- platformforge/product/backstage.py +49 -0
- platformforge/product/catalog.py +74 -0
- platformforge/product/crossplane.py +341 -0
- platformforge/product/golden_paths/__init__.py +5 -0
- platformforge/product/golden_paths/engine.py +97 -0
- platformforge/product/golden_paths/library.yaml +198 -0
- platformforge/product/golden_paths/model.py +84 -0
- platformforge/product/maturity.py +107 -0
- platformforge/product/scorecards.py +84 -0
- platformforge/resources.py +36 -0
- platformforge/risk/__init__.py +5 -0
- platformforge/risk/engine.py +95 -0
- platformforge/risk/signals.py +49 -0
- platformforge/routing/__init__.py +13 -0
- platformforge/routing/decision.py +191 -0
- platformforge/routing/router.py +328 -0
- platformforge/rtk/__init__.py +9 -0
- platformforge/rtk/compact.py +127 -0
- platformforge/rtk/parsers.py +199 -0
- platformforge/rules/__init__.py +5 -0
- platformforge/rules/engine.py +345 -0
- platformforge/sandbox/__init__.py +7 -0
- platformforge/sandbox/env.py +151 -0
- platformforge/sandbox/review.py +92 -0
- platformforge/sdd/__init__.py +8 -0
- platformforge/sdd/lifecycle.py +239 -0
- platformforge/security/__init__.py +19 -0
- platformforge/security/cosign.py +67 -0
- platformforge/security/iam.py +112 -0
- platformforge/security/identity.py +84 -0
- platformforge/security/kyverno.py +106 -0
- platformforge/security/sbom.py +59 -0
- platformforge/security/scan.py +63 -0
- platformforge/security/slsa.py +86 -0
- platformforge/security/supply.py +62 -0
- platformforge/tokensave/__init__.py +20 -0
- platformforge/tokensave/budget.py +62 -0
- platformforge/tokensave/estimate.py +21 -0
- platformforge/tokensave/index.py +172 -0
- platformforge/tokensave/ledger.py +121 -0
- platformforge/tokensave/packs.py +285 -0
- platformforge/ui/__init__.py +28 -0
- platformforge/ui/app.py +465 -0
- platformforge/ui/home.py +113 -0
- platformforge/ui/i18n.py +143 -0
- platformforge/ui/kit.py +689 -0
- platformforge/ui/screen.py +628 -0
- platformforge/ui/tui.py +94 -0
- platformforge/ui/wizard.py +309 -0
- platformforge/workspace/__init__.py +13 -0
- platformforge/workspace/models.py +35 -0
- platformforge/workspace/service.py +69 -0
- platformforge-0.1.0.dist-info/METADATA +212 -0
- platformforge-0.1.0.dist-info/RECORD +659 -0
- platformforge-0.1.0.dist-info/WHEEL +4 -0
- platformforge-0.1.0.dist-info/entry_points.txt +3 -0
- platformforge-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1706 @@
|
|
|
1
|
+
"""Forge Installation Contract v1 — reference engine.
|
|
2
|
+
|
|
3
|
+
Canonical source: ``the-forge/scripts/installkit/forge_installkit.py``.
|
|
4
|
+
Vendored per-Forge (``<pkg>/_installkit.py`` or ``scripts/``); keep the
|
|
5
|
+
``_INSTALLKIT_VERSION``/``_SOURCE_SHA256`` header intact so drift checks can
|
|
6
|
+
compare copies. stdlib-only. Deterministic. Owned-only writes — the sha256
|
|
7
|
+
ledger is the single source of truth for what uninstall may remove.
|
|
8
|
+
|
|
9
|
+
Documents emitted: ``forge/InstallationManifest/v1``,
|
|
10
|
+
``forge/InstallReceipt/v1``, ``forge/InstallationHealth/v1``,
|
|
11
|
+
``forge/WorkspaceInstall/v1`` (see ``the-forge/docs/portable-installation/``).
|
|
12
|
+
|
|
13
|
+
Subprocess use is confined to ``_spawn()`` and gated by ``Spec.spawn_ok`` —
|
|
14
|
+
a Forge whose package forbids subprocess vendors a stripped copy where
|
|
15
|
+
``_spawn`` raises; ``update``/``mcp_verify`` then refuse honestly instead of
|
|
16
|
+
silently degrading.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import platform
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from importlib import metadata as importlib_metadata
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
_INSTALLKIT_VERSION = "1.0.0"
|
|
35
|
+
# Filled by the vendor step (scripts/installkit/vendor.py) — the sha256 of
|
|
36
|
+
# the canonical source body, so drift checks can compare vendored copies.
|
|
37
|
+
_SOURCE_SHA256 = "e2623a522e431c34"
|
|
38
|
+
|
|
39
|
+
SCHEMA_MANIFEST = "forge/InstallationManifest/v1"
|
|
40
|
+
SCHEMA_RECEIPT = "forge/InstallReceipt/v1"
|
|
41
|
+
SCHEMA_HEALTH = "forge/InstallationHealth/v1"
|
|
42
|
+
SCHEMA_WORKSPACE = "forge/WorkspaceInstall/v1"
|
|
43
|
+
|
|
44
|
+
SCOPES = ("project", "workspace", "user")
|
|
45
|
+
PROFILES = ("minimal", "recommended", "full")
|
|
46
|
+
HOSTS = ("claude", "devin", "codex", "copilot")
|
|
47
|
+
CHECK_STATUSES = ("PASS", "FAIL", "BLOCKED", "UNVERIFIED", "NOT_APPLICABLE")
|
|
48
|
+
|
|
49
|
+
FORGE_HOME = ".forge" # under $HOME — the forge-neutral registry
|
|
50
|
+
INSTALLATIONS_DIR = "installations"
|
|
51
|
+
LEDGER_NAME = "install-ledger.json"
|
|
52
|
+
RECEIPTS_DIR = "receipts"
|
|
53
|
+
LOCK_NAME = ".install.lock"
|
|
54
|
+
|
|
55
|
+
# Refusal codes (contract §8) — forge-specific codes may extend these.
|
|
56
|
+
E_SCOPE = "FORGE-INSTALL-SCOPE-UNKNOWN"
|
|
57
|
+
E_HOST = "FORGE-INSTALL-HOST-UNKNOWN"
|
|
58
|
+
E_PROFILE = "FORGE-INSTALL-PROFILE-UNKNOWN"
|
|
59
|
+
E_PERM = "FORGE-INSTALL-PERMISSION-DENIED"
|
|
60
|
+
E_PYTHON = "FORGE-INSTALL-PYTHON-INCOMPATIBLE"
|
|
61
|
+
E_NOTREPO = "FORGE-INSTALL-NOT-A-REPO"
|
|
62
|
+
E_NOTAPPROVED = "FORGE-INSTALL-PLAN-NOT-APPROVED"
|
|
63
|
+
E_LOCKED = "FORGE-INSTALL-LOCKED"
|
|
64
|
+
E_NOTINSTALLED = "FORGE-INSTALL-NOT-INSTALLED"
|
|
65
|
+
E_DRIFT = "FORGE-INSTALL-DRIFT-UNREPAIRABLE"
|
|
66
|
+
E_UNSUPPORTED = "FORGE-INSTALL-UNSUPPORTED"
|
|
67
|
+
E_SPAWN = "FORGE-INSTALL-SPAWN-DISABLED"
|
|
68
|
+
E_VERIFY = "FORGE-INSTALL-VERIFY-FAILED"
|
|
69
|
+
E_WRITE = "FORGE-INSTALL-WRITE-FAILED"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InstallError(Exception):
|
|
73
|
+
"""Structured refusal — carries the contract error kind."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, kind: str, detail: str):
|
|
76
|
+
super().__init__(f"{kind}: {detail}")
|
|
77
|
+
self.kind = kind
|
|
78
|
+
self.detail = detail
|
|
79
|
+
|
|
80
|
+
def document(self, forge_id: str) -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"schema": SCHEMA_RECEIPT,
|
|
83
|
+
"forge_id": forge_id,
|
|
84
|
+
"status": "failed",
|
|
85
|
+
"verification": {"status": "FAIL"},
|
|
86
|
+
"error": {"kind": self.kind, "detail": self.detail},
|
|
87
|
+
"checks": [],
|
|
88
|
+
"managed_files": [],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _utc_now() -> str:
|
|
93
|
+
from datetime import datetime, timezone
|
|
94
|
+
|
|
95
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sha(data: bytes) -> str:
|
|
99
|
+
return hashlib.sha256(data).hexdigest()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _sha_file(path: Path) -> str:
|
|
103
|
+
return _sha(path.read_bytes())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
107
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
fd, tmp = tempfile.mkstemp(prefix=path.name, dir=str(path.parent))
|
|
109
|
+
try:
|
|
110
|
+
with os.fdopen(fd, "wb") as fh:
|
|
111
|
+
fh.write(data)
|
|
112
|
+
os.replace(tmp, path)
|
|
113
|
+
finally:
|
|
114
|
+
if os.path.exists(tmp):
|
|
115
|
+
os.unlink(tmp)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _json_bytes(doc: dict[str, Any]) -> bytes:
|
|
119
|
+
return (json.dumps(doc, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_json(path: Path, default: Any = None) -> Any:
|
|
123
|
+
try:
|
|
124
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
125
|
+
except (OSError, ValueError):
|
|
126
|
+
return default
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _home() -> Path:
|
|
130
|
+
"""The user's home — test isolation via HOME/XDG_CONFIG_HOME is the
|
|
131
|
+
caller's job; this function reads the resolved env once."""
|
|
132
|
+
return Path(os.environ.get("FORGE_HOME_OVERRIDE") or Path.home())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def forge_home() -> Path:
|
|
136
|
+
return _home() / FORGE_HOME
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def installs_root() -> Path:
|
|
140
|
+
return forge_home() / "installs"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def installations_dir() -> Path:
|
|
144
|
+
return forge_home() / INSTALLATIONS_DIR
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _venv_python(venv: Path) -> Path:
|
|
148
|
+
if os.name == "nt":
|
|
149
|
+
return venv / "Scripts" / "python.exe"
|
|
150
|
+
return venv / "bin" / "python"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _venv_bin(venv: Path) -> Path:
|
|
154
|
+
return venv / ("Scripts" if os.name == "nt" else "bin")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _shim_dir() -> Path:
|
|
158
|
+
# ~/.local/bin on every platform — the pipx/uv convention; on Windows
|
|
159
|
+
# both <cli>.cmd (cmd/PowerShell) and <cli> (Git Bash) are written.
|
|
160
|
+
return _home() / ".local" / "bin"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --------------------------------------------------------------------------
|
|
164
|
+
# Spec — what a Forge declares about itself
|
|
165
|
+
# --------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class ForgeSpec:
|
|
170
|
+
"""What the engine needs to know about a Forge. Everything host- or
|
|
171
|
+
domain-specific enters through ``render_assets``/hooks — the engine is
|
|
172
|
+
generic."""
|
|
173
|
+
|
|
174
|
+
forge_id: str # e.g. "spark-forge-aws" (repo name)
|
|
175
|
+
package: str # import name
|
|
176
|
+
distribution: str # pip dist name
|
|
177
|
+
cli_name: str # console script
|
|
178
|
+
python_spec: str # e.g. ">=3.10" or ">=3.12,<3.13"
|
|
179
|
+
state_dir: str # project state dir, e.g. ".sparkforge-aws"
|
|
180
|
+
mcp_command: tuple[str, ...] = () # e.g. ("sparkforge-aws","mcp","serve")
|
|
181
|
+
mcp_server_name: str | None = None # .mcp.json key
|
|
182
|
+
mcp_verify_tool: str | None = None # no-arg read-only tool for tools/call
|
|
183
|
+
version_cmd: tuple[str, ...] = ("--version",)
|
|
184
|
+
render_assets: Callable[[InstallContext], dict[str, bytes]] | None = None
|
|
185
|
+
marker_files: tuple[str, ...] = ("AGENTS.md", "CLAUDE.md")
|
|
186
|
+
marker_tag: str | None = None # defaults to forge_id
|
|
187
|
+
marker_body: str = ""
|
|
188
|
+
user_state_dir: str | None = None # e.g. "~/.sparkforge-aws"
|
|
189
|
+
spawn_ok: bool = True # False where subprocess is banned
|
|
190
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def tag(self) -> str:
|
|
194
|
+
return self.marker_tag or self.forge_id
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class InstallContext:
|
|
199
|
+
spec: ForgeSpec
|
|
200
|
+
scope: str
|
|
201
|
+
root: Path # resolved target root (project/workspace) or $HOME
|
|
202
|
+
state_dir: Path # <root>/<spec.state_dir> for project/workspace,
|
|
203
|
+
# <state_dir under HOME> for user
|
|
204
|
+
profile: str
|
|
205
|
+
hosts: tuple[str, ...]
|
|
206
|
+
dry_run: bool = False
|
|
207
|
+
ledger: Ledger | None = None
|
|
208
|
+
# Optional-component selection (wizard/CLI). ``asset_kinds`` narrows the
|
|
209
|
+
# profile's kinds; other booleans (e.g. ``graph_studio``) are runtime
|
|
210
|
+
# flags persisted to ``components.json`` under state_dir.
|
|
211
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------
|
|
215
|
+
# Ledger — sha256-keyed ownership of managed files
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Ledger:
|
|
220
|
+
"""Records every managed write under a target root.
|
|
221
|
+
|
|
222
|
+
``entries[path] = {sha256, kind, action, managed}`` — only ``managed``
|
|
223
|
+
entries are removable by uninstall; ``adopted`` entries are tracked for
|
|
224
|
+
drift but never deleted."""
|
|
225
|
+
|
|
226
|
+
def __init__(self, path: Path):
|
|
227
|
+
self.path = path
|
|
228
|
+
self.doc: dict[str, Any] = _load_json(path, None) or {
|
|
229
|
+
"schema": "forge/InstallLedger/v1",
|
|
230
|
+
"entries": {},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def load(cls, state_dir: Path) -> Ledger:
|
|
235
|
+
return cls(state_dir / LEDGER_NAME)
|
|
236
|
+
|
|
237
|
+
def save(self) -> None:
|
|
238
|
+
_atomic_write(self.path, _json_bytes(self.doc))
|
|
239
|
+
|
|
240
|
+
def record(
|
|
241
|
+
self, rel: str, data: bytes, kind: str, action: str, *, managed: bool = True
|
|
242
|
+
) -> dict[str, Any]:
|
|
243
|
+
entry = {
|
|
244
|
+
"sha256": _sha(data),
|
|
245
|
+
"kind": kind,
|
|
246
|
+
"action": action,
|
|
247
|
+
"managed": managed,
|
|
248
|
+
"updated_at": _utc_now(),
|
|
249
|
+
}
|
|
250
|
+
self.doc["entries"][rel] = entry
|
|
251
|
+
return {"path": rel, "sha256": entry["sha256"], "kind": kind, "action": action}
|
|
252
|
+
|
|
253
|
+
def drop(self, rel: str) -> None:
|
|
254
|
+
self.doc["entries"].pop(rel, None)
|
|
255
|
+
|
|
256
|
+
def get(self, rel: str) -> dict[str, Any] | None:
|
|
257
|
+
entry = self.doc["entries"].get(rel)
|
|
258
|
+
return entry if isinstance(entry, dict) else None
|
|
259
|
+
|
|
260
|
+
def entries(self) -> dict[str, Any]:
|
|
261
|
+
return dict(self.doc["entries"])
|
|
262
|
+
|
|
263
|
+
def owned_paths(self) -> list[str]:
|
|
264
|
+
return sorted(p for p, e in self.doc["entries"].items() if e.get("managed"))
|
|
265
|
+
|
|
266
|
+
def drift(self, root: Path, spec: "ForgeSpec | None" = None) -> list[dict[str, str]]:
|
|
267
|
+
"""Per-file verdict. When ``spec`` is given the check is kind-aware:
|
|
268
|
+
``marker`` entries pass when their managed *block* is intact (user
|
|
269
|
+
text outside the markers is never drift); ``mcp`` entries pass when
|
|
270
|
+
the managed ``mcpServers`` key holds canonical command/args (other
|
|
271
|
+
keys are user content); every other entry is compared by sha256."""
|
|
272
|
+
out = []
|
|
273
|
+
for rel, e in sorted(self.doc["entries"].items()):
|
|
274
|
+
p = root / rel
|
|
275
|
+
if not p.exists():
|
|
276
|
+
out.append({"path": rel, "status": "missing"})
|
|
277
|
+
continue
|
|
278
|
+
if spec is not None and _entry_intact(spec, e, rel, p):
|
|
279
|
+
out.append({"path": rel, "status": "ok"})
|
|
280
|
+
else:
|
|
281
|
+
out.append({"path": rel, "status": "modified"})
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _entry_intact(spec: "ForgeSpec", entry: dict[str, Any], rel: str, path: Path) -> bool:
|
|
286
|
+
"""Kind-aware health check for one ledger entry. Marker entries verify
|
|
287
|
+
the managed block only; ``.mcp.json`` verifies the managed server key;
|
|
288
|
+
everything else compares whole-file sha256."""
|
|
289
|
+
kind = entry.get("kind")
|
|
290
|
+
if kind == "marker" and rel in spec.marker_files and spec.marker_body:
|
|
291
|
+
txt = path.read_text(encoding="utf-8")
|
|
292
|
+
return apply_marker_block(txt, spec.tag, spec.marker_body) == txt
|
|
293
|
+
if kind == "mcp" and spec.mcp_server_name:
|
|
294
|
+
cur = _load_json(path, None)
|
|
295
|
+
if not isinstance(cur, dict):
|
|
296
|
+
return False
|
|
297
|
+
want = {"command": spec.mcp_command[0], "args": list(spec.mcp_command[1:])}
|
|
298
|
+
return bool(mcp_set(cur, spec.mcp_server_name, want, spec.tag) == cur)
|
|
299
|
+
return bool(_sha_file(path) == entry["sha256"])
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# --------------------------------------------------------------------------
|
|
303
|
+
# Locks — refuse concurrent mutation rather than racing
|
|
304
|
+
# --------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class LockError(InstallError):
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _pid_alive(pid: int) -> bool | None:
|
|
312
|
+
"""Best-effort liveness; ``None`` = cannot determine (do not steal)."""
|
|
313
|
+
if pid <= 0:
|
|
314
|
+
return False
|
|
315
|
+
if os.name == "nt":
|
|
316
|
+
try:
|
|
317
|
+
import ctypes # noqa: PLC0415
|
|
318
|
+
|
|
319
|
+
h = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
|
|
320
|
+
if h:
|
|
321
|
+
ctypes.windll.kernel32.CloseHandle(h)
|
|
322
|
+
return True
|
|
323
|
+
return False
|
|
324
|
+
except Exception:
|
|
325
|
+
return None
|
|
326
|
+
try:
|
|
327
|
+
os.kill(pid, 0)
|
|
328
|
+
return True
|
|
329
|
+
except ProcessLookupError:
|
|
330
|
+
return False
|
|
331
|
+
except (PermissionError, OSError):
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class _Lock:
|
|
336
|
+
"""O_EXCL lockfile carrying ``{pid, created_at}``. Stale locks — dead
|
|
337
|
+
holder or older than ``stale_after_s`` — are reclaimed once so an
|
|
338
|
+
interrupted install never bricks the target."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, lock: Path, stale_after_s: float = 900.0) -> None:
|
|
341
|
+
self._lock = lock
|
|
342
|
+
self._stale = stale_after_s
|
|
343
|
+
self.held = False
|
|
344
|
+
self.recovered = False
|
|
345
|
+
|
|
346
|
+
def __enter__(self) -> _Lock:
|
|
347
|
+
try:
|
|
348
|
+
self._create()
|
|
349
|
+
return self
|
|
350
|
+
except FileExistsError:
|
|
351
|
+
holder = _load_json(self._lock) or {"pid": None}
|
|
352
|
+
stale = self._is_stale(holder)
|
|
353
|
+
if not stale:
|
|
354
|
+
raise LockError(E_LOCKED, f"install lock held at {self._lock} ({holder})") from None
|
|
355
|
+
try:
|
|
356
|
+
self._lock.unlink()
|
|
357
|
+
except OSError as exc:
|
|
358
|
+
raise LockError(
|
|
359
|
+
E_LOCKED, f"install lock at {self._lock} could not be reclaimed"
|
|
360
|
+
) from exc
|
|
361
|
+
self.recovered = True
|
|
362
|
+
self._create()
|
|
363
|
+
return self
|
|
364
|
+
|
|
365
|
+
def _create(self) -> None:
|
|
366
|
+
fd = os.open(str(self._lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
367
|
+
os.write(
|
|
368
|
+
fd,
|
|
369
|
+
_json_bytes({"pid": os.getpid(), "created_at": _utc_now()}),
|
|
370
|
+
)
|
|
371
|
+
os.close(fd)
|
|
372
|
+
self.held = True
|
|
373
|
+
|
|
374
|
+
def _is_stale(self, holder: dict[str, Any]) -> bool:
|
|
375
|
+
pid = holder.get("pid")
|
|
376
|
+
if isinstance(pid, int) and pid != os.getpid():
|
|
377
|
+
alive = _pid_alive(pid)
|
|
378
|
+
if alive is False:
|
|
379
|
+
return True
|
|
380
|
+
if alive is True:
|
|
381
|
+
return False
|
|
382
|
+
try:
|
|
383
|
+
age = time.time() - self._lock.stat().st_mtime
|
|
384
|
+
except OSError:
|
|
385
|
+
return False
|
|
386
|
+
return age > self._stale
|
|
387
|
+
|
|
388
|
+
def __exit__(self, *exc: object) -> None:
|
|
389
|
+
if self.held:
|
|
390
|
+
try:
|
|
391
|
+
self._lock.unlink()
|
|
392
|
+
except OSError:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def acquire_lock(state_dir: Path, timeout_s: float = 0.0) -> _Lock:
|
|
397
|
+
"""O_EXCL lockfile. Non-blocking by default — contention is a refusal,
|
|
398
|
+
not a hang. Dead/expired locks are reclaimed once."""
|
|
399
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
return _Lock(state_dir / LOCK_NAME)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# --------------------------------------------------------------------------
|
|
404
|
+
# Marker blocks — delimited managed regions inside user-owned files
|
|
405
|
+
# --------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _markers(tag: str) -> tuple[str, str]:
|
|
409
|
+
return (f"<!-- {tag}:managed:begin -->", f"<!-- {tag}:managed:end -->")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def apply_marker_block(text: str, tag: str, body: str) -> str:
|
|
413
|
+
"""Replace-or-append the managed block; user content outside markers is
|
|
414
|
+
never touched."""
|
|
415
|
+
begin, end = _markers(tag)
|
|
416
|
+
block = f"{begin}\n{body.rstrip()}\n{end}"
|
|
417
|
+
if begin in text and end in text:
|
|
418
|
+
pre = text[: text.index(begin)]
|
|
419
|
+
post = text[text.index(end) + len(end) :]
|
|
420
|
+
return pre + block + post
|
|
421
|
+
sep = "" if text.endswith("\n\n") or not text else "\n\n" if text.endswith("\n") else "\n\n"
|
|
422
|
+
return text + sep + block + "\n" if text else block + "\n"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def remove_marker_block(text: str, tag: str) -> str:
|
|
426
|
+
begin, end = _markers(tag)
|
|
427
|
+
if begin in text and end in text:
|
|
428
|
+
pre = text[: text.index(begin)]
|
|
429
|
+
post = text[text.index(end) + len(end) :]
|
|
430
|
+
return (pre.rstrip("\n") + "\n" + post.lstrip("\n")).strip("\n") + "\n"
|
|
431
|
+
return text
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def marker_sha(tag: str, body: str) -> str:
|
|
435
|
+
begin, end = _markers(tag)
|
|
436
|
+
return _sha(f"{begin}\n{body.rstrip()}\n{end}".encode())
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# --------------------------------------------------------------------------
|
|
440
|
+
# .mcp.json — managed key inside a shared config file
|
|
441
|
+
# --------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def mcp_set(
|
|
445
|
+
doc: dict[str, Any] | None, key: str, value: dict[str, Any], tag: str
|
|
446
|
+
) -> dict[str, Any]:
|
|
447
|
+
"""Set ``mcpServers.<key>`` inside a managed sub-block. Other servers and
|
|
448
|
+
top-level keys are preserved byte-for-byte as JSON."""
|
|
449
|
+
doc = dict(doc or {})
|
|
450
|
+
servers = dict(doc.get("mcpServers") or {})
|
|
451
|
+
servers[key] = value
|
|
452
|
+
doc["mcpServers"] = servers
|
|
453
|
+
managed = dict(doc.get("_forge_managed") or {})
|
|
454
|
+
managed[tag] = sorted(set(managed.get(tag, [])) | {f"mcpServers.{key}"})
|
|
455
|
+
doc["_forge_managed"] = managed
|
|
456
|
+
return doc
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def mcp_unset(doc: dict[str, Any], key: str, tag: str) -> dict[str, Any]:
|
|
460
|
+
doc = dict(doc or {})
|
|
461
|
+
servers = dict(doc.get("mcpServers") or {})
|
|
462
|
+
servers.pop(key, None)
|
|
463
|
+
if servers:
|
|
464
|
+
doc["mcpServers"] = servers
|
|
465
|
+
else:
|
|
466
|
+
doc.pop("mcpServers", None)
|
|
467
|
+
managed = dict(doc.get("_forge_managed") or {})
|
|
468
|
+
managed.pop(tag, None)
|
|
469
|
+
if managed:
|
|
470
|
+
doc["_forge_managed"] = managed
|
|
471
|
+
else:
|
|
472
|
+
doc.pop("_forge_managed", None)
|
|
473
|
+
return doc
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# --------------------------------------------------------------------------
|
|
477
|
+
# Scope resolution
|
|
478
|
+
# --------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _find_vcs_root(start: Path) -> Path | None:
|
|
482
|
+
cur = start.resolve()
|
|
483
|
+
for _ in range(64):
|
|
484
|
+
if (cur / ".git").exists():
|
|
485
|
+
return cur
|
|
486
|
+
if cur.parent == cur:
|
|
487
|
+
return None
|
|
488
|
+
cur = cur.parent
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _find_workspace_root(start: Path) -> Path | None:
|
|
493
|
+
cur = start.resolve()
|
|
494
|
+
for _ in range(64):
|
|
495
|
+
for name in (
|
|
496
|
+
"forge.workspace.yaml",
|
|
497
|
+
"forge.workspace.json",
|
|
498
|
+
".forge-workspace",
|
|
499
|
+
"pyproject.toml",
|
|
500
|
+
):
|
|
501
|
+
if (cur / name).exists():
|
|
502
|
+
if name == "pyproject.toml" and not _is_workspace_pyproject(cur):
|
|
503
|
+
cur = cur.parent
|
|
504
|
+
continue
|
|
505
|
+
return cur
|
|
506
|
+
if cur.parent == cur:
|
|
507
|
+
return None
|
|
508
|
+
cur = cur.parent
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _is_workspace_pyproject(root: Path) -> bool:
|
|
513
|
+
try:
|
|
514
|
+
txt = (root / "pyproject.toml").read_text(encoding="utf-8")
|
|
515
|
+
return "tool.forge.workspace" in txt or "tool.uv.workspace" in txt
|
|
516
|
+
except OSError:
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _find_state_root(spec: ForgeSpec, cwd: Path) -> Path | None:
|
|
521
|
+
"""Nearest ancestor (or cwd) already carrying `spec.state_dir` — the dir
|
|
522
|
+
is an installed project even without a VCS checkout."""
|
|
523
|
+
cur = Path(cwd).resolve()
|
|
524
|
+
while True:
|
|
525
|
+
if (cur / spec.state_dir).is_dir():
|
|
526
|
+
return cur
|
|
527
|
+
if cur.parent == cur:
|
|
528
|
+
return None
|
|
529
|
+
cur = cur.parent
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def resolve_scope(spec: ForgeSpec, scope: str, cwd: Path, explicit: Path | None) -> Path:
|
|
533
|
+
if scope not in SCOPES:
|
|
534
|
+
raise InstallError(E_SCOPE, f"scope {scope!r} — expected {SCOPES}")
|
|
535
|
+
if scope == "user":
|
|
536
|
+
return _home()
|
|
537
|
+
root = explicit or _find_vcs_root(cwd)
|
|
538
|
+
if scope == "workspace":
|
|
539
|
+
ws = _find_workspace_root(explicit or cwd)
|
|
540
|
+
return ws or (explicit or _find_vcs_root(cwd) or cwd)
|
|
541
|
+
if root is None:
|
|
542
|
+
if explicit:
|
|
543
|
+
return explicit
|
|
544
|
+
state_root = _find_state_root(spec, cwd)
|
|
545
|
+
if state_root is not None:
|
|
546
|
+
return state_root
|
|
547
|
+
raise InstallError(E_NOTREPO, f"{cwd}: no VCS root — pass --root or cd into a repo")
|
|
548
|
+
return root
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def state_dir_for(spec: ForgeSpec, scope: str, root: Path) -> Path:
|
|
552
|
+
if scope == "user":
|
|
553
|
+
base = spec.user_state_dir or f"~/.{spec.forge_id.replace('-', '_')}"
|
|
554
|
+
return Path(os.path.expandvars(os.path.expanduser(base)))
|
|
555
|
+
return root / spec.state_dir
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# --------------------------------------------------------------------------
|
|
559
|
+
# The engine
|
|
560
|
+
# --------------------------------------------------------------------------
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _host_dirs(host: str, scope: str) -> list[str]:
|
|
564
|
+
"""Project/user roots a host reads its assets from."""
|
|
565
|
+
if scope == "user":
|
|
566
|
+
return {
|
|
567
|
+
"claude": ["~/.claude/skills", "~/.claude/agents"],
|
|
568
|
+
"devin": ["~/.devin/skills", "~/.agents/skills", "~/.agents/agents"],
|
|
569
|
+
"codex": ["~/.codex", "~/.agents/skills", "~/.agents/agents"],
|
|
570
|
+
"copilot": ["~/.agents/skills", "~/.agents/agents"],
|
|
571
|
+
}.get(host, [])
|
|
572
|
+
return {
|
|
573
|
+
"claude": [".claude/skills", ".claude/agents"],
|
|
574
|
+
"devin": [".devin/skills", ".agents/skills", ".agents/agents"],
|
|
575
|
+
"codex": [".agents/skills", ".agents/agents", ".codex"],
|
|
576
|
+
"copilot": [".agents/skills", ".agents/agents", ".github/skills"],
|
|
577
|
+
}.get(host, [])
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def profile_asset_kinds(profile: str) -> tuple[str, ...]:
|
|
581
|
+
return {
|
|
582
|
+
"minimal": ("config", "mcp", "marker", "state"),
|
|
583
|
+
"recommended": ("config", "mcp", "marker", "state", "skill", "agent"),
|
|
584
|
+
"full": ("config", "mcp", "marker", "state", "skill", "agent"),
|
|
585
|
+
}[profile]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def asset_kinds_for(ctx: InstallContext) -> tuple[str, ...]:
|
|
589
|
+
"""Profile kinds narrowed by the component selection, when present."""
|
|
590
|
+
selected = ctx.options.get("asset_kinds")
|
|
591
|
+
if selected is None:
|
|
592
|
+
return profile_asset_kinds(ctx.profile)
|
|
593
|
+
return tuple(selected)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def components_path(state_dir: Path) -> Path:
|
|
597
|
+
"""Where the optional-component selection is persisted."""
|
|
598
|
+
return state_dir / "components.json"
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# Optional components selectable beyond the profile. Public names map to
|
|
602
|
+
# asset kinds; "graph-studio"/"tui" are runtime flags persisted to
|
|
603
|
+
# components.json. Core kinds are always installed.
|
|
604
|
+
COMPONENT_KINDS = {"skills": "skill", "agents": "agent", "mcp": "mcp"}
|
|
605
|
+
CORE_KINDS = ("config", "marker", "state")
|
|
606
|
+
OPTIONAL_COMPONENTS = ("skills", "agents", "mcp", "tui", "graph-studio")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def component_options(
|
|
610
|
+
profile: str, components: tuple[str, ...] | list[str] | None
|
|
611
|
+
) -> dict[str, Any]:
|
|
612
|
+
"""Component list → InstallContext.options. None = profile default."""
|
|
613
|
+
if components is None:
|
|
614
|
+
return {}
|
|
615
|
+
selected = set(components)
|
|
616
|
+
unknown = selected - set(OPTIONAL_COMPONENTS)
|
|
617
|
+
if unknown:
|
|
618
|
+
raise InstallError(
|
|
619
|
+
E_PROFILE,
|
|
620
|
+
f"unknown components {sorted(unknown)}; known: {list(OPTIONAL_COMPONENTS)}",
|
|
621
|
+
)
|
|
622
|
+
kinds = list(CORE_KINDS)
|
|
623
|
+
for comp, kind in COMPONENT_KINDS.items():
|
|
624
|
+
if comp in selected and kind not in kinds:
|
|
625
|
+
kinds.append(kind)
|
|
626
|
+
return {
|
|
627
|
+
"asset_kinds": kinds,
|
|
628
|
+
"graph_studio": "graph-studio" in selected,
|
|
629
|
+
"tui": "tui" in selected,
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def load_components(state_dir: Path) -> dict[str, Any]:
|
|
634
|
+
"""{} when never recorded — absence means default (all enabled)."""
|
|
635
|
+
return _load_json(components_path(state_dir), {})
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def plan(ctx: InstallContext) -> dict[str, Any]:
|
|
639
|
+
"""Pure plan — what *would* change. Never writes."""
|
|
640
|
+
assets = ctx.spec.render_assets(ctx) if ctx.spec.render_assets else {}
|
|
641
|
+
kinds = set(asset_kinds_for(ctx))
|
|
642
|
+
files = []
|
|
643
|
+
for rel in sorted(assets):
|
|
644
|
+
kind = _asset_kind(rel)
|
|
645
|
+
if kind not in kinds:
|
|
646
|
+
continue
|
|
647
|
+
target = ctx.root / rel
|
|
648
|
+
exists = target.exists()
|
|
649
|
+
files.append(
|
|
650
|
+
{
|
|
651
|
+
"path": rel,
|
|
652
|
+
"kind": kind,
|
|
653
|
+
"action": "update" if exists else "create",
|
|
654
|
+
"sha256": _sha(assets[rel]),
|
|
655
|
+
}
|
|
656
|
+
)
|
|
657
|
+
markers = [
|
|
658
|
+
{"file": m, "marker": ctx.spec.tag}
|
|
659
|
+
for m in ctx.spec.marker_files
|
|
660
|
+
if (ctx.root / m).exists() or m == "AGENTS.md"
|
|
661
|
+
]
|
|
662
|
+
return {
|
|
663
|
+
"schema": SCHEMA_RECEIPT,
|
|
664
|
+
"forge_id": ctx.spec.forge_id,
|
|
665
|
+
"operation": "install",
|
|
666
|
+
"scope": ctx.scope,
|
|
667
|
+
"profile": ctx.profile,
|
|
668
|
+
"components": dict(ctx.options) or None,
|
|
669
|
+
"target_root": str(ctx.root),
|
|
670
|
+
"hosts": list(ctx.hosts),
|
|
671
|
+
"dry_run": ctx.dry_run,
|
|
672
|
+
"status": "planned",
|
|
673
|
+
"planned_files": files,
|
|
674
|
+
"planned_markers": markers,
|
|
675
|
+
"managed_files": [],
|
|
676
|
+
"checks": [],
|
|
677
|
+
"verification": {"status": "UNVERIFIED"},
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _asset_kind(rel: str) -> str:
|
|
682
|
+
if "/skills/" in f"/{rel}" or rel.endswith("SKILL.md"):
|
|
683
|
+
return "skill"
|
|
684
|
+
if "/agents/" in f"/{rel}" or "agent" in Path(rel).name.lower():
|
|
685
|
+
return "agent"
|
|
686
|
+
if Path(rel).name == ".mcp.json" or "mcp" in rel:
|
|
687
|
+
return "mcp"
|
|
688
|
+
if rel.startswith((".", "")) and Path(rel).suffix in (".json", ".yaml", ".yml", ".toml"):
|
|
689
|
+
return "config"
|
|
690
|
+
return "state"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _rollback_writes(ctx: InstallContext, created: list[Path], backups: dict[Path, Path]) -> None:
|
|
694
|
+
"""§14.3.7 — revert managed writes from a failed apply: restore pre-state
|
|
695
|
+
backups, remove files created by the operation. User-owned files were
|
|
696
|
+
never touched (conflicts refuse the plan before this point), so rollback
|
|
697
|
+
can only undo our own writes."""
|
|
698
|
+
for target, bak in backups.items():
|
|
699
|
+
try:
|
|
700
|
+
_atomic_write(target, bak.read_bytes())
|
|
701
|
+
except OSError:
|
|
702
|
+
pass
|
|
703
|
+
removed: list[str] = []
|
|
704
|
+
for target in created:
|
|
705
|
+
try:
|
|
706
|
+
target.unlink()
|
|
707
|
+
removed.append(str(target.relative_to(ctx.root)))
|
|
708
|
+
except (OSError, ValueError):
|
|
709
|
+
pass
|
|
710
|
+
_prune_empty_dirs(ctx.root, removed)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def apply_install(ctx: InstallContext, *, approved: bool = False) -> dict[str, Any]:
|
|
714
|
+
"""The governed write path. Idempotent: unchanged assets are not
|
|
715
|
+
re-touched; pre-existing identical files are adopted, never owned."""
|
|
716
|
+
if not approved and not ctx.dry_run:
|
|
717
|
+
raise InstallError(
|
|
718
|
+
E_NOTAPPROVED, "install requires --yes/--approve or --dry-run; the plan is the contract"
|
|
719
|
+
)
|
|
720
|
+
p = plan(ctx)
|
|
721
|
+
if ctx.dry_run:
|
|
722
|
+
p["status"] = "planned"
|
|
723
|
+
return p
|
|
724
|
+
|
|
725
|
+
files: list[dict[str, Any]] = []
|
|
726
|
+
markers: list[dict[str, Any]] = []
|
|
727
|
+
checks: list[dict[str, Any]] = []
|
|
728
|
+
ledger = ctx.ledger or Ledger.load(ctx.state_dir)
|
|
729
|
+
ledger_before = ledger.path.read_bytes() if ledger.path.exists() else None
|
|
730
|
+
component_file = components_path(ctx.state_dir) if ctx.options else None
|
|
731
|
+
component_before = (
|
|
732
|
+
component_file.read_bytes()
|
|
733
|
+
if component_file is not None and component_file.exists()
|
|
734
|
+
else None
|
|
735
|
+
)
|
|
736
|
+
# §14.3 journal — every write is either a creation (removable) or an
|
|
737
|
+
# overwrite with a pre-state snapshot; on failure the managed half is
|
|
738
|
+
# reverted before the error propagates.
|
|
739
|
+
created: list[Path] = []
|
|
740
|
+
backups: dict[Path, Path] = {}
|
|
741
|
+
|
|
742
|
+
def _snapshot(target: Path, rel: str) -> None:
|
|
743
|
+
bak = ctx.state_dir / "backups" / rel
|
|
744
|
+
bak.parent.mkdir(parents=True, exist_ok=True)
|
|
745
|
+
_atomic_write(bak, target.read_bytes())
|
|
746
|
+
backups[target] = bak
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
assets = ctx.spec.render_assets(ctx) if ctx.spec.render_assets else {}
|
|
750
|
+
kinds = set(asset_kinds_for(ctx))
|
|
751
|
+
ctx_bytes = {"skill": 0, "agent": 0, "managed": 0}
|
|
752
|
+
for rel in sorted(assets):
|
|
753
|
+
kind = _asset_kind(rel)
|
|
754
|
+
if kind not in kinds:
|
|
755
|
+
continue
|
|
756
|
+
data = assets[rel]
|
|
757
|
+
if kind in ("skill", "agent"):
|
|
758
|
+
ctx_bytes[kind] += len(data)
|
|
759
|
+
ctx_bytes["managed"] += len(data)
|
|
760
|
+
target = ctx.root / rel
|
|
761
|
+
existing_entry = ledger.get(rel)
|
|
762
|
+
if target.exists():
|
|
763
|
+
cur = target.read_bytes()
|
|
764
|
+
if cur == data:
|
|
765
|
+
action = "unchanged" if existing_entry else "adopted"
|
|
766
|
+
files.append(
|
|
767
|
+
ledger.record(rel, data, kind, action, managed=bool(existing_entry))
|
|
768
|
+
)
|
|
769
|
+
continue
|
|
770
|
+
if existing_entry is None:
|
|
771
|
+
# Pre-existing foreign file — never overwrite silently.
|
|
772
|
+
files.append(
|
|
773
|
+
{
|
|
774
|
+
"path": rel,
|
|
775
|
+
"sha256": _sha(cur),
|
|
776
|
+
"kind": kind,
|
|
777
|
+
"action": "unchanged",
|
|
778
|
+
"note": "user-owned, not managed — skipped",
|
|
779
|
+
}
|
|
780
|
+
)
|
|
781
|
+
continue
|
|
782
|
+
_snapshot(target, rel)
|
|
783
|
+
else:
|
|
784
|
+
created.append(target)
|
|
785
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
786
|
+
_atomic_write(target, data)
|
|
787
|
+
files.append(ledger.record(rel, data, kind, "updated" if existing_entry else "created"))
|
|
788
|
+
|
|
789
|
+
# Marker blocks in AGENTS.md / CLAUDE.md — delimited, user content kept.
|
|
790
|
+
if "marker" in kinds and ctx.spec.marker_body:
|
|
791
|
+
for m in ctx.spec.marker_files:
|
|
792
|
+
target = ctx.root / m
|
|
793
|
+
if not target.exists() and m != "AGENTS.md":
|
|
794
|
+
continue # never create CLAUDE.md unprompted; AGENTS.md is the generic contract
|
|
795
|
+
original = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
796
|
+
updated = apply_marker_block(original, ctx.spec.tag, ctx.spec.marker_body)
|
|
797
|
+
if updated == original:
|
|
798
|
+
markers.append(
|
|
799
|
+
{
|
|
800
|
+
"file": m,
|
|
801
|
+
"marker": ctx.spec.tag,
|
|
802
|
+
"sha256": marker_sha(ctx.spec.tag, ctx.spec.marker_body),
|
|
803
|
+
}
|
|
804
|
+
)
|
|
805
|
+
continue
|
|
806
|
+
if target.exists():
|
|
807
|
+
_snapshot(target, m)
|
|
808
|
+
else:
|
|
809
|
+
created.append(target)
|
|
810
|
+
_atomic_write(target, updated.encode("utf-8"))
|
|
811
|
+
ledger.record(
|
|
812
|
+
m, updated.encode(), "marker", "updated" if ledger.get(m) else "created"
|
|
813
|
+
)
|
|
814
|
+
markers.append(
|
|
815
|
+
{
|
|
816
|
+
"file": m,
|
|
817
|
+
"marker": ctx.spec.tag,
|
|
818
|
+
"sha256": marker_sha(ctx.spec.tag, ctx.spec.marker_body),
|
|
819
|
+
}
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
# .mcp.json managed key — only when the forge has an MCP server.
|
|
823
|
+
mcp_entries: list[dict[str, Any]] = []
|
|
824
|
+
if ctx.spec.mcp_server_name and "mcp" in kinds:
|
|
825
|
+
mcp_file = ctx.root / ".mcp.json"
|
|
826
|
+
entry = {"command": ctx.spec.mcp_command[0], "args": list(ctx.spec.mcp_command[1:])}
|
|
827
|
+
if mcp_file.exists():
|
|
828
|
+
cur = _load_json(mcp_file, None)
|
|
829
|
+
if cur is None:
|
|
830
|
+
checks.append(
|
|
831
|
+
{
|
|
832
|
+
"id": "mcp-json",
|
|
833
|
+
"status": "FAIL",
|
|
834
|
+
"detail": ".mcp.json exists but is not JSON",
|
|
835
|
+
}
|
|
836
|
+
)
|
|
837
|
+
else:
|
|
838
|
+
new = mcp_set(cur, ctx.spec.mcp_server_name, entry, ctx.spec.tag)
|
|
839
|
+
if _json_bytes(new) != mcp_file.read_bytes():
|
|
840
|
+
_snapshot(mcp_file, ".mcp.json")
|
|
841
|
+
_atomic_write(mcp_file, _json_bytes(new))
|
|
842
|
+
ledger.record(
|
|
843
|
+
".mcp.json",
|
|
844
|
+
_json_bytes(new),
|
|
845
|
+
"mcp",
|
|
846
|
+
"updated" if ledger.get(".mcp.json") else "created",
|
|
847
|
+
)
|
|
848
|
+
mcp_entries.append(
|
|
849
|
+
{
|
|
850
|
+
"server": ctx.spec.mcp_server_name,
|
|
851
|
+
"file": ".mcp.json",
|
|
852
|
+
"key": f"managed:{ctx.spec.tag}",
|
|
853
|
+
"action": "set",
|
|
854
|
+
}
|
|
855
|
+
)
|
|
856
|
+
else:
|
|
857
|
+
new = mcp_set(None, ctx.spec.mcp_server_name, entry, ctx.spec.tag)
|
|
858
|
+
created.append(mcp_file)
|
|
859
|
+
_atomic_write(mcp_file, _json_bytes(new))
|
|
860
|
+
ledger.record(".mcp.json", _json_bytes(new), "mcp", "created")
|
|
861
|
+
mcp_entries.append(
|
|
862
|
+
{
|
|
863
|
+
"server": ctx.spec.mcp_server_name,
|
|
864
|
+
"file": ".mcp.json",
|
|
865
|
+
"key": f"managed:{ctx.spec.tag}",
|
|
866
|
+
"action": "created",
|
|
867
|
+
}
|
|
868
|
+
)
|
|
869
|
+
except InstallError:
|
|
870
|
+
_rollback_writes(ctx, created, backups)
|
|
871
|
+
raise
|
|
872
|
+
except Exception as exc:
|
|
873
|
+
_rollback_writes(ctx, created, backups)
|
|
874
|
+
raise InstallError(
|
|
875
|
+
E_WRITE, f"install failed at write stage; managed writes reverted: {exc}"
|
|
876
|
+
) from exc
|
|
877
|
+
|
|
878
|
+
try:
|
|
879
|
+
ledger.save()
|
|
880
|
+
if ctx.options:
|
|
881
|
+
ctx.state_dir.mkdir(parents=True, exist_ok=True)
|
|
882
|
+
_atomic_write(component_file, _json_bytes(dict(ctx.options)))
|
|
883
|
+
except InstallError:
|
|
884
|
+
_rollback_writes(ctx, created, backups)
|
|
885
|
+
raise
|
|
886
|
+
except Exception as exc:
|
|
887
|
+
_rollback_writes(ctx, created, backups)
|
|
888
|
+
try:
|
|
889
|
+
if ledger_before is None:
|
|
890
|
+
ledger.path.unlink(missing_ok=True)
|
|
891
|
+
else:
|
|
892
|
+
_atomic_write(ledger.path, ledger_before)
|
|
893
|
+
if component_file is not None:
|
|
894
|
+
if component_before is None:
|
|
895
|
+
component_file.unlink(missing_ok=True)
|
|
896
|
+
else:
|
|
897
|
+
_atomic_write(component_file, component_before)
|
|
898
|
+
except OSError:
|
|
899
|
+
pass
|
|
900
|
+
raise InstallError(
|
|
901
|
+
E_WRITE, f"install failed at state stage; managed writes reverted: {exc}"
|
|
902
|
+
) from exc
|
|
903
|
+
written = sum(1 for f in files if f.get("action") in ("created", "updated"))
|
|
904
|
+
checks.append({"id": "files-written", "status": "PASS", "detail": f"{written} writes"})
|
|
905
|
+
if "codex" in ctx.hosts and ctx.scope == "project":
|
|
906
|
+
# Observed on codex-cli 0.160.0: `codex mcp list` ignores project
|
|
907
|
+
# `.mcp.json` and `.codex/config.toml` — MCP only enters through
|
|
908
|
+
# `~/.codex/config.toml` (user scope) or `codex mcp add`.
|
|
909
|
+
checks.append(
|
|
910
|
+
{
|
|
911
|
+
"id": "mcp-scope:codex",
|
|
912
|
+
"status": "NOT_APPLICABLE",
|
|
913
|
+
"detail": "codex does not read project .mcp.json; MCP needs "
|
|
914
|
+
"--scope user or `codex mcp add`",
|
|
915
|
+
}
|
|
916
|
+
)
|
|
917
|
+
receipt = {
|
|
918
|
+
"schema": SCHEMA_RECEIPT,
|
|
919
|
+
"receipt_id": f"sha256:{_sha(_json_bytes({'f': files, 'm': markers}))}",
|
|
920
|
+
"forge_id": ctx.spec.forge_id,
|
|
921
|
+
"operation": "install",
|
|
922
|
+
"scope": ctx.scope,
|
|
923
|
+
"profile": ctx.profile,
|
|
924
|
+
"components": dict(ctx.options) or None,
|
|
925
|
+
"target_root": str(ctx.root),
|
|
926
|
+
"host": ",".join(ctx.hosts) or None,
|
|
927
|
+
"dry_run": False,
|
|
928
|
+
"managed_files": files,
|
|
929
|
+
"managed_markers": markers,
|
|
930
|
+
"mcp": mcp_entries,
|
|
931
|
+
# Context-budget metrics — only what is observable at write time
|
|
932
|
+
# (§13.2). Token counts are never invented: hosts that supply
|
|
933
|
+
# them report through their own telemetry.
|
|
934
|
+
"context": {
|
|
935
|
+
"profile": ctx.profile,
|
|
936
|
+
"skills_bytes": ctx_bytes["skill"],
|
|
937
|
+
"agents_bytes": ctx_bytes["agent"],
|
|
938
|
+
"managed_bytes": ctx_bytes["managed"] or None,
|
|
939
|
+
"mcp_entries": len(mcp_entries) or None,
|
|
940
|
+
},
|
|
941
|
+
"env": {"python": platform.python_version()},
|
|
942
|
+
"checks": checks,
|
|
943
|
+
"verification": {
|
|
944
|
+
"status": "PASS"
|
|
945
|
+
if all(c["status"] in ("PASS", "NOT_APPLICABLE") for c in checks)
|
|
946
|
+
else "FAIL"
|
|
947
|
+
},
|
|
948
|
+
"status": "completed",
|
|
949
|
+
"rollback": {"available": bool(files), "restore": "ledger"},
|
|
950
|
+
"created_at": _utc_now(),
|
|
951
|
+
"created_by": _created_by(ctx.spec.distribution),
|
|
952
|
+
}
|
|
953
|
+
_write_receipt(ctx.state_dir, receipt)
|
|
954
|
+
return receipt
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _created_by(distribution: str) -> str:
|
|
958
|
+
"""``dist/version`` when the dist metadata is installed, bare ``dist``
|
|
959
|
+
otherwise — matches service.py's ``f"theforge/{__version__}"`` shape."""
|
|
960
|
+
try:
|
|
961
|
+
return f"{distribution}/{importlib_metadata.version(distribution)}"
|
|
962
|
+
except importlib_metadata.PackageNotFoundError:
|
|
963
|
+
return distribution
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _write_receipt(state_dir: Path, receipt: dict[str, Any]) -> Path:
|
|
967
|
+
ts = _utc_now().replace(":", "").replace("-", "")
|
|
968
|
+
path = state_dir / RECEIPTS_DIR / f"{receipt['operation']}-{ts}.json"
|
|
969
|
+
_atomic_write(path, _json_bytes(receipt))
|
|
970
|
+
return path
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# --------------------------------------------------------------------------
|
|
974
|
+
# status / doctor / repair / uninstall
|
|
975
|
+
# --------------------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def status(ctx: InstallContext) -> dict[str, Any]:
|
|
979
|
+
ledger = Ledger.load(ctx.state_dir)
|
|
980
|
+
drift = ledger.drift(ctx.root, ctx.spec)
|
|
981
|
+
ok = sum(1 for d in drift if d["status"] == "ok")
|
|
982
|
+
bad = [d for d in drift if d["status"] != "ok"]
|
|
983
|
+
receipts = (
|
|
984
|
+
sorted((ctx.state_dir / RECEIPTS_DIR).glob("*.json"))
|
|
985
|
+
if (ctx.state_dir / RECEIPTS_DIR).is_dir()
|
|
986
|
+
else []
|
|
987
|
+
)
|
|
988
|
+
return {
|
|
989
|
+
"schema": SCHEMA_HEALTH,
|
|
990
|
+
"forge_id": ctx.spec.forge_id,
|
|
991
|
+
"scope": ctx.scope,
|
|
992
|
+
"target_root": str(ctx.root),
|
|
993
|
+
"state_dir": str(ctx.state_dir),
|
|
994
|
+
"ledger_entries": len(ledger.entries()),
|
|
995
|
+
"drift": {
|
|
996
|
+
"ok": ok,
|
|
997
|
+
"modified": [d for d in bad if d["status"] == "modified"],
|
|
998
|
+
"missing": [d for d in bad if d["status"] == "missing"],
|
|
999
|
+
},
|
|
1000
|
+
"receipts": [r.name for r in receipts],
|
|
1001
|
+
"forge_portal": forge_portal_presence(),
|
|
1002
|
+
"status": ("healthy" if not bad else "degraded" if ok else "broken")
|
|
1003
|
+
if ledger.entries()
|
|
1004
|
+
else "unverified",
|
|
1005
|
+
"checked_at": _utc_now(),
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def doctor(ctx: InstallContext) -> dict[str, Any]:
|
|
1010
|
+
checks: list[dict[str, Any]] = []
|
|
1011
|
+
ledger = Ledger.load(ctx.state_dir)
|
|
1012
|
+
installed = ledger.entries()
|
|
1013
|
+
checks.append(
|
|
1014
|
+
{
|
|
1015
|
+
"id": "ledger",
|
|
1016
|
+
"status": "PASS" if installed else "UNVERIFIED",
|
|
1017
|
+
"detail": f"{len(installed)} managed entries",
|
|
1018
|
+
"repairable": False,
|
|
1019
|
+
}
|
|
1020
|
+
)
|
|
1021
|
+
for d in ledger.drift(ctx.root, ctx.spec):
|
|
1022
|
+
if d["status"] != "ok":
|
|
1023
|
+
checks.append(
|
|
1024
|
+
{
|
|
1025
|
+
"id": f"drift:{d['path']}",
|
|
1026
|
+
"status": "FAIL",
|
|
1027
|
+
"detail": d["status"],
|
|
1028
|
+
"repairable": True,
|
|
1029
|
+
}
|
|
1030
|
+
)
|
|
1031
|
+
if ctx.spec.mcp_server_name:
|
|
1032
|
+
mcp_file = ctx.root / ".mcp.json"
|
|
1033
|
+
if mcp_file.exists():
|
|
1034
|
+
doc = _load_json(mcp_file, None)
|
|
1035
|
+
ok = isinstance(doc, dict) and ctx.spec.mcp_server_name in doc.get("mcpServers", {})
|
|
1036
|
+
checks.append(
|
|
1037
|
+
{"id": "mcp-config", "status": "PASS" if ok else "FAIL", "repairable": not ok}
|
|
1038
|
+
)
|
|
1039
|
+
else:
|
|
1040
|
+
checks.append(
|
|
1041
|
+
{
|
|
1042
|
+
"id": "mcp-config",
|
|
1043
|
+
"status": "FAIL",
|
|
1044
|
+
"detail": ".mcp.json missing",
|
|
1045
|
+
"repairable": True,
|
|
1046
|
+
}
|
|
1047
|
+
)
|
|
1048
|
+
mcp_check = mcp_verify(ctx.spec)
|
|
1049
|
+
checks.append(mcp_check)
|
|
1050
|
+
invoke = mcp_check.get("invoke")
|
|
1051
|
+
if isinstance(invoke, dict): # hoisted so a FAIL degrades health
|
|
1052
|
+
checks.append(
|
|
1053
|
+
{
|
|
1054
|
+
"id": "mcp-invoke",
|
|
1055
|
+
"status": invoke["status"],
|
|
1056
|
+
"detail": f"{invoke['tool']}: {invoke['detail']}",
|
|
1057
|
+
"repairable": False,
|
|
1058
|
+
}
|
|
1059
|
+
)
|
|
1060
|
+
presence = forge_portal_presence()
|
|
1061
|
+
found = presence["state"] == "installed"
|
|
1062
|
+
checks.append(
|
|
1063
|
+
{
|
|
1064
|
+
"id": "forge-portal",
|
|
1065
|
+
"status": "PASS" if found else "NOT_APPLICABLE",
|
|
1066
|
+
"detail": presence["install_root"]
|
|
1067
|
+
or ("vendored assets only — no local forge-portal install found"
|
|
1068
|
+
if not found
|
|
1069
|
+
else presence["manifest"]),
|
|
1070
|
+
"repairable": False,
|
|
1071
|
+
}
|
|
1072
|
+
)
|
|
1073
|
+
overall = "healthy"
|
|
1074
|
+
if any(c["status"] == "FAIL" for c in checks):
|
|
1075
|
+
overall = "degraded" if installed else "broken"
|
|
1076
|
+
elif not installed:
|
|
1077
|
+
overall = "unverified"
|
|
1078
|
+
handshake = next((c for c in checks if c.get("id") == "mcp-handshake"), None)
|
|
1079
|
+
return {
|
|
1080
|
+
"schema": SCHEMA_HEALTH,
|
|
1081
|
+
"forge_id": ctx.spec.forge_id,
|
|
1082
|
+
"status": overall,
|
|
1083
|
+
"checks": checks,
|
|
1084
|
+
# Observable context budget only (§13.2): tools the server
|
|
1085
|
+
# actually enumerated; nothing inferred.
|
|
1086
|
+
"context": {
|
|
1087
|
+
"profile": ctx.profile,
|
|
1088
|
+
"managed_entries": len(installed),
|
|
1089
|
+
"tools_exposed": len(handshake["tools"])
|
|
1090
|
+
if isinstance(handshake, dict) and isinstance(handshake.get("tools"), list)
|
|
1091
|
+
else None,
|
|
1092
|
+
},
|
|
1093
|
+
"checked_at": _utc_now(),
|
|
1094
|
+
"repair_hint": f"{ctx.spec.cli_name} repair"
|
|
1095
|
+
if any(c.get("repairable") for c in checks)
|
|
1096
|
+
else None,
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _repair_content(
|
|
1101
|
+
ctx: InstallContext, rel: str, entry: dict[str, Any], assets: dict[str, bytes], target: Path
|
|
1102
|
+
) -> bytes | None:
|
|
1103
|
+
"""Canonical bytes for a drifted *managed* entry, or ``None`` when the
|
|
1104
|
+
entry cannot be re-asserted. Marker entries re-heal the managed block
|
|
1105
|
+
inside the user's file (content outside the markers survives);
|
|
1106
|
+
``.mcp.json`` re-sets the managed server key preserving sibling keys;
|
|
1107
|
+
whole-file managed assets re-assert the rendered bytes."""
|
|
1108
|
+
kind = entry.get("kind")
|
|
1109
|
+
if kind == "marker" and rel in ctx.spec.marker_files:
|
|
1110
|
+
original = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
1111
|
+
return apply_marker_block(original, ctx.spec.tag, ctx.spec.marker_body).encode("utf-8")
|
|
1112
|
+
if kind == "mcp" and ctx.spec.mcp_server_name:
|
|
1113
|
+
cur = _load_json(target, {})
|
|
1114
|
+
if not isinstance(cur, dict):
|
|
1115
|
+
cur = {}
|
|
1116
|
+
want = {"command": ctx.spec.mcp_command[0], "args": list(ctx.spec.mcp_command[1:])}
|
|
1117
|
+
new = mcp_set(cur, ctx.spec.mcp_server_name, want, ctx.spec.tag)
|
|
1118
|
+
return _json_bytes(new)
|
|
1119
|
+
return assets.get(rel)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def repair(ctx: InstallContext) -> dict[str, Any]:
|
|
1123
|
+
"""Re-assert drifted *managed* content: missing entries are restored,
|
|
1124
|
+
damaged managed regions (marker block, mcp key) are healed inside the
|
|
1125
|
+
user's file, drifted managed assets are re-asserted. Entries with no
|
|
1126
|
+
canonical source land in ``skipped`` as unrepairable; foreign content is
|
|
1127
|
+
never touched."""
|
|
1128
|
+
ledger = Ledger.load(ctx.state_dir)
|
|
1129
|
+
drift = [d for d in ledger.drift(ctx.root, ctx.spec) if d["status"] != "ok"]
|
|
1130
|
+
fixed, kept, skipped = [], [], []
|
|
1131
|
+
assets = ctx.spec.render_assets(ctx) if ctx.spec.render_assets else {}
|
|
1132
|
+
for d in drift:
|
|
1133
|
+
rel = d["path"]
|
|
1134
|
+
entry = ledger.get(rel)
|
|
1135
|
+
target = ctx.root / rel
|
|
1136
|
+
if entry is None:
|
|
1137
|
+
skipped.append(rel)
|
|
1138
|
+
continue
|
|
1139
|
+
data = _repair_content(ctx, rel, entry, assets, target)
|
|
1140
|
+
if data is None:
|
|
1141
|
+
skipped.append(rel)
|
|
1142
|
+
continue
|
|
1143
|
+
if target.exists() and target.read_bytes() == data:
|
|
1144
|
+
kept.append(rel)
|
|
1145
|
+
continue
|
|
1146
|
+
if target.exists():
|
|
1147
|
+
bak = ctx.state_dir / "backups" / rel
|
|
1148
|
+
bak.parent.mkdir(parents=True, exist_ok=True)
|
|
1149
|
+
_atomic_write(bak, target.read_bytes())
|
|
1150
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1151
|
+
_atomic_write(target, data)
|
|
1152
|
+
ledger.record(
|
|
1153
|
+
rel, data, entry["kind"], "restored" if d["status"] == "missing" else "rehealed"
|
|
1154
|
+
)
|
|
1155
|
+
fixed.append(rel)
|
|
1156
|
+
ledger.save()
|
|
1157
|
+
return {
|
|
1158
|
+
"schema": SCHEMA_RECEIPT,
|
|
1159
|
+
"forge_id": ctx.spec.forge_id,
|
|
1160
|
+
"operation": "repair",
|
|
1161
|
+
"scope": ctx.scope,
|
|
1162
|
+
"target_root": str(ctx.root),
|
|
1163
|
+
"repaired": fixed,
|
|
1164
|
+
"kept": kept,
|
|
1165
|
+
"skipped": skipped,
|
|
1166
|
+
"managed_files": [],
|
|
1167
|
+
"checks": [
|
|
1168
|
+
{
|
|
1169
|
+
"id": "repair",
|
|
1170
|
+
"status": "PASS" if not skipped else "FAIL",
|
|
1171
|
+
"detail": (f"{len(fixed)} restored, {len(kept)} kept, {len(skipped)} skipped"),
|
|
1172
|
+
}
|
|
1173
|
+
],
|
|
1174
|
+
"verification": {"status": "PASS" if not skipped else "FAIL"},
|
|
1175
|
+
"status": "completed",
|
|
1176
|
+
"created_at": _utc_now(),
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def uninstall(ctx: InstallContext, *, purge_state: bool = False) -> dict[str, Any]:
|
|
1181
|
+
"""Remove only ledger-owned files. Adopted and user files survive;
|
|
1182
|
+
marker blocks are excised; the .mcp.json managed key is unset."""
|
|
1183
|
+
ledger = Ledger.load(ctx.state_dir)
|
|
1184
|
+
removed, kept = [], []
|
|
1185
|
+
for rel in ledger.owned_paths():
|
|
1186
|
+
target = ctx.root / rel
|
|
1187
|
+
if rel in ctx.spec.marker_files and target.exists():
|
|
1188
|
+
# marker files: excise the block, keep the file
|
|
1189
|
+
txt = remove_marker_block(target.read_text(encoding="utf-8"), ctx.spec.tag)
|
|
1190
|
+
if txt.strip():
|
|
1191
|
+
_atomic_write(target, txt.encode())
|
|
1192
|
+
kept.append(rel)
|
|
1193
|
+
else:
|
|
1194
|
+
target.unlink()
|
|
1195
|
+
removed.append(rel)
|
|
1196
|
+
ledger.drop(rel)
|
|
1197
|
+
continue
|
|
1198
|
+
if target.exists():
|
|
1199
|
+
# Re-verify sha256 before deleting — a user-modified managed
|
|
1200
|
+
# file is treated as user-owned and kept.
|
|
1201
|
+
reg = ledger.get(rel)
|
|
1202
|
+
if reg is None or _sha_file(target) != reg["sha256"]:
|
|
1203
|
+
kept.append(rel)
|
|
1204
|
+
ledger.drop(rel)
|
|
1205
|
+
continue
|
|
1206
|
+
target.unlink()
|
|
1207
|
+
removed.append(rel)
|
|
1208
|
+
ledger.drop(rel)
|
|
1209
|
+
if ctx.spec.mcp_server_name:
|
|
1210
|
+
mcp_file = ctx.root / ".mcp.json"
|
|
1211
|
+
if mcp_file.exists():
|
|
1212
|
+
doc = _load_json(mcp_file, None)
|
|
1213
|
+
if isinstance(doc, dict):
|
|
1214
|
+
new = mcp_unset(doc, ctx.spec.mcp_server_name, ctx.spec.tag)
|
|
1215
|
+
if new.get("mcpServers") or len(new) > 0:
|
|
1216
|
+
_atomic_write(mcp_file, _json_bytes(new))
|
|
1217
|
+
else:
|
|
1218
|
+
mcp_file.unlink()
|
|
1219
|
+
ledger.drop(".mcp.json")
|
|
1220
|
+
_prune_empty_dirs(ctx.root, removed)
|
|
1221
|
+
if purge_state and ctx.state_dir.exists():
|
|
1222
|
+
import shutil
|
|
1223
|
+
|
|
1224
|
+
shutil.rmtree(ctx.state_dir, ignore_errors=True)
|
|
1225
|
+
# prune now-empty forge state ancestors (e.g. .forge-doctor-data/)
|
|
1226
|
+
parent = ctx.state_dir.parent
|
|
1227
|
+
while parent != ctx.root and ctx.root in parent.parents:
|
|
1228
|
+
try:
|
|
1229
|
+
parent.rmdir()
|
|
1230
|
+
except OSError:
|
|
1231
|
+
break
|
|
1232
|
+
parent = parent.parent
|
|
1233
|
+
else:
|
|
1234
|
+
ledger.save()
|
|
1235
|
+
receipt = {
|
|
1236
|
+
"schema": SCHEMA_RECEIPT,
|
|
1237
|
+
"forge_id": ctx.spec.forge_id,
|
|
1238
|
+
"operation": "uninstall",
|
|
1239
|
+
"scope": ctx.scope,
|
|
1240
|
+
"target_root": str(ctx.root),
|
|
1241
|
+
"removed": removed,
|
|
1242
|
+
"kept": kept,
|
|
1243
|
+
"managed_files": [],
|
|
1244
|
+
"checks": [
|
|
1245
|
+
{
|
|
1246
|
+
"id": "uninstall",
|
|
1247
|
+
"status": "PASS",
|
|
1248
|
+
"detail": f"{len(removed)} removed, {len(kept)} kept",
|
|
1249
|
+
}
|
|
1250
|
+
],
|
|
1251
|
+
"verification": {"status": "PASS"},
|
|
1252
|
+
"status": "completed",
|
|
1253
|
+
"created_at": _utc_now(),
|
|
1254
|
+
}
|
|
1255
|
+
if not purge_state:
|
|
1256
|
+
_write_receipt(ctx.state_dir, receipt)
|
|
1257
|
+
return receipt
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _prune_empty_dirs(root: Path, removed: list[str]) -> None:
|
|
1261
|
+
"""Remove empty parent dirs of deleted managed files, stopping at the
|
|
1262
|
+
first non-empty ancestor or ``root``. User dirs are never touched —
|
|
1263
|
+
``rmdir`` refuses on non-empty directories by design."""
|
|
1264
|
+
seen: set[Path] = set()
|
|
1265
|
+
for rel in removed:
|
|
1266
|
+
parent = (root / rel).parent
|
|
1267
|
+
while parent != root and root in parent.parents and parent not in seen:
|
|
1268
|
+
try:
|
|
1269
|
+
parent.rmdir()
|
|
1270
|
+
except OSError:
|
|
1271
|
+
break
|
|
1272
|
+
seen.add(parent)
|
|
1273
|
+
parent = parent.parent
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
# --------------------------------------------------------------------------
|
|
1277
|
+
# ~/.forge registry — the forge-neutral installations directory
|
|
1278
|
+
# --------------------------------------------------------------------------
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def manifest_for(
|
|
1282
|
+
spec: ForgeSpec,
|
|
1283
|
+
*,
|
|
1284
|
+
install_root: Path,
|
|
1285
|
+
venv: Path | None,
|
|
1286
|
+
version: str,
|
|
1287
|
+
source: dict[str, Any],
|
|
1288
|
+
shim: Path | None,
|
|
1289
|
+
) -> dict[str, Any]:
|
|
1290
|
+
doc = {
|
|
1291
|
+
"schema": SCHEMA_MANIFEST,
|
|
1292
|
+
"forge_id": spec.forge_id,
|
|
1293
|
+
"package": spec.package,
|
|
1294
|
+
"distribution": spec.distribution,
|
|
1295
|
+
"cli": {"name": spec.cli_name, "version_cmd": [spec.cli_name, *spec.version_cmd]},
|
|
1296
|
+
"version": version,
|
|
1297
|
+
"install_root": str(install_root),
|
|
1298
|
+
"python": {
|
|
1299
|
+
"executable": str(_venv_python(venv)) if venv else sys.executable,
|
|
1300
|
+
"version": platform.python_version(),
|
|
1301
|
+
"satisfies": spec.python_spec,
|
|
1302
|
+
},
|
|
1303
|
+
"source": source,
|
|
1304
|
+
"installed_at": _utc_now(),
|
|
1305
|
+
"installed_by": {"agent": "forge_bootstrap", "version": _INSTALLKIT_VERSION},
|
|
1306
|
+
}
|
|
1307
|
+
if venv:
|
|
1308
|
+
doc["venv"] = str(venv)
|
|
1309
|
+
if shim:
|
|
1310
|
+
cli_doc = doc["cli"]
|
|
1311
|
+
if isinstance(cli_doc, dict):
|
|
1312
|
+
cli_doc["shim"] = str(shim)
|
|
1313
|
+
if spec.mcp_server_name:
|
|
1314
|
+
mcp_doc: dict[str, Any] = {
|
|
1315
|
+
"server_name": spec.mcp_server_name,
|
|
1316
|
+
"command": list(spec.mcp_command),
|
|
1317
|
+
"verified": False,
|
|
1318
|
+
}
|
|
1319
|
+
if spec.mcp_verify_tool:
|
|
1320
|
+
mcp_doc["verify_tool"] = spec.mcp_verify_tool
|
|
1321
|
+
doc["mcp"] = mcp_doc
|
|
1322
|
+
return doc
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def register_installation(manifest: dict[str, Any]) -> Path:
|
|
1326
|
+
path = installations_dir() / f"{manifest['forge_id']}.json"
|
|
1327
|
+
_atomic_write(path, _json_bytes(manifest))
|
|
1328
|
+
return path
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def deregister_installation(forge_id: str) -> bool:
|
|
1332
|
+
path = installations_dir() / f"{forge_id}.json"
|
|
1333
|
+
if path.exists():
|
|
1334
|
+
path.unlink()
|
|
1335
|
+
return True
|
|
1336
|
+
return False
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def installed_forges() -> list[dict[str, Any]]:
|
|
1340
|
+
out = []
|
|
1341
|
+
d = installations_dir()
|
|
1342
|
+
if d.is_dir():
|
|
1343
|
+
for f in sorted(d.glob("*.json")):
|
|
1344
|
+
doc = _load_json(f, None)
|
|
1345
|
+
if isinstance(doc, dict):
|
|
1346
|
+
doc["_manifest_path"] = str(f)
|
|
1347
|
+
out.append(doc)
|
|
1348
|
+
return out
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def forge_portal_presence() -> dict[str, Any]:
|
|
1352
|
+
"""Whether a local forge-portal exists in an already-defined location:
|
|
1353
|
+
``FORGE_PORTAL_HOME`` env, ``~/.forge/installations/forge-portal.json``
|
|
1354
|
+
(InstallationManifest registry) or ``~/.forge/installs/forge-portal/``.
|
|
1355
|
+
Never fabricates paths — every reported location exists on disk."""
|
|
1356
|
+
locations: list[dict[str, str]] = []
|
|
1357
|
+
|
|
1358
|
+
env_root = os.environ.get("FORGE_PORTAL_HOME", "").strip()
|
|
1359
|
+
env_ok = bool(env_root) and Path(env_root).is_dir()
|
|
1360
|
+
if env_ok:
|
|
1361
|
+
locations.append({"kind": "env:FORGE_PORTAL_HOME", "path": env_root})
|
|
1362
|
+
|
|
1363
|
+
manifest_path = installations_dir() / "forge-portal.json"
|
|
1364
|
+
install_root: str | None = None
|
|
1365
|
+
installed_at: str | None = None
|
|
1366
|
+
manifest_ok = manifest_path.is_file()
|
|
1367
|
+
if manifest_ok:
|
|
1368
|
+
locations.append({"kind": "registry", "path": str(manifest_path)})
|
|
1369
|
+
doc = _load_json(manifest_path, None)
|
|
1370
|
+
if isinstance(doc, dict):
|
|
1371
|
+
install_root = doc.get("install_root") or None
|
|
1372
|
+
installed_at = doc.get("installed_at") or None
|
|
1373
|
+
|
|
1374
|
+
if install_root and not Path(install_root).is_dir():
|
|
1375
|
+
install_root = None # manifest points somewhere gone — honest
|
|
1376
|
+
if install_root:
|
|
1377
|
+
locations.append({"kind": "install_root", "path": install_root})
|
|
1378
|
+
elif not manifest_ok:
|
|
1379
|
+
default_root = installs_root() / "forge-portal"
|
|
1380
|
+
if default_root.is_dir():
|
|
1381
|
+
install_root = str(default_root)
|
|
1382
|
+
locations.append({"kind": "install_root", "path": install_root})
|
|
1383
|
+
|
|
1384
|
+
root = install_root or (env_root if env_ok else None)
|
|
1385
|
+
return {
|
|
1386
|
+
"state": "installed" if root or manifest_ok else "vendored",
|
|
1387
|
+
"install_root": root,
|
|
1388
|
+
"manifest": str(manifest_path) if manifest_ok else None,
|
|
1389
|
+
"installed_at": installed_at,
|
|
1390
|
+
"locations": locations,
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
# --------------------------------------------------------------------------
|
|
1395
|
+
# Workspace members — multi-repo discovery (``forge/WorkspaceInstall/v1``)
|
|
1396
|
+
# --------------------------------------------------------------------------
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def discover_projects(root: Path, *, max_depth: int = 2) -> list[Path]:
|
|
1400
|
+
"""Independent repos under a workspace root: directories containing
|
|
1401
|
+
``.git``. ``root`` itself is included when it is a repository; nested
|
|
1402
|
+
checkouts are found up to ``max_depth`` levels deep. Never descends
|
|
1403
|
+
into a discovered repo (its inner modules stay isolated)."""
|
|
1404
|
+
root = Path(root).resolve()
|
|
1405
|
+
found: list[Path] = []
|
|
1406
|
+
if (root / ".git").exists():
|
|
1407
|
+
found.append(root)
|
|
1408
|
+
|
|
1409
|
+
def _walk(d: Path, depth: int) -> None:
|
|
1410
|
+
if depth > max_depth:
|
|
1411
|
+
return
|
|
1412
|
+
try:
|
|
1413
|
+
children = sorted(p for p in d.iterdir() if p.is_dir())
|
|
1414
|
+
except OSError:
|
|
1415
|
+
return
|
|
1416
|
+
for child in children:
|
|
1417
|
+
if child.name.startswith("."): # hidden dirs incl. .git internals
|
|
1418
|
+
continue
|
|
1419
|
+
if (child / ".git").exists():
|
|
1420
|
+
found.append(child.resolve())
|
|
1421
|
+
else:
|
|
1422
|
+
_walk(child, depth + 1)
|
|
1423
|
+
|
|
1424
|
+
_walk(root, 1)
|
|
1425
|
+
return found
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def member_precedence(member: Path, spec: ForgeSpec) -> str | None:
|
|
1429
|
+
"""``"project"`` when a member already carries a project-scope install
|
|
1430
|
+
or its own managed MCP key for this forge — local specializes the
|
|
1431
|
+
workspace install and must never be silently widened. ``None`` when
|
|
1432
|
+
the member is a clean adoption target."""
|
|
1433
|
+
state = member / spec.state_dir
|
|
1434
|
+
if state.is_dir():
|
|
1435
|
+
return "project"
|
|
1436
|
+
mcp = member / ".mcp.json"
|
|
1437
|
+
if spec.mcp_server_name and mcp.exists():
|
|
1438
|
+
doc = _load_json(mcp, None)
|
|
1439
|
+
if isinstance(doc, dict) and spec.mcp_server_name in (doc.get("mcpServers") or {}):
|
|
1440
|
+
return "project"
|
|
1441
|
+
return None
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
# --------------------------------------------------------------------------
|
|
1445
|
+
# Spawn-gated ops — refused honestly where subprocess is unavailable
|
|
1446
|
+
# --------------------------------------------------------------------------
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _spawn(
|
|
1450
|
+
spec: ForgeSpec,
|
|
1451
|
+
cmd: list[str],
|
|
1452
|
+
*,
|
|
1453
|
+
timeout: int = 30,
|
|
1454
|
+
input_bytes: bytes | None = None,
|
|
1455
|
+
env: dict[str, str] | None = None,
|
|
1456
|
+
) -> tuple[int, bytes, bytes]:
|
|
1457
|
+
if not spec.spawn_ok:
|
|
1458
|
+
raise InstallError(
|
|
1459
|
+
E_SPAWN,
|
|
1460
|
+
f"{spec.forge_id}: subprocess disabled by policy — "
|
|
1461
|
+
"run the equivalent scripts/ helper instead",
|
|
1462
|
+
)
|
|
1463
|
+
# --vendor-strip:subprocess-begin
|
|
1464
|
+
import subprocess # noqa: PLC0415 - lazy: spawn_ok gates reachability
|
|
1465
|
+
|
|
1466
|
+
proc = subprocess.run( # noqa: S603 — cmd is the declared spec
|
|
1467
|
+
cmd, capture_output=True, timeout=timeout, input=input_bytes, env=env
|
|
1468
|
+
)
|
|
1469
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
1470
|
+
# --vendor-strip:subprocess-end
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def mcp_verify(spec: ForgeSpec, *, timeout: int = 20) -> dict[str, Any]:
|
|
1474
|
+
"""JSON-RPC stdio handshake: initialize → notifications/initialized →
|
|
1475
|
+
tools/list. Reports honestly — never infers success from file presence."""
|
|
1476
|
+
if not spec.mcp_server_name or not spec.mcp_command:
|
|
1477
|
+
return {
|
|
1478
|
+
"id": "mcp-handshake",
|
|
1479
|
+
"status": "NOT_APPLICABLE",
|
|
1480
|
+
"detail": "no MCP server declared",
|
|
1481
|
+
}
|
|
1482
|
+
if not spec.spawn_ok:
|
|
1483
|
+
return {
|
|
1484
|
+
"id": "mcp-handshake",
|
|
1485
|
+
"status": "BLOCKED",
|
|
1486
|
+
"detail": "subprocess disabled by policy",
|
|
1487
|
+
}
|
|
1488
|
+
exe = spec.mcp_command[0]
|
|
1489
|
+
if not (os.sep in exe or (os.altsep and os.altsep in exe)):
|
|
1490
|
+
import shutil # noqa: PLC0415
|
|
1491
|
+
|
|
1492
|
+
if shutil.which(exe) is None:
|
|
1493
|
+
return {
|
|
1494
|
+
"id": "mcp-handshake",
|
|
1495
|
+
"status": "BLOCKED",
|
|
1496
|
+
"detail": f"{exe!r} not resolvable on PATH — install incomplete",
|
|
1497
|
+
}
|
|
1498
|
+
try:
|
|
1499
|
+
import subprocess # noqa: PLC0415
|
|
1500
|
+
|
|
1501
|
+
proc = subprocess.Popen( # noqa: S603 — command is the declared spec
|
|
1502
|
+
list(spec.mcp_command),
|
|
1503
|
+
stdin=subprocess.PIPE,
|
|
1504
|
+
stdout=subprocess.PIPE,
|
|
1505
|
+
stderr=subprocess.PIPE,
|
|
1506
|
+
)
|
|
1507
|
+
except FileNotFoundError as exc:
|
|
1508
|
+
# CLI not on PATH — the install is incomplete, not defective.
|
|
1509
|
+
return {
|
|
1510
|
+
"id": "mcp-handshake",
|
|
1511
|
+
"status": "BLOCKED",
|
|
1512
|
+
"detail": f"cannot spawn {spec.mcp_command[0]!r}: {exc}",
|
|
1513
|
+
}
|
|
1514
|
+
except OSError as exc:
|
|
1515
|
+
return {
|
|
1516
|
+
"id": "mcp-handshake",
|
|
1517
|
+
"status": "FAIL",
|
|
1518
|
+
"detail": f"cannot spawn {spec.mcp_command[0]!r}: {exc}",
|
|
1519
|
+
}
|
|
1520
|
+
if proc.stdin is None or proc.stdout is None:
|
|
1521
|
+
proc.kill()
|
|
1522
|
+
return {"id": "mcp-handshake", "status": "FAIL", "detail": "stdio pipes unavailable"}
|
|
1523
|
+
try:
|
|
1524
|
+
out = _mcp_handshake(proc, spec, timeout)
|
|
1525
|
+
except Exception as exc: # handshake failures are FAIL, not crashes
|
|
1526
|
+
out = {"id": "mcp-handshake", "status": "FAIL", "detail": str(exc)}
|
|
1527
|
+
killed = False
|
|
1528
|
+
try:
|
|
1529
|
+
proc.terminate()
|
|
1530
|
+
proc.wait(timeout=3)
|
|
1531
|
+
except Exception:
|
|
1532
|
+
try:
|
|
1533
|
+
proc.kill()
|
|
1534
|
+
killed = True
|
|
1535
|
+
except Exception: # noqa: S110 — best-effort kill after terminate
|
|
1536
|
+
pass
|
|
1537
|
+
stderr_tail = b""
|
|
1538
|
+
try:
|
|
1539
|
+
if proc.stderr is not None:
|
|
1540
|
+
stderr_tail = proc.stderr.read()[-400:]
|
|
1541
|
+
except Exception: # noqa: S110 — server stderr is best-effort evidence
|
|
1542
|
+
pass
|
|
1543
|
+
out["process"] = {
|
|
1544
|
+
"exit": ("clean" if proc.returncode == 0 else "killed" if killed else "terminated"),
|
|
1545
|
+
"returncode": proc.returncode,
|
|
1546
|
+
"stderr_tail": stderr_tail.decode(errors="replace").strip()[-200:] or None,
|
|
1547
|
+
}
|
|
1548
|
+
return out
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _mcp_handshake(proc: Any, spec: ForgeSpec, timeout: int) -> dict[str, Any]:
|
|
1552
|
+
"""initialize → initialized → tools/list (+ tools/call when the spec
|
|
1553
|
+
declares a no-arg ``mcp_verify_tool``). Returns the check dict; the
|
|
1554
|
+
caller attaches process-exit evidence."""
|
|
1555
|
+
assert proc.stdin is not None and proc.stdout is not None
|
|
1556
|
+
req = json.dumps(
|
|
1557
|
+
{
|
|
1558
|
+
"jsonrpc": "2.0",
|
|
1559
|
+
"id": 1,
|
|
1560
|
+
"method": "initialize",
|
|
1561
|
+
"params": {
|
|
1562
|
+
"protocolVersion": "2024-11-05",
|
|
1563
|
+
"capabilities": {},
|
|
1564
|
+
"clientInfo": {"name": "forge-verify", "version": "1"},
|
|
1565
|
+
},
|
|
1566
|
+
}
|
|
1567
|
+
).encode()
|
|
1568
|
+
proc.stdin.write(req + b"\n")
|
|
1569
|
+
proc.stdin.write(
|
|
1570
|
+
json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}).encode() + b"\n"
|
|
1571
|
+
)
|
|
1572
|
+
proc.stdin.write(
|
|
1573
|
+
json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}).encode() + b"\n"
|
|
1574
|
+
)
|
|
1575
|
+
if spec.mcp_verify_tool:
|
|
1576
|
+
proc.stdin.write(
|
|
1577
|
+
json.dumps(
|
|
1578
|
+
{
|
|
1579
|
+
"jsonrpc": "2.0",
|
|
1580
|
+
"id": 3,
|
|
1581
|
+
"method": "tools/call",
|
|
1582
|
+
"params": {"name": spec.mcp_verify_tool, "arguments": {}},
|
|
1583
|
+
}
|
|
1584
|
+
).encode()
|
|
1585
|
+
+ b"\n"
|
|
1586
|
+
)
|
|
1587
|
+
proc.stdin.flush()
|
|
1588
|
+
# selectors cannot watch pipes on Windows — a daemon reader thread
|
|
1589
|
+
# draining stdout into a queue is the portable deadline mechanism.
|
|
1590
|
+
import queue # noqa: PLC0415
|
|
1591
|
+
import threading # noqa: PLC0415
|
|
1592
|
+
import time # noqa: PLC0415
|
|
1593
|
+
|
|
1594
|
+
lines: "queue.Queue[bytes]" = queue.Queue()
|
|
1595
|
+
|
|
1596
|
+
def _drain() -> None:
|
|
1597
|
+
for ln in proc.stdout:
|
|
1598
|
+
lines.put(ln)
|
|
1599
|
+
|
|
1600
|
+
threading.Thread(target=_drain, daemon=True).start()
|
|
1601
|
+
tools: list[str] = []
|
|
1602
|
+
got_init = False
|
|
1603
|
+
invoke: dict[str, Any] | None = (
|
|
1604
|
+
{"tool": spec.mcp_verify_tool, "status": "UNVERIFIED", "detail": "no response"}
|
|
1605
|
+
if spec.mcp_verify_tool
|
|
1606
|
+
else None
|
|
1607
|
+
)
|
|
1608
|
+
want_ids = {1, 2} | ({3} if spec.mcp_verify_tool else set())
|
|
1609
|
+
deadline = time.monotonic() + timeout
|
|
1610
|
+
while want_ids:
|
|
1611
|
+
remain = deadline - time.monotonic()
|
|
1612
|
+
if remain <= 0:
|
|
1613
|
+
break
|
|
1614
|
+
try:
|
|
1615
|
+
line = lines.get(timeout=remain)
|
|
1616
|
+
except queue.Empty:
|
|
1617
|
+
break
|
|
1618
|
+
if not line:
|
|
1619
|
+
break
|
|
1620
|
+
try:
|
|
1621
|
+
msg = json.loads(line)
|
|
1622
|
+
except ValueError:
|
|
1623
|
+
continue
|
|
1624
|
+
mid = msg.get("id")
|
|
1625
|
+
if mid == 1 and "result" in msg:
|
|
1626
|
+
got_init = True
|
|
1627
|
+
want_ids.discard(1)
|
|
1628
|
+
elif mid == 2 and "result" in msg:
|
|
1629
|
+
tools = [t.get("name", "?") for t in msg["result"].get("tools", [])]
|
|
1630
|
+
want_ids.discard(2)
|
|
1631
|
+
elif mid == 3 and invoke is not None:
|
|
1632
|
+
want_ids.discard(3)
|
|
1633
|
+
if "error" in msg:
|
|
1634
|
+
invoke["status"] = "FAIL"
|
|
1635
|
+
invoke["detail"] = str(msg["error"])[:200]
|
|
1636
|
+
else:
|
|
1637
|
+
result = msg.get("result") or {}
|
|
1638
|
+
if result.get("isError"):
|
|
1639
|
+
invoke["status"] = "FAIL"
|
|
1640
|
+
invoke["detail"] = "tool reported isError"
|
|
1641
|
+
else:
|
|
1642
|
+
invoke["status"] = "PASS"
|
|
1643
|
+
invoke["detail"] = "structured result returned"
|
|
1644
|
+
if not got_init:
|
|
1645
|
+
return {"id": "mcp-handshake", "status": "FAIL", "detail": "no initialize response"}
|
|
1646
|
+
out: dict[str, Any] = {
|
|
1647
|
+
"id": "mcp-handshake",
|
|
1648
|
+
"status": "PASS",
|
|
1649
|
+
"detail": f"{len(tools)} tools",
|
|
1650
|
+
"tools": tools[:50],
|
|
1651
|
+
}
|
|
1652
|
+
if invoke is not None:
|
|
1653
|
+
out["invoke"] = invoke
|
|
1654
|
+
return out
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def cli_version(spec: ForgeSpec, *, timeout: int = 15) -> str | None:
|
|
1658
|
+
try:
|
|
1659
|
+
code, out, _ = _spawn(spec, [spec.cli_name, *spec.version_cmd], timeout=timeout)
|
|
1660
|
+
if code == 0:
|
|
1661
|
+
return out.decode(errors="replace").strip().splitlines()[0]
|
|
1662
|
+
except (InstallError, Exception):
|
|
1663
|
+
return None
|
|
1664
|
+
return None
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
# --------------------------------------------------------------------------
|
|
1668
|
+
# Launcher shims — make <cli> resolvable in a new terminal
|
|
1669
|
+
# --------------------------------------------------------------------------
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def write_launcher(spec: ForgeSpec, venv: Path) -> Path:
|
|
1673
|
+
"""Write a shim into the shim dir that delegates to the venv's console
|
|
1674
|
+
script. POSIX gets an exec wrapper; Windows gets a .cmd + a bash shim."""
|
|
1675
|
+
bindir = _venv_bin(venv)
|
|
1676
|
+
real = bindir / (spec.cli_name + (".exe" if os.name == "nt" else ""))
|
|
1677
|
+
if not real.exists():
|
|
1678
|
+
real = bindir / spec.cli_name
|
|
1679
|
+
shim_dir = _shim_dir()
|
|
1680
|
+
shim_dir.mkdir(parents=True, exist_ok=True)
|
|
1681
|
+
if os.name == "nt":
|
|
1682
|
+
cmd = shim_dir / f"{spec.cli_name}.cmd"
|
|
1683
|
+
cmd.write_text(f'@echo off\r\n"{real}" %*\r\n', encoding="utf-8")
|
|
1684
|
+
bash = shim_dir / spec.cli_name
|
|
1685
|
+
bash.write_text(f'#!/bin/sh\nexec "{real}" "$@"\n', encoding="utf-8")
|
|
1686
|
+
return cmd
|
|
1687
|
+
shim = shim_dir / spec.cli_name
|
|
1688
|
+
shim.write_text(f'#!/bin/sh\nexec "{real}" "$@"\n', encoding="utf-8")
|
|
1689
|
+
shim.chmod(0o755)
|
|
1690
|
+
return shim
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def path_guidance() -> str | None:
|
|
1694
|
+
"""None when the shim dir is already on PATH; else the line to add."""
|
|
1695
|
+
shim = _shim_dir()
|
|
1696
|
+
path = os.environ.get("PATH", "")
|
|
1697
|
+
if str(shim) in path.split(os.pathsep):
|
|
1698
|
+
return None
|
|
1699
|
+
if os.name == "nt":
|
|
1700
|
+
return f'setx PATH "%PATH%;{shim}" # then open a new terminal'
|
|
1701
|
+
shell = Path(os.environ.get("SHELL", "/bin/sh")).name
|
|
1702
|
+
rc = {"zsh": "~/.zshrc", "bash": "~/.bashrc"}.get(shell, "~/.profile")
|
|
1703
|
+
return f"echo 'export PATH=\"{shim}:$PATH\"' >> {rc} # then restart the shell"
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
__all__ = [name for name in dir() if not name.startswith("_")]
|