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.
Files changed (659) hide show
  1. platformforge/__init__.py +7 -0
  2. platformforge/__main__.py +5 -0
  3. platformforge/_forge_portal.py +913 -0
  4. platformforge/_graphstudio.py +376 -0
  5. platformforge/_graphview.py +181 -0
  6. platformforge/_installkit.py +1706 -0
  7. platformforge/agents/__init__.py +156 -0
  8. platformforge/agents/bench.py +61 -0
  9. platformforge/agents/contextpack.py +137 -0
  10. platformforge/agents/contracts.py +394 -0
  11. platformforge/agents/coordinators.py +98 -0
  12. platformforge/agents/critic.py +113 -0
  13. platformforge/agents/debate.py +161 -0
  14. platformforge/agents/executors.py +160 -0
  15. platformforge/agents/guardian.py +48 -0
  16. platformforge/agents/mirrors.py +240 -0
  17. platformforge/agents/orchestrator.py +295 -0
  18. platformforge/agents/planner.py +116 -0
  19. platformforge/agents/playbook.py +51 -0
  20. platformforge/agents/referee.py +132 -0
  21. platformforge/agents/reviewers.py +113 -0
  22. platformforge/agents/roster.py +893 -0
  23. platformforge/agents/runledger.py +85 -0
  24. platformforge/agents/runstore.py +74 -0
  25. platformforge/agents/specialists.py +73 -0
  26. platformforge/agents/taskspec.py +94 -0
  27. platformforge/agents/uniqueness.py +115 -0
  28. platformforge/agents/verifier.py +106 -0
  29. platformforge/aiplat/__init__.py +4 -0
  30. platformforge/aiplat/models.py +175 -0
  31. platformforge/analytics/__init__.py +6 -0
  32. platformforge/analytics/capacity.py +109 -0
  33. platformforge/analytics/debt.py +92 -0
  34. platformforge/analytics/dx.py +69 -0
  35. platformforge/analytics/finops_v4.py +171 -0
  36. platformforge/analytics/goldenpath.py +131 -0
  37. platformforge/analytics/history.py +233 -0
  38. platformforge/analytics/measurement.py +149 -0
  39. platformforge/analytics/models.py +127 -0
  40. platformforge/analytics/opsanalytics.py +119 -0
  41. platformforge/analytics/policyintel.py +139 -0
  42. platformforge/analytics/reliability.py +106 -0
  43. platformforge/analytics/soak.py +135 -0
  44. platformforge/analytics/store.py +254 -0
  45. platformforge/bench.py +174 -0
  46. platformforge/cases/__init__.py +13 -0
  47. platformforge/cases/casefile.py +106 -0
  48. platformforge/cases/context_audit.py +134 -0
  49. platformforge/cases/ledgers.py +174 -0
  50. platformforge/cases/replay.py +142 -0
  51. platformforge/cases/routing_audit.py +162 -0
  52. platformforge/caveman/__init__.py +11 -0
  53. platformforge/caveman/compress.py +156 -0
  54. platformforge/cicd/__init__.py +16 -0
  55. platformforge/cicd/backstage.py +51 -0
  56. platformforge/cicd/dockerfile.py +64 -0
  57. platformforge/cicd/github_actions.py +122 -0
  58. platformforge/cicd/gitlab_ci.py +56 -0
  59. platformforge/cicd/gitops.py +130 -0
  60. platformforge/cli/__init__.py +0 -0
  61. platformforge/cli/fleetcmd.py +457 -0
  62. platformforge/cli/main.py +3832 -0
  63. platformforge/cloud/__init__.py +8 -0
  64. platformforge/cloud/aws.py +254 -0
  65. platformforge/cloud/common.py +69 -0
  66. platformforge/cloud/multicloud.py +143 -0
  67. platformforge/collect/__init__.py +11 -0
  68. platformforge/collect/detect.py +254 -0
  69. platformforge/context/__init__.py +10 -0
  70. platformforge/context/capsule.py +60 -0
  71. platformforge/context/gateway.py +227 -0
  72. platformforge/context/refs.py +49 -0
  73. platformforge/context/sufficiency.py +50 -0
  74. platformforge/core/__init__.py +1 -0
  75. platformforge/core/hashing.py +39 -0
  76. platformforge/core/ownership.py +220 -0
  77. platformforge/core/receipts.py +74 -0
  78. platformforge/core/redaction.py +97 -0
  79. platformforge/core/store.py +119 -0
  80. platformforge/core/workspace.py +89 -0
  81. platformforge/data/contracts/agent-handoff.schema.json +26 -0
  82. platformforge/data/contracts/agent-run.schema.json +27 -0
  83. platformforge/data/contracts/agent-spec.schema.json +43 -0
  84. platformforge/data/contracts/capability.schema.json +23 -0
  85. platformforge/data/contracts/change-event.schema.json +22 -0
  86. platformforge/data/contracts/change-intent.schema.json +62 -0
  87. platformforge/data/contracts/change-plan.schema.json +43 -0
  88. platformforge/data/contracts/collector-receipt.schema.json +36 -0
  89. platformforge/data/contracts/context-pack.schema.json +29 -0
  90. platformforge/data/contracts/debate.schema.json +31 -0
  91. platformforge/data/contracts/distribution-manifest-v1.schema.json +81 -0
  92. platformforge/data/contracts/drift-event.schema.json +21 -0
  93. platformforge/data/contracts/fact.schema.json +35 -0
  94. platformforge/data/contracts/finding.schema.json +20 -0
  95. platformforge/data/contracts/graph-v2.schema.json +67 -0
  96. platformforge/data/contracts/graph.schema.json +40 -0
  97. platformforge/data/contracts/install-receipt-v1.schema.json +49 -0
  98. platformforge/data/contracts/observation.schema.json +93 -0
  99. platformforge/data/contracts/receipt.schema.json +21 -0
  100. platformforge/data/contracts/recommendation.schema.json +32 -0
  101. platformforge/data/contracts/refusal.schema.json +16 -0
  102. platformforge/data/contracts/slo-contract.schema.json +25 -0
  103. platformforge/data/contracts/task-spec.schema.json +37 -0
  104. platformforge/data/contracts/workspace-manifest-v1.schema.json +62 -0
  105. platformforge/data/evals/cases/agent-budget-exhaustion/case.yaml +6 -0
  106. platformforge/data/evals/cases/agent-conflicting-specialists/case.yaml +11 -0
  107. platformforge/data/evals/cases/agent-fleet-optimization/case.yaml +13 -0
  108. platformforge/data/evals/cases/agent-host-no-subagents/case.yaml +6 -0
  109. platformforge/data/evals/cases/agent-incident-multi/case.yaml +13 -0
  110. platformforge/data/evals/cases/agent-missing-evidence/case.yaml +6 -0
  111. platformforge/data/evals/cases/agent-multi-domain-audit/case.yaml +14 -0
  112. platformforge/data/evals/cases/agent-production-change/case.yaml +15 -0
  113. platformforge/data/evals/cases/agent-security-mandatory/case.yaml +12 -0
  114. platformforge/data/evals/cases/agent-simple-iac/case.yaml +14 -0
  115. platformforge/data/evals/cases/approval-integrity-seal/case.yaml +6 -0
  116. platformforge/data/evals/cases/capacity-hot/case.yaml +7 -0
  117. platformforge/data/evals/cases/capacity-hot/fixture/capacity.json +5 -0
  118. platformforge/data/evals/cases/catalog-noowner/case.yaml +7 -0
  119. platformforge/data/evals/cases/catalog-noowner/fixture/catalog-info.yaml +6 -0
  120. platformforge/data/evals/cases/contract-fact/case.yaml +5 -0
  121. platformforge/data/evals/cases/contract-fact/fixture/doc.json +2 -0
  122. platformforge/data/evals/cases/cosign-unverified/case.yaml +8 -0
  123. platformforge/data/evals/cases/cosign-unverified/fixture/sig.json +33 -0
  124. platformforge/data/evals/cases/expected-delta-refusal/case.yaml +6 -0
  125. platformforge/data/evals/cases/finops-noattr/case.yaml +9 -0
  126. platformforge/data/evals/cases/finops-noattr/fixture/costs.json +7 -0
  127. platformforge/data/evals/cases/fleet-inv-absent-allocation-unknown/case.yaml +7 -0
  128. platformforge/data/evals/cases/fleet-inv-centrality-not-criticality/case.yaml +7 -0
  129. platformforge/data/evals/cases/fleet-inv-correlation-not-causality/case.yaml +7 -0
  130. platformforge/data/evals/cases/fleet-inv-dx-no-person-metrics/case.yaml +7 -0
  131. platformforge/data/evals/cases/fleet-inv-federation-restricted-denied/case.yaml +7 -0
  132. platformforge/data/evals/cases/fleet-inv-federation-secret-denied/case.yaml +7 -0
  133. platformforge/data/evals/cases/fleet-inv-history-window-deterministic/case.yaml +7 -0
  134. platformforge/data/evals/cases/fleet-inv-low-util-not-deletable/case.yaml +7 -0
  135. platformforge/data/evals/cases/fleet-inv-member-key-deterministic/case.yaml +7 -0
  136. platformforge/data/evals/cases/fleet-inv-missing-denominator/case.yaml +7 -0
  137. platformforge/data/evals/cases/fleet-inv-partial-coverage-visible/case.yaml +7 -0
  138. platformforge/data/evals/cases/fleet-inv-plan-changeintent-only/case.yaml +7 -0
  139. platformforge/data/evals/cases/fleet-inv-report-all-cited/case.yaml +7 -0
  140. platformforge/data/evals/cases/fleet-inv-small-sample-confidence/case.yaml +7 -0
  141. platformforge/data/evals/cases/fleet-inv-uncertain-optimization-suppressed/case.yaml +7 -0
  142. platformforge/data/evals/cases/gha-dangerous/case.yaml +12 -0
  143. platformforge/data/evals/cases/gha-dangerous/fixture/.github/workflows/ci.yaml +11 -0
  144. platformforge/data/evals/cases/gitops-v2/case.yaml +11 -0
  145. platformforge/data/evals/cases/gitops-v2/fixture/argo.yaml +41 -0
  146. platformforge/data/evals/cases/graph-k8s-blast/case.yaml +7 -0
  147. platformforge/data/evals/cases/graph-k8s-blast/fixture/deploy.yaml +15 -0
  148. platformforge/data/evals/cases/iac-module-nopin/case.yaml +8 -0
  149. platformforge/data/evals/cases/iac-module-nopin/fixture/main.tf +3 -0
  150. platformforge/data/evals/cases/iac-plan-replace/case.yaml +8 -0
  151. platformforge/data/evals/cases/iac-plan-replace/fixture/plan.json +12 -0
  152. platformforge/data/evals/cases/iac-sg-open/case.yaml +8 -0
  153. platformforge/data/evals/cases/iac-sg-open/fixture/main.tf +9 -0
  154. platformforge/data/evals/cases/iam-oidc/case.yaml +8 -0
  155. platformforge/data/evals/cases/iam-oidc/fixture/policy.json +11 -0
  156. platformforge/data/evals/cases/iam-wildcard-positive/case.yaml +8 -0
  157. platformforge/data/evals/cases/iam-wildcard-positive/fixture/policy.json +3 -0
  158. platformforge/data/evals/cases/k8s-bad-actor/case.yaml +19 -0
  159. platformforge/data/evals/cases/k8s-bad-actor/fixture/app.yaml +97 -0
  160. platformforge/data/evals/cases/k8s-beta-api/case.yaml +8 -0
  161. platformforge/data/evals/cases/k8s-beta-api/fixture/cr.yaml +7 -0
  162. platformforge/data/evals/cases/k8s-dep-api-boundary/case.yaml +15 -0
  163. platformforge/data/evals/cases/k8s-dep-api-boundary/fixture/app.yaml +25 -0
  164. platformforge/data/evals/cases/k8s-dep-api-clean/case.yaml +12 -0
  165. platformforge/data/evals/cases/k8s-dep-api-clean/fixture/app.yaml +29 -0
  166. platformforge/data/evals/cases/k8s-dep-api-unknown/case.yaml +11 -0
  167. platformforge/data/evals/cases/k8s-dep-api-unknown/fixture/app.yaml +25 -0
  168. platformforge/data/evals/cases/k8s-dep-api-v120/case.yaml +12 -0
  169. platformforge/data/evals/cases/k8s-dep-api-v120/fixture/app.yaml +25 -0
  170. platformforge/data/evals/cases/k8s-dep-api-v129/case.yaml +9 -0
  171. platformforge/data/evals/cases/k8s-dep-api-v129/fixture/app.yaml +25 -0
  172. platformforge/data/evals/cases/k8s-hpa-noreq/case.yaml +8 -0
  173. platformforge/data/evals/cases/k8s-hpa-noreq/fixture/app.yaml +28 -0
  174. platformforge/data/evals/cases/k8s-limits-boundary/case.yaml +10 -0
  175. platformforge/data/evals/cases/k8s-limits-boundary/fixture/app.yaml +15 -0
  176. platformforge/data/evals/cases/k8s-limits-negative/case.yaml +8 -0
  177. platformforge/data/evals/cases/k8s-limits-negative/fixture/deploy.yaml +15 -0
  178. platformforge/data/evals/cases/k8s-limits-positive/case.yaml +8 -0
  179. platformforge/data/evals/cases/k8s-limits-positive/fixture/deploy.yaml +14 -0
  180. platformforge/data/evals/cases/k8s-nopdb/case.yaml +7 -0
  181. platformforge/data/evals/cases/k8s-nopdb/fixture/app.yaml +34 -0
  182. platformforge/data/evals/cases/knowledge-registry/case.yaml +10 -0
  183. platformforge/data/evals/cases/kyverno-dep/case.yaml +10 -0
  184. platformforge/data/evals/cases/kyverno-dep/fixture/pol.yaml +18 -0
  185. platformforge/data/evals/cases/kyverno-legacy/case.yaml +8 -0
  186. platformforge/data/evals/cases/kyverno-legacy/fixture/pol.yaml +7 -0
  187. platformforge/data/evals/cases/live-aws-collect/case.yaml +11 -0
  188. platformforge/data/evals/cases/live-aws-collect/fixture/aws/ec2_describe-vpcs.json +15 -0
  189. platformforge/data/evals/cases/live-aws-collect/fixture/aws/iam_list-users.denied.txt +1 -0
  190. platformforge/data/evals/cases/live-aws-collect/fixture/aws/sts_get-caller-identity.json +5 -0
  191. platformforge/data/evals/cases/live-drift/case.yaml +8 -0
  192. platformforge/data/evals/cases/live-drift/fixture/after.json +22 -0
  193. platformforge/data/evals/cases/live-drift/fixture/before.json +22 -0
  194. platformforge/data/evals/cases/live-incident/case.yaml +7 -0
  195. platformforge/data/evals/cases/live-incident/fixture/changes.json +8 -0
  196. platformforge/data/evals/cases/live-incident/fixture/incident.json +7 -0
  197. platformforge/data/evals/cases/live-k8s-collect/case.yaml +10 -0
  198. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_configmaps.denied.txt +1 -0
  199. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_deployments.apps.json +25 -0
  200. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_pods.json +17 -0
  201. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_secrets.json +13 -0
  202. platformforge/data/evals/cases/live-multicluster/case.yaml +7 -0
  203. platformforge/data/evals/cases/live-multicluster/fixture/clusters.json +6 -0
  204. platformforge/data/evals/cases/live-plan/case.yaml +7 -0
  205. platformforge/data/evals/cases/live-plan/fixture/drift.json +8 -0
  206. platformforge/data/evals/cases/live-reconcile/case.yaml +7 -0
  207. platformforge/data/evals/cases/live-reconcile/fixture/desired.json +7 -0
  208. platformforge/data/evals/cases/live-reconcile/fixture/observed.json +18 -0
  209. platformforge/data/evals/cases/live-secrets-boundary/case.yaml +8 -0
  210. platformforge/data/evals/cases/live-secrets-boundary/fixture/envelope.json +16 -0
  211. platformforge/data/evals/cases/live-temporal-graph/case.yaml +10 -0
  212. platformforge/data/evals/cases/live-temporal-graph/fixture/graph.json +22 -0
  213. platformforge/data/evals/cases/ops-approval/case.yaml +6 -0
  214. platformforge/data/evals/cases/ops-convergence/case.yaml +6 -0
  215. platformforge/data/evals/cases/ops-execution-safety/case.yaml +6 -0
  216. platformforge/data/evals/cases/ops-intent/case.yaml +6 -0
  217. platformforge/data/evals/cases/ops-planning/case.yaml +6 -0
  218. platformforge/data/evals/cases/ops-policy/case.yaml +6 -0
  219. platformforge/data/evals/cases/ops-rollback/case.yaml +6 -0
  220. platformforge/data/evals/cases/ops-verification/case.yaml +6 -0
  221. platformforge/data/evals/cases/rollback-argocd-revision/case.yaml +6 -0
  222. platformforge/data/evals/cases/rollback-blind-inverse/case.yaml +6 -0
  223. platformforge/data/evals/cases/rollback-git-commit/case.yaml +6 -0
  224. platformforge/data/evals/cases/rollback-impossible/case.yaml +6 -0
  225. platformforge/data/evals/cases/rollback-k8s-annotation-restore/case.yaml +6 -0
  226. platformforge/data/evals/cases/rollback-k8s-scale/case.yaml +6 -0
  227. platformforge/data/evals/cases/rollback-material-tamper/case.yaml +6 -0
  228. platformforge/data/evals/cases/rollback-terraform-replan/case.yaml +6 -0
  229. platformforge/data/evals/cases/rollback-unknown/case.yaml +6 -0
  230. platformforge/data/evals/cases/routing-specialist/case.yaml +9 -0
  231. platformforge/data/evals/cases/rtk-roundtrip/case.yaml +5 -0
  232. platformforge/data/evals/cases/rtk-roundtrip/fixture/verbose.txt +300 -0
  233. platformforge/data/evals/cases/sbom-vuln/case.yaml +9 -0
  234. platformforge/data/evals/cases/sbom-vuln/fixture/sbom.json +9 -0
  235. platformforge/data/evals/cases/secret-prose-mixed/fixture/app.env +1 -0
  236. platformforge/data/evals/cases/secret-prose-mixed/fixture/policy-notes.md +7 -0
  237. platformforge/data/evals/cases/secrets-noleak/case.yaml +5 -0
  238. platformforge/data/evals/cases/secrets-noleak/fixture/app.env +2 -0
  239. platformforge/data/evals/cases/slo-fastburn/case.yaml +8 -0
  240. platformforge/data/evals/cases/slo-fastburn/fixture/events.yaml +2 -0
  241. platformforge/data/evals/cases/slo-fastburn/fixture/slo.yaml +4 -0
  242. platformforge/data/evals/cases/slo-noinput/case.yaml +8 -0
  243. platformforge/data/evals/cases/slo-noinput/fixture/events.yaml +1 -0
  244. platformforge/data/evals/cases/slo-noinput/fixture/slo.yaml +4 -0
  245. platformforge/data/evals/cases/sot-conflict/case.yaml +6 -0
  246. platformforge/data/evals/cases/token-budget/case.yaml +6 -0
  247. platformforge/data/evals/cases/token-budget/fixture/big.py +50 -0
  248. platformforge/data/evals/cases/xr-delete/case.yaml +8 -0
  249. platformforge/data/evals/cases/xr-delete/fixture/xr.yaml +10 -0
  250. platformforge/data/evals/cases/xr-graph-chain/case.yaml +8 -0
  251. platformforge/data/evals/cases/xr-graph-chain/fixture/xrd.yaml +90 -0
  252. platformforge/data/evals/cases/xr-negative-crd/case.yaml +8 -0
  253. platformforge/data/evals/cases/xr-negative-crd/fixture/widget.yaml +19 -0
  254. platformforge/data/evals/cases/xr-upbound-mr/case.yaml +8 -0
  255. platformforge/data/evals/cases/xr-upbound-mr/fixture/bucket.yaml +12 -0
  256. platformforge/data/evals/qpt-bench/change-review/case.yaml +5 -0
  257. platformforge/data/evals/qpt-bench/change-review/fixture/plan.json +9 -0
  258. platformforge/data/evals/qpt-bench/gha-security/case.yaml +5 -0
  259. platformforge/data/evals/qpt-bench/gha-security/fixture/ci.yml +10 -0
  260. platformforge/data/evals/qpt-bench/iam-investigate/case.yaml +5 -0
  261. platformforge/data/evals/qpt-bench/iam-investigate/fixture/policy.json +6 -0
  262. platformforge/data/evals/qpt-bench/incident-analysis/case.yaml +5 -0
  263. platformforge/data/evals/qpt-bench/incident-analysis/fixture/query.json +5 -0
  264. platformforge/data/evals/qpt-bench/incident-analysis/fixture/rules.yaml +6 -0
  265. platformforge/data/evals/qpt-bench/k8s-review/case.yaml +6 -0
  266. platformforge/data/evals/qpt-bench/k8s-review/fixture/deploy.yaml +15 -0
  267. platformforge/data/evals/qpt-bench/tf-security/case.yaml +5 -0
  268. platformforge/data/evals/qpt-bench/tf-security/fixture/main.tf +17 -0
  269. platformforge/data/knowledge/aws/eks.yaml +20 -0
  270. platformforge/data/knowledge/aws/iam.yaml +28 -0
  271. platformforge/data/knowledge/aws/network-exposure.yaml +19 -0
  272. platformforge/data/knowledge/aws/organizations.yaml +19 -0
  273. platformforge/data/knowledge/crossplane/composition-providers.yaml +20 -0
  274. platformforge/data/knowledge/crossplane/managed-resources.yaml +26 -0
  275. platformforge/data/knowledge/crossplane/v1-v2.yaml +25 -0
  276. platformforge/data/knowledge/kubernetes/api-deprecations.yaml +33 -0
  277. platformforge/data/knowledge/kubernetes/autoscaling-rbac.yaml +27 -0
  278. platformforge/data/knowledge/kubernetes/gateway-api.yaml +20 -0
  279. platformforge/data/knowledge/kubernetes/pod-security.yaml +26 -0
  280. platformforge/data/knowledge/sources.yaml +516 -0
  281. platformforge/data/knowledge/terraform/lifecycle.yaml +18 -0
  282. platformforge/data/knowledge/terraform/plan-semantics.yaml +26 -0
  283. platformforge/data/lab/chaos-pod-kill/chaos.yaml +3 -0
  284. platformforge/data/lab/chaos-pod-kill/facts.json +13 -0
  285. platformforge/data/lab/fleets/acme/capacity.yaml +35 -0
  286. platformforge/data/lab/fleets/acme/costs.yaml +25 -0
  287. platformforge/data/lab/fleets/acme/events.yaml +41 -0
  288. platformforge/data/lab/fleets/acme/fleet.yaml +51 -0
  289. platformforge/data/lab/fleets/acme/graph.yaml +45 -0
  290. platformforge/data/lab/fleets/acme/policies.yaml +28 -0
  291. platformforge/data/lab/fleets/acme/requests.yaml +24 -0
  292. platformforge/data/lab/scenarios/argo-bad-rollout/expected.yaml +6 -0
  293. platformforge/data/lab/scenarios/argo-bad-rollout/fixture/app.yaml +9 -0
  294. platformforge/data/lab/scenarios/aws-dump/expected.yaml +19 -0
  295. platformforge/data/lab/scenarios/aws-dump/fixture/compute.json +4 -0
  296. platformforge/data/lab/scenarios/aws-dump/fixture/iam.json +7 -0
  297. platformforge/data/lab/scenarios/aws-dump/fixture/network.json +6 -0
  298. platformforge/data/lab/scenarios/aws-dump/fixture/org.json +4 -0
  299. platformforge/data/lab/scenarios/aws-dump/fixture/storage.json +4 -0
  300. platformforge/data/lab/scenarios/container-unsigned/expected.yaml +5 -0
  301. platformforge/data/lab/scenarios/container-unsigned/fixture/supply.json +11 -0
  302. platformforge/data/lab/scenarios/cost-idle-workload/expected.yaml +5 -0
  303. platformforge/data/lab/scenarios/cost-idle-workload/fixture/costs.json +12 -0
  304. platformforge/data/lab/scenarios/crossplane-platform/expected.yaml +16 -0
  305. platformforge/data/lab/scenarios/crossplane-platform/fixture/negative.yaml +18 -0
  306. platformforge/data/lab/scenarios/crossplane-platform/fixture/xrd.yaml +90 -0
  307. platformforge/data/lab/scenarios/demo-platform/expected.yaml +16 -0
  308. platformforge/data/lab/scenarios/demo-platform/fixture/.github/workflows/ci.yml +10 -0
  309. platformforge/data/lab/scenarios/demo-platform/fixture/deploy.yaml +16 -0
  310. platformforge/data/lab/scenarios/demo-platform/fixture/infra.tf +5 -0
  311. platformforge/data/lab/scenarios/fleet-ai/expected.yaml +12 -0
  312. platformforge/data/lab/scenarios/fleet-capacity/expected.yaml +12 -0
  313. platformforge/data/lab/scenarios/fleet-cost/expected.yaml +18 -0
  314. platformforge/data/lab/scenarios/fleet-federation/expected.yaml +11 -0
  315. platformforge/data/lab/scenarios/fleet-golden-path/expected.yaml +18 -0
  316. platformforge/data/lab/scenarios/fleet-op-hotspot/expected.yaml +11 -0
  317. platformforge/data/lab/scenarios/fleet-ownership/expected.yaml +17 -0
  318. platformforge/data/lab/scenarios/fleet-partial/expected.yaml +10 -0
  319. platformforge/data/lab/scenarios/fleet-policy-friction/expected.yaml +10 -0
  320. platformforge/data/lab/scenarios/fleet-recurring-incident/expected.yaml +12 -0
  321. platformforge/data/lab/scenarios/fleet-report/expected.yaml +13 -0
  322. platformforge/data/lab/scenarios/fleet-risk/expected.yaml +18 -0
  323. platformforge/data/lab/scenarios/gitops-drift/expected.yaml +6 -0
  324. platformforge/data/lab/scenarios/gitops-drift/fixture/app.yaml +12 -0
  325. platformforge/data/lab/scenarios/k8s-bad-readiness/expected.yaml +6 -0
  326. platformforge/data/lab/scenarios/k8s-bad-readiness/fixture/deploy.yaml +23 -0
  327. platformforge/data/lab/scenarios/k8s-bad-readiness/fixture/netpol.yaml +9 -0
  328. platformforge/data/lab/scenarios/k8s-missing-limits/expected.yaml +6 -0
  329. platformforge/data/lab/scenarios/k8s-missing-limits/fixture/deploy.yaml +26 -0
  330. platformforge/data/lab/scenarios/k8s-missing-limits/fixture/netpol.yaml +9 -0
  331. platformforge/data/lab/scenarios/k8s-single-replica/expected.yaml +5 -0
  332. platformforge/data/lab/scenarios/k8s-single-replica/fixture/deploy.yaml +26 -0
  333. platformforge/data/lab/scenarios/k8s-single-replica/fixture/netpol.yaml +4 -0
  334. platformforge/data/lab/scenarios/live-aws-collect/expected.yaml +4 -0
  335. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/ec2_describe-vpcs.json +15 -0
  336. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/iam_list-users.denied.txt +1 -0
  337. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/sts_get-caller-identity.json +5 -0
  338. platformforge/data/lab/scenarios/live-k8s-collect/expected.yaml +4 -0
  339. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_configmaps.denied.txt +1 -0
  340. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_deployments.apps.json +25 -0
  341. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_pods.json +17 -0
  342. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_secrets.json +13 -0
  343. platformforge/data/lab/scenarios/live-kind/expected.yaml +14 -0
  344. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_configmaps.denied.txt +1 -0
  345. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_deployments.apps.json +25 -0
  346. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_pods.json +17 -0
  347. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_secrets.json +13 -0
  348. platformforge/data/lab/scenarios/network-policy-missing/expected.yaml +6 -0
  349. platformforge/data/lab/scenarios/network-policy-missing/fixture/deploy.yaml +26 -0
  350. platformforge/data/lab/scenarios/ops-bad-rollout/expected.yaml +3 -0
  351. platformforge/data/lab/scenarios/ops-bad-rollout/fixture/ops.yaml +33 -0
  352. platformforge/data/lab/scenarios/ops-break-glass/expected.yaml +3 -0
  353. platformforge/data/lab/scenarios/ops-break-glass/fixture/ops.yaml +21 -0
  354. platformforge/data/lab/scenarios/ops-broken-selector/expected.yaml +3 -0
  355. platformforge/data/lab/scenarios/ops-broken-selector/fixture/ops.yaml +21 -0
  356. platformforge/data/lab/scenarios/ops-changed-plan-hash/expected.yaml +3 -0
  357. platformforge/data/lab/scenarios/ops-changed-plan-hash/fixture/ops.yaml +20 -0
  358. platformforge/data/lab/scenarios/ops-conflicting-operation/expected.yaml +3 -0
  359. platformforge/data/lab/scenarios/ops-conflicting-operation/fixture/ops.yaml +21 -0
  360. platformforge/data/lab/scenarios/ops-expired-approval/expected.yaml +3 -0
  361. platformforge/data/lab/scenarios/ops-expired-approval/fixture/ops.yaml +20 -0
  362. platformforge/data/lab/scenarios/ops-partial-failure/expected.yaml +3 -0
  363. platformforge/data/lab/scenarios/ops-partial-failure/fixture/ops.yaml +24 -0
  364. platformforge/data/lab/scenarios/ops-policy-denial/expected.yaml +3 -0
  365. platformforge/data/lab/scenarios/ops-policy-denial/fixture/ops.yaml +17 -0
  366. platformforge/data/lab/scenarios/ops-replica-drift/expected.yaml +3 -0
  367. platformforge/data/lab/scenarios/ops-replica-drift/fixture/ops.yaml +28 -0
  368. platformforge/data/lab/scenarios/ops-rollback-annotate/expected.yaml +3 -0
  369. platformforge/data/lab/scenarios/ops-rollback-annotate/fixture/ops.yaml +41 -0
  370. platformforge/data/lab/scenarios/ops-rollback-gitops/expected.yaml +3 -0
  371. platformforge/data/lab/scenarios/ops-rollback-gitops/fixture/ops.yaml +38 -0
  372. platformforge/data/lab/scenarios/ops-rollback-impossible/expected.yaml +3 -0
  373. platformforge/data/lab/scenarios/ops-rollback-impossible/fixture/ops.yaml +34 -0
  374. platformforge/data/lab/scenarios/ops-rollback-scale/expected.yaml +3 -0
  375. platformforge/data/lab/scenarios/ops-rollback-scale/fixture/ops.yaml +40 -0
  376. platformforge/data/lab/scenarios/ops-rollback-terraform/expected.yaml +3 -0
  377. platformforge/data/lab/scenarios/ops-rollback-terraform/fixture/ops.yaml +38 -0
  378. platformforge/data/lab/scenarios/ops-rollback-terraform/fixture/tf/tfplan.bin +1 -0
  379. platformforge/data/lab/scenarios/ops-stale-observation/expected.yaml +3 -0
  380. platformforge/data/lab/scenarios/ops-stale-observation/fixture/ops.yaml +20 -0
  381. platformforge/data/lab/scenarios/secret-exposure/expected.yaml +5 -0
  382. platformforge/data/lab/scenarios/secret-exposure/fixture/app.env +2 -0
  383. platformforge/data/lab/scenarios/slo-burn/expected.yaml +5 -0
  384. platformforge/data/lab/scenarios/slo-burn/fixture/events.yaml +2 -0
  385. platformforge/data/lab/scenarios/slo-burn/fixture/slo.yaml +4 -0
  386. platformforge/data/lab/scenarios/terraform-iam-wildcard/expected.yaml +5 -0
  387. platformforge/data/lab/scenarios/terraform-iam-wildcard/fixture/policy.json +14 -0
  388. platformforge/data/lab/scenarios/terraform-public-bucket/expected.yaml +6 -0
  389. platformforge/data/lab/scenarios/terraform-public-bucket/fixture/main.tf +5 -0
  390. platformforge/data/portable_skills/platformforge-agents/SKILL.md +47 -0
  391. platformforge/data/portable_skills/platformforge-change/SKILL.md +41 -0
  392. platformforge/data/portable_skills/platformforge-core/SKILL.md +62 -0
  393. platformforge/data/portable_skills/platformforge-economy/SKILL.md +42 -0
  394. platformforge/data/portable_skills/platformforge-finops/SKILL.md +34 -0
  395. platformforge/data/portable_skills/platformforge-fleet/SKILL.md +39 -0
  396. platformforge/data/portable_skills/platformforge-governance/SKILL.md +76 -0
  397. platformforge/data/portable_skills/platformforge-graph/SKILL.md +46 -0
  398. platformforge/data/portable_skills/platformforge-lab/SKILL.md +39 -0
  399. platformforge/data/portable_skills/platformforge-sdd/SKILL.md +39 -0
  400. platformforge/data/portable_skills/platformforge-security/SKILL.md +38 -0
  401. platformforge/data/portable_skills/platformforge-sre/SKILL.md +41 -0
  402. platformforge/data/rules/catalog/finops.yaml +26 -0
  403. platformforge/data/rules/catalog/gitops.yaml +161 -0
  404. platformforge/data/rules/catalog/iac.yaml +84 -0
  405. platformforge/data/rules/catalog/k8s.yaml +322 -0
  406. platformforge/data/rules/catalog/orchestration.yaml +99 -0
  407. platformforge/data/rules/catalog/product.yaml +42 -0
  408. platformforge/data/rules/catalog/routing.yaml +122 -0
  409. platformforge/data/rules/catalog/security.yaml +159 -0
  410. platformforge/data/rules/catalog/sre.yaml +67 -0
  411. platformforge/diagnose/__init__.py +75 -0
  412. platformforge/distribution/__init__.py +18 -0
  413. platformforge/distribution/bundle.py +127 -0
  414. platformforge/distribution/hostflow.py +247 -0
  415. platformforge/distribution/models.py +80 -0
  416. platformforge/distribution/service.py +225 -0
  417. platformforge/economy/__init__.py +27 -0
  418. platformforge/economy/bench.py +66 -0
  419. platformforge/economy/budget.py +198 -0
  420. platformforge/economy/cache.py +287 -0
  421. platformforge/economy/checkpoint.py +112 -0
  422. platformforge/economy/collection.py +142 -0
  423. platformforge/economy/doctor.py +207 -0
  424. platformforge/economy/engine.py +158 -0
  425. platformforge/economy/envelope.py +61 -0
  426. platformforge/economy/fleetpack.py +86 -0
  427. platformforge/economy/ledger.py +232 -0
  428. platformforge/economy/plan.py +131 -0
  429. platformforge/economy/pricing.py +101 -0
  430. platformforge/economy/qpt.py +286 -0
  431. platformforge/economy/reconcile.py +86 -0
  432. platformforge/economy/verifyplan.py +107 -0
  433. platformforge/economy/waste.py +123 -0
  434. platformforge/evals/__init__.py +16 -0
  435. platformforge/evals/coverage.py +140 -0
  436. platformforge/evals/fleet_invariants.py +260 -0
  437. platformforge/evals/ops_invariants.py +158 -0
  438. platformforge/evals/runner.py +541 -0
  439. platformforge/federation/__init__.py +4 -0
  440. platformforge/federation/node.py +135 -0
  441. platformforge/finops/__init__.py +14 -0
  442. platformforge/finops/alloc.py +31 -0
  443. platformforge/finops/costs.py +79 -0
  444. platformforge/finops/focus.py +100 -0
  445. platformforge/finops/graph_cost.py +51 -0
  446. platformforge/finops/ingest.py +144 -0
  447. platformforge/finops/insights.py +193 -0
  448. platformforge/finops/unit.py +36 -0
  449. platformforge/fleet/__init__.py +5 -0
  450. platformforge/fleet/loader.py +73 -0
  451. platformforge/fleet/models.py +155 -0
  452. platformforge/fleet/orggraph.py +172 -0
  453. platformforge/fleet/query.py +154 -0
  454. platformforge/forge/__init__.py +9 -0
  455. platformforge/forge/collect.py +88 -0
  456. platformforge/forge/delegate.py +77 -0
  457. platformforge/forge/manifest.py +106 -0
  458. platformforge/freeze/__init__.py +22 -0
  459. platformforge/freeze/exceptions.py +56 -0
  460. platformforge/freeze/manifest.py +167 -0
  461. platformforge/freeze/snapshots.py +108 -0
  462. platformforge/graph/__init__.py +39 -0
  463. platformforge/graph/backend.py +174 -0
  464. platformforge/graph/bench.py +245 -0
  465. platformforge/graph/build.py +130 -0
  466. platformforge/graph/delivery.py +183 -0
  467. platformforge/graph/diff.py +292 -0
  468. platformforge/graph/events.py +116 -0
  469. platformforge/graph/migrate.py +44 -0
  470. platformforge/graph/model.py +264 -0
  471. platformforge/graph/persist.py +97 -0
  472. platformforge/graph/query.py +290 -0
  473. platformforge/graph/temporal.py +78 -0
  474. platformforge/graph/vocab.py +63 -0
  475. platformforge/graphview.py +94 -0
  476. platformforge/iac/__init__.py +7 -0
  477. platformforge/iac/drift.py +53 -0
  478. platformforge/iac/plan.py +84 -0
  479. platformforge/iac/terraform.py +123 -0
  480. platformforge/install/__init__.py +1 -0
  481. platformforge/install/service.py +421 -0
  482. platformforge/k8s/__init__.py +8 -0
  483. platformforge/k8s/helm.py +152 -0
  484. platformforge/k8s/hubble.py +87 -0
  485. platformforge/k8s/manifests.py +513 -0
  486. platformforge/knowledge/__init__.py +1 -0
  487. platformforge/knowledge/packs.py +157 -0
  488. platformforge/knowledge/registry.py +249 -0
  489. platformforge/knowledge/select.py +127 -0
  490. platformforge/lab/__init__.py +5 -0
  491. platformforge/lab/chaos.py +71 -0
  492. platformforge/lab/fleetlab.py +351 -0
  493. platformforge/lab/opslab.py +271 -0
  494. platformforge/lab/runner.py +219 -0
  495. platformforge/live/__init__.py +36 -0
  496. platformforge/live/budget.py +92 -0
  497. platformforge/live/capability.py +80 -0
  498. platformforge/live/collectors/__init__.py +6 -0
  499. platformforge/live/collectors/aws.py +370 -0
  500. platformforge/live/collectors/aws_transport.py +98 -0
  501. platformforge/live/collectors/k8s.py +389 -0
  502. platformforge/live/collectors/k8s_transport.py +85 -0
  503. platformforge/live/cursors.py +48 -0
  504. platformforge/live/drift.py +130 -0
  505. platformforge/live/envelope.py +85 -0
  506. platformforge/live/federation.py +113 -0
  507. platformforge/live/fixtures.py +168 -0
  508. platformforge/live/identity.py +258 -0
  509. platformforge/live/incident.py +200 -0
  510. platformforge/live/models.py +435 -0
  511. platformforge/live/reconcile.py +382 -0
  512. platformforge/live/remediate.py +169 -0
  513. platformforge/live/store.py +256 -0
  514. platformforge/live/topology.py +249 -0
  515. platformforge/mcp/__init__.py +7 -0
  516. platformforge/mcp/parity.py +69 -0
  517. platformforge/mcp/registry.py +427 -0
  518. platformforge/mcp/server.py +84 -0
  519. platformforge/mcp/tools.py +732 -0
  520. platformforge/models/__init__.py +21 -0
  521. platformforge/models/base.py +214 -0
  522. platformforge/observe/__init__.py +26 -0
  523. platformforge/observe/capacity.py +39 -0
  524. platformforge/observe/dr.py +67 -0
  525. platformforge/observe/grafana.py +76 -0
  526. platformforge/observe/incident.py +58 -0
  527. platformforge/observe/otel.py +135 -0
  528. platformforge/observe/postmortem.py +72 -0
  529. platformforge/observe/prometheus.py +88 -0
  530. platformforge/observe/slo.py +125 -0
  531. platformforge/ops/__init__.py +8 -0
  532. platformforge/ops/actions.py +215 -0
  533. platformforge/ops/analytics.py +83 -0
  534. platformforge/ops/approval.py +275 -0
  535. platformforge/ops/autorem.py +101 -0
  536. platformforge/ops/config.py +175 -0
  537. platformforge/ops/engine.py +758 -0
  538. platformforge/ops/envelope.py +135 -0
  539. platformforge/ops/executors/__init__.py +1 -0
  540. platformforge/ops/executors/base.py +211 -0
  541. platformforge/ops/executors/git.py +77 -0
  542. platformforge/ops/executors/gitops.py +134 -0
  543. platformforge/ops/executors/terraform.py +84 -0
  544. platformforge/ops/gates.py +135 -0
  545. platformforge/ops/goldenpath.py +180 -0
  546. platformforge/ops/material.py +181 -0
  547. platformforge/ops/models.py +421 -0
  548. platformforge/ops/operation.py +335 -0
  549. platformforge/ops/opgraph.py +233 -0
  550. platformforge/ops/policy.py +275 -0
  551. platformforge/ops/preconditions.py +109 -0
  552. platformforge/ops/registry.py +101 -0
  553. platformforge/ops/risk.py +216 -0
  554. platformforge/ops/rollback.py +287 -0
  555. platformforge/ops/rollback_builders.py +163 -0
  556. platformforge/ops/runbook.py +269 -0
  557. platformforge/ops/simulate.py +125 -0
  558. platformforge/ops/source_of_truth.py +198 -0
  559. platformforge/ops/store.py +157 -0
  560. platformforge/ops/verify.py +190 -0
  561. platformforge/optimize/__init__.py +4 -0
  562. platformforge/optimize/engine.py +109 -0
  563. platformforge/optimize/models.py +68 -0
  564. platformforge/optimize/scan.py +196 -0
  565. platformforge/plan/__init__.py +107 -0
  566. platformforge/portable_cli.py +199 -0
  567. platformforge/portal_adapter.py +740 -0
  568. platformforge/portal_assets/chunk-7_l-b4Tr.js +1 -0
  569. platformforge/portal_assets/chunk-B0Iad6pD.js +1 -0
  570. platformforge/portal_assets/chunk-B605XnCF.js +1 -0
  571. platformforge/portal_assets/chunk-B7N3TXcn.js +1 -0
  572. platformforge/portal_assets/chunk-BAj6yGem.js +1 -0
  573. platformforge/portal_assets/chunk-BGUitn2r.js +1 -0
  574. platformforge/portal_assets/chunk-BVKnCv85.js +1 -0
  575. platformforge/portal_assets/chunk-BYyM9V_w.js +1 -0
  576. platformforge/portal_assets/chunk-BaQMhq-G.js +1 -0
  577. platformforge/portal_assets/chunk-BavOrxVm.js +1 -0
  578. platformforge/portal_assets/chunk-Bb0C24YV.js +1 -0
  579. platformforge/portal_assets/chunk-BfCFGPzs.js +1 -0
  580. platformforge/portal_assets/chunk-C2t4OUVu.js +1 -0
  581. platformforge/portal_assets/chunk-C3pI4QjU.js +1 -0
  582. platformforge/portal_assets/chunk-C4gpbtbP.js +1 -0
  583. platformforge/portal_assets/chunk-CGWeHhKY.js +1 -0
  584. platformforge/portal_assets/chunk-CJCT86Dr.js +1 -0
  585. platformforge/portal_assets/chunk-CXcsLDPJ.js +1 -0
  586. platformforge/portal_assets/chunk-CcjxoLxw.js +1 -0
  587. platformforge/portal_assets/chunk-D-gBPXHa.js +1 -0
  588. platformforge/portal_assets/chunk-D3eZogIe.js +1 -0
  589. platformforge/portal_assets/chunk-DQU0VyoQ.js +1 -0
  590. platformforge/portal_assets/chunk-DqWNkF1O.js +1 -0
  591. platformforge/portal_assets/chunk-DuPPkSIE.js +3 -0
  592. platformforge/portal_assets/chunk-DyIHOW_a.js +1 -0
  593. platformforge/portal_assets/chunk-Fx4zSGL5.js +1 -0
  594. platformforge/portal_assets/chunk-UBKt05Rg.js +1 -0
  595. platformforge/portal_assets/chunk-htnJDQEY.js +1 -0
  596. platformforge/portal_assets/chunk-kXRFMGqe.js +1 -0
  597. platformforge/portal_assets/chunk-qe3u763a.js +1 -0
  598. platformforge/portal_assets/favicon.ico +0 -0
  599. platformforge/portal_assets/index.html +13 -0
  600. platformforge/portal_assets/main-QDFVZK3T.js +5 -0
  601. platformforge/portal_assets/styles-MQEBDFDX.css +1 -0
  602. platformforge/product/__init__.py +10 -0
  603. platformforge/product/backstage.py +49 -0
  604. platformforge/product/catalog.py +74 -0
  605. platformforge/product/crossplane.py +341 -0
  606. platformforge/product/golden_paths/__init__.py +5 -0
  607. platformforge/product/golden_paths/engine.py +97 -0
  608. platformforge/product/golden_paths/library.yaml +198 -0
  609. platformforge/product/golden_paths/model.py +84 -0
  610. platformforge/product/maturity.py +107 -0
  611. platformforge/product/scorecards.py +84 -0
  612. platformforge/resources.py +36 -0
  613. platformforge/risk/__init__.py +5 -0
  614. platformforge/risk/engine.py +95 -0
  615. platformforge/risk/signals.py +49 -0
  616. platformforge/routing/__init__.py +13 -0
  617. platformforge/routing/decision.py +191 -0
  618. platformforge/routing/router.py +328 -0
  619. platformforge/rtk/__init__.py +9 -0
  620. platformforge/rtk/compact.py +127 -0
  621. platformforge/rtk/parsers.py +199 -0
  622. platformforge/rules/__init__.py +5 -0
  623. platformforge/rules/engine.py +345 -0
  624. platformforge/sandbox/__init__.py +7 -0
  625. platformforge/sandbox/env.py +151 -0
  626. platformforge/sandbox/review.py +92 -0
  627. platformforge/sdd/__init__.py +8 -0
  628. platformforge/sdd/lifecycle.py +239 -0
  629. platformforge/security/__init__.py +19 -0
  630. platformforge/security/cosign.py +67 -0
  631. platformforge/security/iam.py +112 -0
  632. platformforge/security/identity.py +84 -0
  633. platformforge/security/kyverno.py +106 -0
  634. platformforge/security/sbom.py +59 -0
  635. platformforge/security/scan.py +63 -0
  636. platformforge/security/slsa.py +86 -0
  637. platformforge/security/supply.py +62 -0
  638. platformforge/tokensave/__init__.py +20 -0
  639. platformforge/tokensave/budget.py +62 -0
  640. platformforge/tokensave/estimate.py +21 -0
  641. platformforge/tokensave/index.py +172 -0
  642. platformforge/tokensave/ledger.py +121 -0
  643. platformforge/tokensave/packs.py +285 -0
  644. platformforge/ui/__init__.py +28 -0
  645. platformforge/ui/app.py +465 -0
  646. platformforge/ui/home.py +113 -0
  647. platformforge/ui/i18n.py +143 -0
  648. platformforge/ui/kit.py +689 -0
  649. platformforge/ui/screen.py +628 -0
  650. platformforge/ui/tui.py +94 -0
  651. platformforge/ui/wizard.py +309 -0
  652. platformforge/workspace/__init__.py +13 -0
  653. platformforge/workspace/models.py +35 -0
  654. platformforge/workspace/service.py +69 -0
  655. platformforge-0.1.0.dist-info/METADATA +212 -0
  656. platformforge-0.1.0.dist-info/RECORD +659 -0
  657. platformforge-0.1.0.dist-info/WHEEL +4 -0
  658. platformforge-0.1.0.dist-info/entry_points.txt +3 -0
  659. 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("_")]