@polymorphism-tech/morph-spec 8.37.1 → 8.40.0

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 (317) hide show
  1. package/CLAUDE.md +37 -13
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +75 -49
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +6 -5
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +96 -36
  10. package/framework/docs/CLI.md +54 -17
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/mcp/registry.json +33 -2
  30. package/framework/rubrics/backend.json +2 -2
  31. package/framework/rubrics/default.json +2 -2
  32. package/framework/rubrics/frontend.json +2 -2
  33. package/framework/rules/csharp-standards.md +1 -0
  34. package/framework/rules/infrastructure-standards.md +0 -1
  35. package/framework/rules/morph-workflow.md +10 -4
  36. package/framework/rules/nextjs-standards.md +1 -0
  37. package/framework/schemas/config.schema.json +41 -1
  38. package/framework/schemas/feature.schema.json +5 -1
  39. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  40. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  41. package/framework/skills/core/morph-apply/SKILL.md +40 -11
  42. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  43. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  44. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  45. package/framework/skills/core/morph-implement/SKILL.md +44 -10
  46. package/framework/skills/core/morph-media/SKILL.md +1 -1
  47. package/framework/skills/core/morph-plan/SKILL.md +73 -15
  48. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  49. package/framework/skills/core/morph-review/SKILL.md +51 -13
  50. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  51. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  52. package/framework/skills/core/morph-uiux/SKILL.md +39 -13
  53. package/framework/standards/STANDARDS.json +189 -14
  54. package/framework/standards/ai-agents/context-providers.md +16 -26
  55. package/framework/standards/ai-agents/durable-workflows-hitl.md +15 -21
  56. package/framework/standards/ai-agents/modalities-image-gen.md +21 -25
  57. package/framework/standards/ai-agents/modalities-speech-to-text.md +4 -15
  58. package/framework/standards/ai-agents/modalities-text-to-speech.md +2 -11
  59. package/framework/standards/ai-agents/modalities-vision.md +1 -18
  60. package/framework/standards/ai-agents/prompt-sources.md +0 -4
  61. package/framework/standards/ai-agents/testing-ai.md +2 -1
  62. package/framework/standards/architecture/code-comments.md +100 -0
  63. package/framework/standards/backend/dotnet/testing.md +22 -0
  64. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +63 -50
  65. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  66. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  67. package/framework/standards/frontend/admin-app/forms.md +232 -0
  68. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  69. package/framework/standards/frontend/design-system/ai-image-generation.md +5 -5
  70. package/framework/standards/frontend/nextjs/components.md +37 -58
  71. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  72. package/framework/standards/frontend/nextjs/forms.md +31 -30
  73. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  74. package/framework/standards/frontend/nextjs/testing.md +18 -0
  75. package/framework/standards/integration/mcp/mcp-tools.md +48 -117
  76. package/framework/templates/code/dotnet/abstractions/result.cs +0 -2
  77. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +26 -39
  78. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +28 -68
  79. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +99 -117
  80. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +2 -44
  81. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +40 -61
  82. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +54 -107
  83. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +53 -58
  84. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +192 -281
  85. package/framework/templates/code/dotnet/database/migration.cs +49 -83
  86. package/framework/templates/code/dotnet/database/vector-entity.cs +2 -46
  87. package/framework/templates/code/dotnet/extensions/result-extensions.cs +0 -2
  88. package/framework/templates/code/dotnet/feature/validator.cs.hbs +11 -15
  89. package/framework/templates/code/dotnet/jobs/agent.cs +3 -51
  90. package/framework/templates/code/dotnet/jobs/job.cs +2 -97
  91. package/framework/templates/code/dotnet/test.cs +2 -87
  92. package/framework/templates/code/typescript/contracts.ts +140 -167
  93. package/framework/templates/context/design-system.md +3 -3
  94. package/framework/templates/docs/proposal.md +11 -0
  95. package/framework/templates/docs/spec.md +15 -0
  96. package/framework/templates/docs/ui-mockups.md +115 -98
  97. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +1 -48
  98. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +3 -79
  99. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +1 -70
  100. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +1 -47
  101. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +2 -99
  102. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +0 -6
  103. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +3 -88
  104. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +1 -41
  105. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +0 -21
  106. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +1 -25
  107. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +2 -63
  108. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +0 -30
  109. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +1 -52
  110. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +3 -78
  111. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +4 -109
  112. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +0 -22
  113. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +1 -36
  114. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +2 -57
  115. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +0 -75
  116. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +2 -59
  117. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +4 -111
  118. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +0 -34
  119. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +1 -57
  120. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +0 -19
  121. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +2 -74
  122. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +2 -33
  123. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +1 -43
  124. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +1 -96
  125. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +0 -13
  126. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +0 -27
  127. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +0 -33
  128. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +0 -31
  129. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +3 -75
  130. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +0 -42
  131. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +0 -39
  132. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +1 -55
  133. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +3 -59
  134. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +0 -46
  135. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +3 -28
  136. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +7 -61
  137. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +2 -37
  138. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +1 -23
  139. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +2 -82
  140. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +1 -68
  141. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +1 -62
  142. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +2 -62
  143. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +1 -25
  144. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +0 -124
  145. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +1 -73
  146. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +1 -66
  147. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +0 -41
  148. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +2 -112
  149. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +2 -43
  150. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +5 -110
  151. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +7 -103
  152. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +1 -42
  153. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +1 -30
  154. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +1 -19
  155. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +0 -48
  156. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +1 -29
  157. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +2 -38
  158. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +1 -14
  159. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +4 -69
  160. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +1 -10
  161. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +1 -57
  162. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +0 -26
  163. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +1 -19
  164. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +0 -32
  165. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +0 -14
  166. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +0 -24
  167. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +0 -32
  168. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +0 -37
  169. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +0 -24
  170. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +0 -78
  171. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +0 -53
  172. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +0 -44
  173. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +0 -44
  174. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +0 -19
  175. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +0 -24
  176. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +6 -48
  177. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +1 -13
  178. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +2 -71
  179. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +5 -100
  180. package/framework/templates/feature/decisions.md +124 -122
  181. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +39 -43
  182. package/framework/templates/frontend/nextjs/env.mjs.hbs +29 -32
  183. package/framework/templates/frontend/nextjs/page.tsx.hbs +21 -22
  184. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +42 -54
  185. package/framework/templates/integrations/asaas-client.cs +332 -387
  186. package/framework/templates/integrations/asaas-webhook.cs +290 -351
  187. package/framework/templates/integrations/neon-auth-config.cs +9 -105
  188. package/framework/templates/project/validate.js +97 -120
  189. package/package.json +2 -2
  190. package/src/commands/dashboard/dashboard.js +80 -53
  191. package/src/commands/graph/affected.js +4 -3
  192. package/src/commands/graph/build.js +109 -18
  193. package/src/commands/graph/explain.js +8 -5
  194. package/src/commands/graph/status.js +14 -8
  195. package/src/commands/mcp/mcp-setup.js +37 -9
  196. package/src/commands/observability/cost.js +148 -18
  197. package/src/commands/project/doctor.js +85 -15
  198. package/src/commands/project/init.js +6 -3
  199. package/src/commands/project/install-plugin-cmd.js +1 -1
  200. package/src/commands/project/update.js +30 -0
  201. package/src/commands/project/worktree.js +73 -90
  202. package/src/commands/state/advance.js +18 -0
  203. package/src/commands/state/approve.js +15 -0
  204. package/src/commands/state/archive.js +62 -9
  205. package/src/commands/state/finish.js +62 -14
  206. package/src/commands/state/gate-check.js +8 -1
  207. package/src/commands/state/gate-decision.js +8 -4
  208. package/src/commands/state/report-check.js +90 -9
  209. package/src/commands/state/usecase.js +66 -0
  210. package/src/commands/tasks/dag.js +17 -2
  211. package/src/commands/validation/validate-feature.js +6 -1
  212. package/src/commands/validation/verify.js +20 -1
  213. package/src/dashboard/app/App.jsx +80 -16
  214. package/src/dashboard/app/components/ActivityFeed.jsx +172 -0
  215. package/src/dashboard/app/components/CommandPalette.jsx +155 -0
  216. package/src/dashboard/app/components/DetailPanel.jsx +47 -11
  217. package/src/dashboard/app/components/EntityLink.jsx +55 -0
  218. package/src/dashboard/app/components/ErrorBoundary.jsx +54 -0
  219. package/src/dashboard/app/components/FlowGraph.jsx +19 -17
  220. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +53 -0
  221. package/src/dashboard/app/components/Markdown.jsx +47 -0
  222. package/src/dashboard/app/components/PhaseRail.jsx +111 -0
  223. package/src/dashboard/app/components/SearchFilter.jsx +40 -22
  224. package/src/dashboard/app/components/Sidebar.jsx +146 -37
  225. package/src/dashboard/app/hooks/useMorphQuery.js +54 -0
  226. package/src/dashboard/app/hooks/usePref.js +38 -0
  227. package/src/dashboard/app/lib/format.js +69 -0
  228. package/src/dashboard/app/lib/phases.js +56 -0
  229. package/src/dashboard/app/lib/project.js +50 -0
  230. package/src/dashboard/app/pages/Activity.jsx +24 -0
  231. package/src/dashboard/app/pages/AgentMap.jsx +371 -370
  232. package/src/dashboard/app/pages/FeatureDetail.jsx +604 -0
  233. package/src/dashboard/app/pages/Features.jsx +400 -0
  234. package/src/dashboard/app/pages/Guide.jsx +146 -0
  235. package/src/dashboard/app/pages/HookInspector.jsx +357 -322
  236. package/src/dashboard/app/pages/PhasePipeline.jsx +277 -408
  237. package/src/dashboard/app/pages/Portfolio.jsx +156 -0
  238. package/src/dashboard/app/pages/Rubrics.jsx +101 -0
  239. package/src/dashboard/app/pages/RulesMap.jsx +300 -273
  240. package/src/dashboard/app/pages/SkillsLibrary.jsx +483 -366
  241. package/src/dashboard/app/pages/Standards.jsx +407 -332
  242. package/src/dashboard/app/styles/index.css +187 -47
  243. package/src/dashboard/index.html +14 -12
  244. package/src/dashboard/package.json +32 -24
  245. package/src/dashboard/scanner/activity-scanner.js +351 -0
  246. package/src/dashboard/scanner/agents-scanner.js +63 -1
  247. package/src/dashboard/scanner/docs-scanner.js +59 -0
  248. package/src/dashboard/scanner/feature-status.js +10 -0
  249. package/src/dashboard/scanner/features-scanner.js +360 -0
  250. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  251. package/src/dashboard/scanner/index.js +14 -7
  252. package/src/dashboard/scanner/phases-scanner.js +14 -15
  253. package/src/dashboard/scanner/portfolio.js +153 -0
  254. package/src/dashboard/scanner/project-api.js +195 -0
  255. package/src/dashboard/scanner/rules-scanner.js +42 -37
  256. package/src/dashboard/scanner/skills-scanner.js +105 -43
  257. package/src/dashboard/scanner/standards-scanner.js +4 -1
  258. package/src/dashboard/vite-plugin-morph-data.js +134 -17
  259. package/src/lib/ai/comment-stripper.js +101 -4
  260. package/src/lib/detectors/claude-config-detector.js +2 -7
  261. package/src/lib/detectors/index.js +5 -1
  262. package/src/lib/evaluator-report.js +47 -0
  263. package/src/lib/generators/handoff-generator.js +4 -18
  264. package/src/lib/generators/settings-generator.js +208 -209
  265. package/src/lib/git/default-branch.js +52 -0
  266. package/src/lib/git/tree-fingerprint.js +58 -0
  267. package/src/lib/git/tree-state.js +167 -0
  268. package/src/lib/graph/god-nodes.js +12 -3
  269. package/src/lib/graph/graph-health.js +48 -2
  270. package/src/lib/graph/graph-loader.js +99 -25
  271. package/src/lib/graph/graphifyignore.js +145 -28
  272. package/src/lib/graph/sanitize.js +117 -11
  273. package/src/lib/installers/mcp-installer.js +724 -719
  274. package/src/lib/installers/memory-installer.js +1 -2
  275. package/src/lib/projects/registry.js +154 -0
  276. package/src/lib/risk-detector.js +90 -0
  277. package/src/lib/specs/acervo-format.js +70 -0
  278. package/src/lib/specs/acervo-lifecycle.js +264 -0
  279. package/src/lib/specs/adr-index.js +169 -0
  280. package/src/lib/specs/revocations.js +156 -0
  281. package/src/lib/specs/screen-checklist.js +357 -0
  282. package/src/lib/specs/usecase-check.js +20 -16
  283. package/src/lib/specs/usecase-promoter.js +103 -38
  284. package/src/lib/specs/usecase-registry.js +7 -7
  285. package/src/lib/tasks/tasks-json-parser.js +2 -3
  286. package/src/lib/telemetry/metrics.js +386 -0
  287. package/src/lib/telemetry/telemetry-reader.js +161 -51
  288. package/src/lib/telemetry/verify-event.js +79 -0
  289. package/src/lib/validators/code-budget/code-budget-validator.js +200 -0
  290. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  291. package/src/lib/validators/code-budget/index.js +9 -0
  292. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  293. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  294. package/src/lib/validators/validation-runner.js +21 -5
  295. package/src/lib/validators/verify-runner.js +11 -0
  296. package/src/lib/worktree/finish-backup.js +739 -0
  297. package/src/lib/worktree/ignored-triage.js +15 -8
  298. package/src/lib/worktree/worktree-audit.js +5 -0
  299. package/src/scripts/install-plugin.js +1 -1
  300. package/src/scripts/setup-infra.js +31 -22
  301. package/src/utils/agents-installer.js +338 -24
  302. package/src/utils/banner.js +1 -1
  303. package/src/utils/hooks-installer.js +75 -15
  304. package/src/commands/index.js +0 -13
  305. package/src/commands/project/checkpoint.js +0 -209
  306. package/src/core/state/phase-state-machine.js +0 -197
  307. package/src/dashboard/app/hooks/useDetailPanel.js +0 -15
  308. package/src/dashboard/app/pages/LiveState.jsx +0 -206
  309. package/src/dashboard/scanner/squads-scanner.js +0 -13
  310. package/src/dashboard/scanner/state-scanner.js +0 -85
  311. package/src/lib/generators/index.js +0 -10
  312. package/src/lib/hooks/hook-executor.js +0 -167
  313. package/src/lib/validators/checkpoint-persistence.js +0 -39
  314. package/src/lib/validators/shared/emit-validator-dispatch.js +0 -14
  315. package/src/lib/validators/shared/index.js +0 -12
  316. package/src/lib/validators/shared/issue-counter.js +0 -14
  317. package/src/lib/validators/shared/result-formatter.js +0 -124
@@ -1,719 +1,724 @@
1
- /**
2
- * MCP Installer
3
- *
4
- * Core logic for installing and configuring MCP servers during morph-spec init
5
- * and via `morph-spec mcp setup`. Handles auto-installable (credential-free)
6
- * MCPs, prerequisite checking, credential collection, and setup guidance.
7
- */
8
-
9
- import { join } from 'path';
10
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
11
- import { execSync, spawn } from 'child_process';
12
- import { homedir } from 'os';
13
- import { installMcpServers } from '../../utils/claude-settings-manager.js';
14
-
15
- /**
16
- * Load the enhanced MCP registry (v3.0.0)
17
- * @returns {Object} Registry with install blocks
18
- */
19
- export function loadMcpRegistry() {
20
- const registryPath = join(
21
- import.meta.dirname, '..', '..', '..', 'framework', 'mcp', 'registry.json'
22
- );
23
- const raw = readFileSync(registryPath, 'utf-8');
24
- return JSON.parse(raw);
25
- }
26
-
27
- /**
28
- * Filter MCPs by stack relevance
29
- * @param {Object} registry - Full MCP registry
30
- * @param {string} stack - Detected project stack (e.g., 'nextjs-neon', 'blazor')
31
- * @returns {Object} Filtered map of mcpName → mcpEntry
32
- */
33
- export function filterByStack(registry, stack) {
34
- const result = {};
35
- const normalizedStack = (stack || '').toLowerCase();
36
-
37
- for (const [name, entry] of Object.entries(registry.mcps)) {
38
- if (!entry.install) continue;
39
- const relevance = entry.install.stackRelevance || [];
40
-
41
- if (relevance.includes('*')) {
42
- result[name] = entry;
43
- } else if (normalizedStack && relevance.some(s => normalizedStack.includes(s))) {
44
- result[name] = entry;
45
- }
46
- }
47
-
48
- return result;
49
- }
50
-
51
- /**
52
- * Match detected MCP servers against the registry
53
- * @param {Array} existingServers - From detectClaudeConfig().mcpServers
54
- * @param {Object} registry - MCP registry
55
- * @returns {Object} Map of mcpName → { installed: true, source }
56
- */
57
- export function getInstalledMcps(existingServers, registry) {
58
- const installed = {};
59
- const registryNames = Object.keys(registry.mcps);
60
-
61
- for (const server of existingServers) {
62
- const serverName = server.name.toLowerCase();
63
- for (const regName of registryNames) {
64
- if (serverName.includes(regName)) {
65
- installed[regName] = { installed: true, source: server.source };
66
- }
67
- }
68
- }
69
-
70
- return installed;
71
- }
72
-
73
- /**
74
- * Check if prerequisites are met for an MCP entry
75
- * @param {Object} mcpEntry - MCP entry from registry
76
- * @returns {Array} Array of { name, met, installUrl } for each prerequisite
77
- */
78
- export function checkPrerequisites(mcpEntry) {
79
- const prereqs = mcpEntry.install?.prerequisites || [];
80
- const results = [];
81
-
82
- for (const prereq of prereqs) {
83
- let met = false;
84
- try {
85
- execSync(prereq.checkCommand, { stdio: 'ignore', timeout: 5000 });
86
- met = true;
87
- } catch {
88
- // Command failed or not found
89
- }
90
- results.push({
91
- name: prereq.name,
92
- met,
93
- installUrl: prereq.installUrl
94
- });
95
- }
96
-
97
- return results;
98
- }
99
-
100
- /**
101
- * Check if an MCP config uses remote HTTP transport (no local process).
102
- * @param {Object} config - MCP server config
103
- * @returns {boolean}
104
- */
105
- export function isRemoteMcp(config) {
106
- return config.type === 'http' || config.url != null;
107
- }
108
-
109
- /**
110
- * Patch MCP config for the current platform.
111
- * On Windows, npx must be invoked via `cmd /c` to avoid ENOENT errors.
112
- * Registry already stores cmd/c format; this handles legacy configs that still use npx directly.
113
- * Remote HTTP MCPs are returned as-is (no local process to patch).
114
- * @param {Object} config - MCP server config { command, args, env? } or { type: "http", url }
115
- * @returns {Object} Patched config (new object, original unmodified)
116
- */
117
- export function patchConfigForPlatform(config) {
118
- // Remote MCPs need no platform patching
119
- if (isRemoteMcp(config)) return config;
120
- if (config.command !== 'npx') return config;
121
- return {
122
- ...config,
123
- command: 'cmd',
124
- args: ['/c', 'npx', ...(config.args || [])]
125
- };
126
- }
127
-
128
- /**
129
- * Install auto-installable MCPs (no credentials, no prerequisites)
130
- * @param {string} targetPath - Project root directory
131
- * @param {Object} mcpsToInstall - Map of mcpName → mcpEntry
132
- * @returns {Promise<Object>} Result from installMcpServers
133
- */
134
- export async function installAutoMcps(targetPath, mcpsToInstall) {
135
- const servers = {};
136
-
137
- for (const [name, entry] of Object.entries(mcpsToInstall)) {
138
- let config = { ...entry.install.config };
139
- // Remote MCPs don't have env to strip
140
- if (!isRemoteMcp(config)) {
141
- // Only include env if it has actual values
142
- if (config.env && Object.values(config.env).every(v => !v)) {
143
- delete config.env;
144
- }
145
- }
146
- servers[name] = patchConfigForPlatform(config);
147
- }
148
-
149
- return installMcpServers(targetPath, servers);
150
- }
151
-
152
- /**
153
- * Install companion skills for MCPs that define them (e.g., Stitch agent skills).
154
- * Reads the `companionSkills` field from registry entries and runs the install command.
155
- * @param {Object} mcpsInstalled - Map of mcpName → mcpEntry that were just installed
156
- * @param {Function} exec - execSync function (for testing)
157
- * @returns {Array<{name: string, repo: string, success: boolean}>}
158
- */
159
- export function installCompanionSkills(mcpsInstalled, exec = execSync) {
160
- const results = [];
161
-
162
- for (const [name, entry] of Object.entries(mcpsInstalled)) {
163
- const companion = entry.companionSkills;
164
- if (!companion?.installCommand) continue;
165
-
166
- try {
167
- exec(companion.installCommand, { stdio: 'inherit', timeout: 30000 });
168
- results.push({ name, repo: companion.repo, success: true });
169
- } catch {
170
- results.push({ name, repo: companion.repo, success: false });
171
- }
172
- }
173
-
174
- return results;
175
- }
176
-
177
- /**
178
- * Generate the Stitch OAuth proxy template script.
179
- * Cross-platform (Windows gcloud.cmd + Unix gcloud), fetch()-based,
180
- * with readline, session tracking, and graceful shutdown.
181
- * @param {string} gcpProject - GCP project ID for x-goog-user-project header
182
- * @returns {string} CJS proxy script content
183
- */
184
- export function getStitchProxyTemplate(gcpProject) {
185
- return `#!/usr/bin/env node
186
- /**
187
- * Stitch MCP stdio-to-HTTP proxy with Google auth
188
- * Generated by morph-spec mcp setup stitch
189
- */
190
- const { execSync } = require('child_process');
191
- const { createInterface } = require('readline');
192
-
193
- const STITCH_URL = 'https://stitch.googleapis.com/mcp';
194
- const PROJECT_ID = '${gcpProject}';
195
-
196
- // Cross-platform gcloud path: Windows uses gcloud.cmd from Cloud SDK install
197
- const GCLOUD = process.platform === 'win32'
198
- ? (process.env.LOCALAPPDATA || '') + '/Google/Cloud SDK/google-cloud-sdk/bin/gcloud.cmd'
199
- : 'gcloud';
200
-
201
- let cachedToken = null;
202
- let tokenExpiry = 0;
203
- let pendingRequests = 0;
204
- let stdinClosed = false;
205
- let sessionId = null;
206
-
207
- function getToken() {
208
- if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
209
- try {
210
- cachedToken = execSync(\`"\${GCLOUD}" auth application-default print-access-token\`, {
211
- encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
212
- }).trim();
213
- tokenExpiry = Date.now() + 50 * 60 * 1000; // 50-minute TTL
214
- return cachedToken;
215
- } catch {
216
- return null;
217
- }
218
- }
219
-
220
- function maybeExit() {
221
- if (stdinClosed && pendingRequests === 0) process.exit(0);
222
- }
223
-
224
- async function handleMessage(line) {
225
- let msg;
226
- try { msg = JSON.parse(line); } catch { return; }
227
-
228
- // Notifications (no id) don't need responses
229
- if (msg.id === undefined && msg.method === 'notifications/initialized') return;
230
-
231
- const token = getToken();
232
- if (!token) {
233
- if (msg.id !== undefined) {
234
- process.stdout.write(JSON.stringify({
235
- jsonrpc: '2.0', id: msg.id,
236
- error: { code: -32000, message: 'Failed to get gcloud auth token. Run: gcloud auth application-default login' }
237
- }) + '\\n');
238
- }
239
- return;
240
- }
241
-
242
- const headers = {
243
- 'Content-Type': 'application/json',
244
- 'Authorization': \`Bearer \${token}\`,
245
- 'x-goog-user-project': PROJECT_ID,
246
- };
247
- if (sessionId) headers['mcp-session-id'] = sessionId;
248
-
249
- pendingRequests++;
250
- try {
251
- const res = await fetch(STITCH_URL, { method: 'POST', headers, body: JSON.stringify(msg) });
252
-
253
- // Track sticky session
254
- const sid = res.headers.get('mcp-session-id');
255
- if (sid) sessionId = sid;
256
-
257
- // Retry once on 401 (expired token)
258
- if (res.status === 401) {
259
- cachedToken = null;
260
- tokenExpiry = 0;
261
- const retryToken = getToken();
262
- if (retryToken) {
263
- headers['Authorization'] = \`Bearer \${retryToken}\`;
264
- const retryRes = await fetch(STITCH_URL, { method: 'POST', headers, body: JSON.stringify(msg) });
265
- const retryText = await retryRes.text();
266
- if (retryText.trim()) {
267
- process.stdout.write(retryText.endsWith('\\n') ? retryText : retryText + '\\n');
268
- }
269
- return;
270
- }
271
- }
272
-
273
- const text = await res.text();
274
- if (text.trim()) {
275
- process.stdout.write(text.endsWith('\\n') ? text : text + '\\n');
276
- }
277
- } catch (err) {
278
- if (msg.id !== undefined) {
279
- process.stdout.write(JSON.stringify({
280
- jsonrpc: '2.0', id: msg.id,
281
- error: { code: -32000, message: \`Stitch API error: \${err.message}\` }
282
- }) + '\\n');
283
- }
284
- } finally {
285
- pendingRequests--;
286
- maybeExit();
287
- }
288
- }
289
-
290
- const rl = createInterface({ input: process.stdin });
291
- rl.on('line', handleMessage);
292
- rl.on('close', () => { stdinClosed = true; maybeExit(); });
293
- `;
294
- }
295
-
296
- /**
297
- * Generate and write the Stitch OAuth proxy script to the target project.
298
- * @param {string} targetPath - Project root directory
299
- * @param {string} gcpProject - GCP project ID
300
- * @returns {{ proxyPath: string }} Path to the generated proxy script
301
- */
302
- export function generateStitchProxy(targetPath, gcpProject) {
303
- const claudeDir = join(targetPath, '.claude');
304
- if (!existsSync(claudeDir)) {
305
- mkdirSync(claudeDir, { recursive: true });
306
- }
307
-
308
- const proxyPath = join(claudeDir, 'stitch-proxy.cjs');
309
- writeFileSync(proxyPath, getStitchProxyTemplate(gcpProject), 'utf-8');
310
-
311
- return { proxyPath };
312
- }
313
-
314
- /**
315
- * Resolve the gcloud CLI binary path.
316
- * Checks: system PATH → bundled at ~/.stitch-mcp/ → null.
317
- * @param {Function} exec - execSync function (for testing)
318
- * @returns {{ gcloudPath: string|null, source: string }}
319
- */
320
- export function findGcloud(exec = execSync) {
321
- // Try system PATH first
322
- try {
323
- exec('gcloud --version', { stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 });
324
- return { gcloudPath: 'gcloud', source: 'system' };
325
- } catch { /* not on PATH */ }
326
-
327
- // Windows: try Cloud SDK default install location
328
- if (process.platform === 'win32') {
329
- const winPath = join(process.env.LOCALAPPDATA || '', 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin', 'gcloud.cmd');
330
- if (existsSync(winPath)) {
331
- return { gcloudPath: winPath, source: 'windows-sdk' };
332
- }
333
- }
334
-
335
- // Try bundled at ~/.stitch-mcp/
336
- const bundledDir = join(homedir(), '.stitch-mcp');
337
- const bundledBin = process.platform === 'win32'
338
- ? join(bundledDir, 'google-cloud-sdk', 'bin', 'gcloud.cmd')
339
- : join(bundledDir, 'google-cloud-sdk', 'bin', 'gcloud');
340
- if (existsSync(bundledBin)) {
341
- return { gcloudPath: bundledBin, source: 'bundled' };
342
- }
343
-
344
- return { gcloudPath: null, source: 'not-found' };
345
- }
346
-
347
- /**
348
- * Fully automated Stitch MCP setup orchestrator.
349
- *
350
- * Steps:
351
- * 1. Find gcloud CLI
352
- * 2. Verify ADC credentials
353
- * 3. Detect GCP project
354
- * 4. Enable stitch.googleapis.com API
355
- * 5. Generate proxy script
356
- * 6. Install MCP config in .mcp.json
357
- * 7. Health-check the proxy
358
- *
359
- * @param {string} targetPath - Project root directory
360
- * @param {Object} options
361
- * @param {string} [options.gcpProject] - GCP project ID (auto-detected if omitted)
362
- * @param {Function} [options._exec] - execSync override for testing
363
- * @returns {Promise<Object>} Step-by-step results
364
- */
365
- export async function setupStitch(targetPath, options = {}) {
366
- const exec = options._exec || execSync;
367
- const steps = [];
368
-
369
- // Step 1: Find gcloud
370
- const { gcloudPath, source: gcloudSource } = findGcloud(exec);
371
- if (!gcloudPath) {
372
- steps.push({ step: 'find-gcloud', success: false, error: 'gcloud CLI not found. Install from: https://cloud.google.com/sdk/docs/install' });
373
- return { success: false, steps };
374
- }
375
- steps.push({ step: 'find-gcloud', success: true, gcloudPath, source: gcloudSource });
376
-
377
- const gcloudCmd = gcloudPath.includes(' ') ? `"${gcloudPath}"` : gcloudPath;
378
-
379
- // Step 2: Verify ADC credentials
380
- try {
381
- exec(`${gcloudCmd} auth application-default print-access-token`, {
382
- encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000
383
- });
384
- steps.push({ step: 'verify-adc', success: true });
385
- } catch {
386
- steps.push({ step: 'verify-adc', success: false, error: 'No ADC credentials. Run: gcloud auth application-default login' });
387
- return { success: false, steps };
388
- }
389
-
390
- // Step 3: Detect GCP project
391
- let gcpProject = options.gcpProject;
392
- if (!gcpProject) {
393
- // Try config.json
394
- const configPath = join(targetPath, '.morph', 'config', 'config.json');
395
- if (existsSync(configPath)) {
396
- try {
397
- const config = JSON.parse(readFileSync(configPath, 'utf-8'));
398
- gcpProject = config.gcpProject || config.project?.gcpProject;
399
- } catch { /* invalid config */ }
400
- }
401
- }
402
- if (!gcpProject) {
403
- // Try gcloud config
404
- try {
405
- gcpProject = exec(`${gcloudCmd} config get-value project`, {
406
- encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
407
- }).trim();
408
- if (!gcpProject || gcpProject === '(unset)') gcpProject = null;
409
- } catch { /* gcloud config failed */ }
410
- }
411
- if (!gcpProject) {
412
- steps.push({ step: 'detect-project', success: false, error: 'Could not detect GCP project. Pass it explicitly or set via: gcloud config set project <id>' });
413
- return { success: false, steps };
414
- }
415
- steps.push({ step: 'detect-project', success: true, gcpProject });
416
-
417
- // Step 4: Enable Stitch API
418
- try {
419
- exec(`${gcloudCmd} services enable stitch.googleapis.com --project=${gcpProject}`, {
420
- encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000
421
- });
422
- steps.push({ step: 'enable-api', success: true });
423
- } catch (err) {
424
- steps.push({ step: 'enable-api', success: false, error: `Failed to enable stitch API: ${err.message}` });
425
- // Non-fatal — API might already be enabled and user may lack permissions to re-enable
426
- }
427
-
428
- // Step 5: Generate proxy
429
- const { proxyPath } = generateStitchProxy(targetPath, gcpProject);
430
- steps.push({ step: 'generate-proxy', success: true, proxyPath });
431
-
432
- // Step 6: Install MCP config in .mcp.json
433
- const mcpResult = await installMcpServers(targetPath, {
434
- stitch: { type: 'stdio', command: 'node', args: ['.claude/stitch-proxy.cjs'] }
435
- });
436
- steps.push({ step: 'install-mcp', success: true, added: mcpResult.added, skipped: mcpResult.skipped });
437
-
438
- // Step 7: Health-check
439
- const healthResult = await healthCheckStitchProxy(proxyPath);
440
- steps.push({ step: 'health-check', ...healthResult });
441
-
442
- const allCriticalPassed = steps.every(s =>
443
- s.success || s.step === 'enable-api' || s.step === 'health-check'
444
- );
445
-
446
- return { success: allCriticalPassed, steps, gcpProject };
447
- }
448
-
449
- /**
450
- * Health-check the Stitch proxy by sending an MCP initialize handshake.
451
- * @param {string} proxyPath - Path to stitch-proxy.cjs
452
- * @returns {Promise<Object>} { success, protocolVersion?, error? }
453
- */
454
- export function healthCheckStitchProxy(proxyPath) {
455
- return new Promise((resolve) => {
456
- const initMsg = JSON.stringify({
457
- jsonrpc: '2.0',
458
- id: 1,
459
- method: 'initialize',
460
- params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'morph-spec', version: '1.0.0' } }
461
- });
462
-
463
- const child = spawn('node', [proxyPath], {
464
- stdio: ['pipe', 'pipe', 'pipe'],
465
- timeout: 20000,
466
- });
467
-
468
- let output = '';
469
- let timedOut = false;
470
-
471
- const timer = setTimeout(() => {
472
- timedOut = true;
473
- child.kill();
474
- resolve({ success: false, error: 'Health-check timed out after 20s' });
475
- }, 20000);
476
-
477
- child.stdout.on('data', (data) => {
478
- output += data.toString();
479
- // Check if we got a response
480
- const lines = output.split('\n').filter(l => l.trim());
481
- for (const line of lines) {
482
- try {
483
- const resp = JSON.parse(line);
484
- if (resp.result?.protocolVersion) {
485
- clearTimeout(timer);
486
- child.kill();
487
- resolve({ success: true, protocolVersion: resp.result.protocolVersion });
488
- return;
489
- }
490
- if (resp.error) {
491
- clearTimeout(timer);
492
- child.kill();
493
- resolve({ success: false, error: resp.error.message || 'MCP error' });
494
- return;
495
- }
496
- } catch { /* not valid JSON yet */ }
497
- }
498
- });
499
-
500
- child.on('error', (err) => {
501
- if (!timedOut) {
502
- clearTimeout(timer);
503
- resolve({ success: false, error: err.message });
504
- }
505
- });
506
-
507
- child.on('close', (code) => {
508
- if (!timedOut) {
509
- clearTimeout(timer);
510
- resolve({ success: false, error: output.trim()
511
- ? `Proxy exited with code ${code} — unexpected output`
512
- : `Proxy exited with code ${code} and no output`
513
- });
514
- }
515
- });
516
-
517
- // Send the initialize message
518
- child.stdin.write(initMsg + '\n');
519
- child.stdin.end();
520
- });
521
- }
522
-
523
- /**
524
- * Install a single MCP with provided credentials
525
- * @param {string} targetPath - Project root directory
526
- * @param {string} name - MCP name
527
- * @param {Object} mcpEntry - MCP entry from registry
528
- * @param {Object} credentialValues - Map of envVar → value
529
- * @returns {Promise<Object>} Result from installMcpServers
530
- */
531
- export async function installMcpWithCredentials(targetPath, name, mcpEntry, credentialValues) {
532
- const config = patchConfigForPlatform({ ...mcpEntry.install.config });
533
- config.env = { ...config.env, ...credentialValues };
534
- return installMcpServers(targetPath, { [name]: config });
535
- }
536
-
537
- /**
538
- * Generate setup instructions for a credential-requiring MCP
539
- * @param {string} name - MCP name
540
- * @param {Object} mcpEntry - MCP entry from registry
541
- * @returns {Object} { configSnippet, credentialUrls, cliCommand }
542
- */
543
- export function generateSetupInstructions(name, mcpEntry) {
544
- const config = mcpEntry.install.config;
545
- const credentials = mcpEntry.install.credentials || [];
546
- const setupGuide = mcpEntry.install.setupGuide;
547
-
548
- // Remote HTTP MCPs — show claude mcp add command instead of config snippet
549
- if (isRemoteMcp(config)) {
550
- const configSnippet = JSON.stringify({ [name]: config }, null, 2);
551
- const credentialUrls = credentials.map(c => ({
552
- name: c.name,
553
- envVar: c.envVar,
554
- helpUrl: c.helpUrl
555
- }));
556
- return {
557
- configSnippet,
558
- credentialUrls,
559
- cliCommand: setupGuide || `claude mcp add --transport http ${name} ${config.url}`,
560
- isRemote: true
561
- };
562
- }
563
-
564
- // Build the config snippet with placeholder values
565
- const snippetConfig = { ...config };
566
- if (credentials.length > 0) {
567
- snippetConfig.env = {};
568
- for (const cred of credentials) {
569
- snippetConfig.env[cred.envVar] = cred.secret ? 'YOUR_KEY' : 'YOUR_VALUE';
570
- }
571
- }
572
-
573
- const configSnippet = JSON.stringify({ [name]: snippetConfig }, null, 2);
574
-
575
- const credentialUrls = credentials.map(c => ({
576
- name: c.name,
577
- envVar: c.envVar,
578
- helpUrl: c.helpUrl
579
- }));
580
-
581
- return {
582
- configSnippet,
583
- credentialUrls,
584
- cliCommand: `morph-spec mcp setup ${name}`
585
- };
586
- }
587
-
588
- /**
589
- * Full orchestrator for MCP setup during init or `mcp setup`
590
- * @param {string} targetPath - Project root directory
591
- * @param {string} stack - Detected project stack
592
- * @param {Array} existingMcps - From detectClaudeConfig().mcpServers
593
- * @param {Object} options
594
- * @param {boolean} options.autoOnly - Only handle auto-installable MCPs
595
- * @returns {Object} { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing }
596
- */
597
- export function orchestrateMcpSetup(targetPath, stack, existingMcps, options = {}) {
598
- const registry = loadMcpRegistry();
599
- const relevant = filterByStack(registry, stack);
600
- const installed = getInstalledMcps(existingMcps || [], registry);
601
-
602
- const autoInstallable = {};
603
- const alreadyInstalled = {};
604
- const needsManualSetup = {};
605
- const notRelevant = {};
606
- const prerequisitesMissing = {};
607
-
608
- // Categorize all MCPs
609
- for (const [name, entry] of Object.entries(registry.mcps)) {
610
- if (!entry.install) continue;
611
-
612
- // Check if already installed
613
- if (installed[name]) {
614
- alreadyInstalled[name] = { entry, source: installed[name].source };
615
- continue;
616
- }
617
-
618
- // Check if relevant to stack
619
- if (!relevant[name]) {
620
- notRelevant[name] = entry;
621
- continue;
622
- }
623
-
624
- // Check prerequisites
625
- const prereqResults = checkPrerequisites(entry);
626
- const unmetPrereqs = prereqResults.filter(p => !p.met);
627
- if (unmetPrereqs.length > 0) {
628
- prerequisitesMissing[name] = { entry, unmet: unmetPrereqs };
629
- continue;
630
- }
631
-
632
- // Categorize by install type
633
- if (entry.install.autoInstall) {
634
- autoInstallable[name] = entry;
635
- } else if (!options.autoOnly) {
636
- needsManualSetup[name] = entry;
637
- }
638
- }
639
-
640
- return {
641
- registry,
642
- autoInstallable,
643
- alreadyInstalled,
644
- needsManualSetup,
645
- notRelevant,
646
- prerequisitesMissing
647
- };
648
- }
649
-
650
- /**
651
- * Format MCP status table for display
652
- * @param {Object} orchestration - Result from orchestrateMcpSetup
653
- * @param {Object} justInstalled - Map of names just installed during this run
654
- * @returns {Array<Object>} Array of { name, status, detail }
655
- */
656
- export function formatMcpStatusTable(orchestration, justInstalled = {}) {
657
- const rows = [];
658
-
659
- const { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing, registry } = orchestration;
660
-
661
- // Installed during this run
662
- for (const [name, entry] of Object.entries(justInstalled)) {
663
- const mcpEntry = registry.mcps[name];
664
- rows.push({
665
- name,
666
- status: 'installed',
667
- detail: mcpEntry ? mcpEntry.usage : ''
668
- });
669
- }
670
-
671
- // Auto-installable but not yet installed (and not just-installed)
672
- for (const [name, entry] of Object.entries(autoInstallable)) {
673
- if (justInstalled[name]) continue;
674
- rows.push({
675
- name,
676
- status: 'available',
677
- detail: `Run: morph-spec mcp setup --auto`
678
- });
679
- }
680
-
681
- // Already installed
682
- for (const [name, { source }] of Object.entries(alreadyInstalled)) {
683
- rows.push({
684
- name,
685
- status: 'already_configured',
686
- detail: `(${source})`
687
- });
688
- }
689
-
690
- // Needs manual setup
691
- for (const [name, entry] of Object.entries(needsManualSetup)) {
692
- rows.push({
693
- name,
694
- status: 'needs_setup',
695
- detail: `Run: morph-spec mcp setup ${name}`
696
- });
697
- }
698
-
699
- // Prerequisites missing
700
- for (const [name, { unmet }] of Object.entries(prerequisitesMissing)) {
701
- const missing = unmet.map(p => p.name).join(', ');
702
- rows.push({
703
- name,
704
- status: 'prereq_missing',
705
- detail: `Missing: ${missing}`
706
- });
707
- }
708
-
709
- // Not relevant
710
- for (const [name] of Object.entries(notRelevant)) {
711
- rows.push({
712
- name,
713
- status: 'not_relevant',
714
- detail: 'not relevant to stack'
715
- });
716
- }
717
-
718
- return rows;
719
- }
1
+ /**
2
+ * MCP Installer
3
+ *
4
+ * Core logic for installing and configuring MCP servers during morph-spec init
5
+ * and via `morph-spec mcp setup`. Handles auto-installable (credential-free)
6
+ * MCPs, prerequisite checking, credential collection, and setup guidance.
7
+ */
8
+
9
+ import { join } from 'path';
10
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
11
+ import { execSync, spawn } from 'child_process';
12
+ import { homedir } from 'os';
13
+ import { installMcpServers } from '../../utils/claude-settings-manager.js';
14
+
15
+ /**
16
+ * Load the enhanced MCP registry (v3.0.0)
17
+ * @returns {Object} Registry with install blocks
18
+ */
19
+ export function loadMcpRegistry() {
20
+ const registryPath = join(
21
+ import.meta.dirname, '..', '..', '..', 'framework', 'mcp', 'registry.json'
22
+ );
23
+ const raw = readFileSync(registryPath, 'utf-8');
24
+ return JSON.parse(raw);
25
+ }
26
+
27
+ /**
28
+ * Filter MCPs by stack relevance
29
+ * @param {Object} registry - Full MCP registry
30
+ * @param {string} stack - Detected project stack (e.g., 'nextjs-neon', 'blazor')
31
+ * @param {string[]} [integrations] - `project.integrations` (e.g., ['neon']). The
32
+ * detectors put a database or payment provider here, never in the stack string,
33
+ * so an MCP relevant to it (neon, asaas) is matched against this list too.
34
+ * @returns {Object} Filtered map of mcpName → mcpEntry
35
+ */
36
+ export function filterByStack(registry, stack, integrations = []) {
37
+ const result = {};
38
+ const normalizedStack = (stack || '').toLowerCase();
39
+ const tags = integrations.map(i => String(i).toLowerCase());
40
+
41
+ for (const [name, entry] of Object.entries(registry.mcps)) {
42
+ if (!entry.install) continue;
43
+ const relevance = entry.install.stackRelevance || [];
44
+
45
+ if (relevance.includes('*')) {
46
+ result[name] = entry;
47
+ } else if (relevance.some(s => (normalizedStack && normalizedStack.includes(s)) || tags.includes(s))) {
48
+ result[name] = entry;
49
+ }
50
+ }
51
+
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * Match detected MCP servers against the registry
57
+ * @param {Array} existingServers - From detectClaudeConfig().mcpServers
58
+ * @param {Object} registry - MCP registry
59
+ * @returns {Object} Map of mcpName → { installed: true, source }
60
+ */
61
+ export function getInstalledMcps(existingServers, registry) {
62
+ const installed = {};
63
+ const registryNames = Object.keys(registry.mcps);
64
+
65
+ for (const server of existingServers) {
66
+ const serverName = server.name.toLowerCase();
67
+ for (const regName of registryNames) {
68
+ if (serverName.includes(regName)) {
69
+ installed[regName] = { installed: true, source: server.source };
70
+ }
71
+ }
72
+ }
73
+
74
+ return installed;
75
+ }
76
+
77
+ /**
78
+ * Check if prerequisites are met for an MCP entry
79
+ * @param {Object} mcpEntry - MCP entry from registry
80
+ * @returns {Array} Array of { name, met, installUrl } for each prerequisite
81
+ */
82
+ export function checkPrerequisites(mcpEntry) {
83
+ const prereqs = mcpEntry.install?.prerequisites || [];
84
+ const results = [];
85
+
86
+ for (const prereq of prereqs) {
87
+ let met = false;
88
+ try {
89
+ execSync(prereq.checkCommand, { stdio: 'ignore', timeout: 5000 });
90
+ met = true;
91
+ } catch {
92
+ // Command failed or not found
93
+ }
94
+ results.push({
95
+ name: prereq.name,
96
+ met,
97
+ installUrl: prereq.installUrl
98
+ });
99
+ }
100
+
101
+ return results;
102
+ }
103
+
104
+ /**
105
+ * Check if an MCP config uses remote HTTP transport (no local process).
106
+ * @param {Object} config - MCP server config
107
+ * @returns {boolean}
108
+ */
109
+ export function isRemoteMcp(config) {
110
+ return config.type === 'http' || config.url != null;
111
+ }
112
+
113
+ /**
114
+ * Patch MCP config for the current platform.
115
+ * On Windows, npx must be invoked via `cmd /c` to avoid ENOENT errors.
116
+ * Registry already stores cmd/c format; this handles legacy configs that still use npx directly.
117
+ * Remote HTTP MCPs are returned as-is (no local process to patch).
118
+ * @param {Object} config - MCP server config { command, args, env? } or { type: "http", url }
119
+ * @returns {Object} Patched config (new object, original unmodified)
120
+ */
121
+ export function patchConfigForPlatform(config) {
122
+ // Remote MCPs need no platform patching
123
+ if (isRemoteMcp(config)) return config;
124
+ if (config.command !== 'npx') return config;
125
+ return {
126
+ ...config,
127
+ command: 'cmd',
128
+ args: ['/c', 'npx', ...(config.args || [])]
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Install auto-installable MCPs (no credentials, no prerequisites)
134
+ * @param {string} targetPath - Project root directory
135
+ * @param {Object} mcpsToInstall - Map of mcpName → mcpEntry
136
+ * @returns {Promise<Object>} Result from installMcpServers
137
+ */
138
+ export async function installAutoMcps(targetPath, mcpsToInstall) {
139
+ const servers = {};
140
+
141
+ for (const [name, entry] of Object.entries(mcpsToInstall)) {
142
+ let config = { ...entry.install.config };
143
+ // Remote MCPs don't have env to strip
144
+ if (!isRemoteMcp(config)) {
145
+ // Only include env if it has actual values
146
+ if (config.env && Object.values(config.env).every(v => !v)) {
147
+ delete config.env;
148
+ }
149
+ }
150
+ servers[name] = patchConfigForPlatform(config);
151
+ }
152
+
153
+ return installMcpServers(targetPath, servers);
154
+ }
155
+
156
+ /**
157
+ * Install companion skills for MCPs that define them (e.g., Stitch agent skills).
158
+ * Reads the `companionSkills` field from registry entries and runs the install command.
159
+ * @param {Object} mcpsInstalled - Map of mcpName → mcpEntry that were just installed
160
+ * @param {Function} exec - execSync function (for testing)
161
+ * @returns {Array<{name: string, repo: string, success: boolean}>}
162
+ */
163
+ export function installCompanionSkills(mcpsInstalled, exec = execSync) {
164
+ const results = [];
165
+
166
+ for (const [name, entry] of Object.entries(mcpsInstalled)) {
167
+ const companion = entry.companionSkills;
168
+ if (!companion?.installCommand) continue;
169
+
170
+ try {
171
+ exec(companion.installCommand, { stdio: 'inherit', timeout: 30000 });
172
+ results.push({ name, repo: companion.repo, success: true });
173
+ } catch {
174
+ results.push({ name, repo: companion.repo, success: false });
175
+ }
176
+ }
177
+
178
+ return results;
179
+ }
180
+
181
+ /**
182
+ * Generate the Stitch OAuth proxy template script.
183
+ * Cross-platform (Windows gcloud.cmd + Unix gcloud), fetch()-based,
184
+ * with readline, session tracking, and graceful shutdown.
185
+ * @param {string} gcpProject - GCP project ID for x-goog-user-project header
186
+ * @returns {string} CJS proxy script content
187
+ */
188
+ export function getStitchProxyTemplate(gcpProject) {
189
+ return `#!/usr/bin/env node
190
+ /**
191
+ * Stitch MCP stdio-to-HTTP proxy with Google auth
192
+ * Generated by morph-spec mcp setup stitch
193
+ */
194
+ const { execSync } = require('child_process');
195
+ const { createInterface } = require('readline');
196
+
197
+ const STITCH_URL = 'https://stitch.googleapis.com/mcp';
198
+ const PROJECT_ID = '${gcpProject}';
199
+
200
+ // Cross-platform gcloud path: Windows uses gcloud.cmd from Cloud SDK install
201
+ const GCLOUD = process.platform === 'win32'
202
+ ? (process.env.LOCALAPPDATA || '') + '/Google/Cloud SDK/google-cloud-sdk/bin/gcloud.cmd'
203
+ : 'gcloud';
204
+
205
+ let cachedToken = null;
206
+ let tokenExpiry = 0;
207
+ let pendingRequests = 0;
208
+ let stdinClosed = false;
209
+ let sessionId = null;
210
+
211
+ function getToken() {
212
+ if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
213
+ try {
214
+ cachedToken = execSync(\`"\${GCLOUD}" auth application-default print-access-token\`, {
215
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
216
+ }).trim();
217
+ tokenExpiry = Date.now() + 50 * 60 * 1000; // 50-minute TTL
218
+ return cachedToken;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ function maybeExit() {
225
+ if (stdinClosed && pendingRequests === 0) process.exit(0);
226
+ }
227
+
228
+ async function handleMessage(line) {
229
+ let msg;
230
+ try { msg = JSON.parse(line); } catch { return; }
231
+
232
+ // Notifications (no id) don't need responses
233
+ if (msg.id === undefined && msg.method === 'notifications/initialized') return;
234
+
235
+ const token = getToken();
236
+ if (!token) {
237
+ if (msg.id !== undefined) {
238
+ process.stdout.write(JSON.stringify({
239
+ jsonrpc: '2.0', id: msg.id,
240
+ error: { code: -32000, message: 'Failed to get gcloud auth token. Run: gcloud auth application-default login' }
241
+ }) + '\\n');
242
+ }
243
+ return;
244
+ }
245
+
246
+ const headers = {
247
+ 'Content-Type': 'application/json',
248
+ 'Authorization': \`Bearer \${token}\`,
249
+ 'x-goog-user-project': PROJECT_ID,
250
+ };
251
+ if (sessionId) headers['mcp-session-id'] = sessionId;
252
+
253
+ pendingRequests++;
254
+ try {
255
+ const res = await fetch(STITCH_URL, { method: 'POST', headers, body: JSON.stringify(msg) });
256
+
257
+ // Track sticky session
258
+ const sid = res.headers.get('mcp-session-id');
259
+ if (sid) sessionId = sid;
260
+
261
+ // Retry once on 401 (expired token)
262
+ if (res.status === 401) {
263
+ cachedToken = null;
264
+ tokenExpiry = 0;
265
+ const retryToken = getToken();
266
+ if (retryToken) {
267
+ headers['Authorization'] = \`Bearer \${retryToken}\`;
268
+ const retryRes = await fetch(STITCH_URL, { method: 'POST', headers, body: JSON.stringify(msg) });
269
+ const retryText = await retryRes.text();
270
+ if (retryText.trim()) {
271
+ process.stdout.write(retryText.endsWith('\\n') ? retryText : retryText + '\\n');
272
+ }
273
+ return;
274
+ }
275
+ }
276
+
277
+ const text = await res.text();
278
+ if (text.trim()) {
279
+ process.stdout.write(text.endsWith('\\n') ? text : text + '\\n');
280
+ }
281
+ } catch (err) {
282
+ if (msg.id !== undefined) {
283
+ process.stdout.write(JSON.stringify({
284
+ jsonrpc: '2.0', id: msg.id,
285
+ error: { code: -32000, message: \`Stitch API error: \${err.message}\` }
286
+ }) + '\\n');
287
+ }
288
+ } finally {
289
+ pendingRequests--;
290
+ maybeExit();
291
+ }
292
+ }
293
+
294
+ const rl = createInterface({ input: process.stdin });
295
+ rl.on('line', handleMessage);
296
+ rl.on('close', () => { stdinClosed = true; maybeExit(); });
297
+ `;
298
+ }
299
+
300
+ /**
301
+ * Generate and write the Stitch OAuth proxy script to the target project.
302
+ * @param {string} targetPath - Project root directory
303
+ * @param {string} gcpProject - GCP project ID
304
+ * @returns {{ proxyPath: string }} Path to the generated proxy script
305
+ */
306
+ export function generateStitchProxy(targetPath, gcpProject) {
307
+ const claudeDir = join(targetPath, '.claude');
308
+ if (!existsSync(claudeDir)) {
309
+ mkdirSync(claudeDir, { recursive: true });
310
+ }
311
+
312
+ const proxyPath = join(claudeDir, 'stitch-proxy.cjs');
313
+ writeFileSync(proxyPath, getStitchProxyTemplate(gcpProject), 'utf-8');
314
+
315
+ return { proxyPath };
316
+ }
317
+
318
+ /**
319
+ * Resolve the gcloud CLI binary path.
320
+ * Checks: system PATH → bundled at ~/.stitch-mcp/ → null.
321
+ * @param {Function} exec - execSync function (for testing)
322
+ * @returns {{ gcloudPath: string|null, source: string }}
323
+ */
324
+ export function findGcloud(exec = execSync) {
325
+ // Try system PATH first
326
+ try {
327
+ exec('gcloud --version', { stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 });
328
+ return { gcloudPath: 'gcloud', source: 'system' };
329
+ } catch { /* not on PATH */ }
330
+
331
+ // Windows: try Cloud SDK default install location
332
+ if (process.platform === 'win32') {
333
+ const winPath = join(process.env.LOCALAPPDATA || '', 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin', 'gcloud.cmd');
334
+ if (existsSync(winPath)) {
335
+ return { gcloudPath: winPath, source: 'windows-sdk' };
336
+ }
337
+ }
338
+
339
+ // Try bundled at ~/.stitch-mcp/
340
+ const bundledDir = join(homedir(), '.stitch-mcp');
341
+ const bundledBin = process.platform === 'win32'
342
+ ? join(bundledDir, 'google-cloud-sdk', 'bin', 'gcloud.cmd')
343
+ : join(bundledDir, 'google-cloud-sdk', 'bin', 'gcloud');
344
+ if (existsSync(bundledBin)) {
345
+ return { gcloudPath: bundledBin, source: 'bundled' };
346
+ }
347
+
348
+ return { gcloudPath: null, source: 'not-found' };
349
+ }
350
+
351
+ /**
352
+ * Fully automated Stitch MCP setup orchestrator.
353
+ *
354
+ * Steps:
355
+ * 1. Find gcloud CLI
356
+ * 2. Verify ADC credentials
357
+ * 3. Detect GCP project
358
+ * 4. Enable stitch.googleapis.com API
359
+ * 5. Generate proxy script
360
+ * 6. Install MCP config in .mcp.json
361
+ * 7. Health-check the proxy
362
+ *
363
+ * @param {string} targetPath - Project root directory
364
+ * @param {Object} options
365
+ * @param {string} [options.gcpProject] - GCP project ID (auto-detected if omitted)
366
+ * @param {Function} [options._exec] - execSync override for testing
367
+ * @returns {Promise<Object>} Step-by-step results
368
+ */
369
+ export async function setupStitch(targetPath, options = {}) {
370
+ const exec = options._exec || execSync;
371
+ const steps = [];
372
+
373
+ // Step 1: Find gcloud
374
+ const { gcloudPath, source: gcloudSource } = findGcloud(exec);
375
+ if (!gcloudPath) {
376
+ steps.push({ step: 'find-gcloud', success: false, error: 'gcloud CLI not found. Install from: https://cloud.google.com/sdk/docs/install' });
377
+ return { success: false, steps };
378
+ }
379
+ steps.push({ step: 'find-gcloud', success: true, gcloudPath, source: gcloudSource });
380
+
381
+ const gcloudCmd = gcloudPath.includes(' ') ? `"${gcloudPath}"` : gcloudPath;
382
+
383
+ // Step 2: Verify ADC credentials
384
+ try {
385
+ exec(`${gcloudCmd} auth application-default print-access-token`, {
386
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000
387
+ });
388
+ steps.push({ step: 'verify-adc', success: true });
389
+ } catch {
390
+ steps.push({ step: 'verify-adc', success: false, error: 'No ADC credentials. Run: gcloud auth application-default login' });
391
+ return { success: false, steps };
392
+ }
393
+
394
+ // Step 3: Detect GCP project
395
+ let gcpProject = options.gcpProject;
396
+ if (!gcpProject) {
397
+ // Try config.json
398
+ const configPath = join(targetPath, '.morph', 'config', 'config.json');
399
+ if (existsSync(configPath)) {
400
+ try {
401
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
402
+ gcpProject = config.gcpProject || config.project?.gcpProject;
403
+ } catch { /* invalid config */ }
404
+ }
405
+ }
406
+ if (!gcpProject) {
407
+ // Try gcloud config
408
+ try {
409
+ gcpProject = exec(`${gcloudCmd} config get-value project`, {
410
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
411
+ }).trim();
412
+ if (!gcpProject || gcpProject === '(unset)') gcpProject = null;
413
+ } catch { /* gcloud config failed */ }
414
+ }
415
+ if (!gcpProject) {
416
+ steps.push({ step: 'detect-project', success: false, error: 'Could not detect GCP project. Pass it explicitly or set via: gcloud config set project <id>' });
417
+ return { success: false, steps };
418
+ }
419
+ steps.push({ step: 'detect-project', success: true, gcpProject });
420
+
421
+ // Step 4: Enable Stitch API
422
+ try {
423
+ exec(`${gcloudCmd} services enable stitch.googleapis.com --project=${gcpProject}`, {
424
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000
425
+ });
426
+ steps.push({ step: 'enable-api', success: true });
427
+ } catch (err) {
428
+ steps.push({ step: 'enable-api', success: false, error: `Failed to enable stitch API: ${err.message}` });
429
+ // Non-fatal — API might already be enabled and user may lack permissions to re-enable
430
+ }
431
+
432
+ // Step 5: Generate proxy
433
+ const { proxyPath } = generateStitchProxy(targetPath, gcpProject);
434
+ steps.push({ step: 'generate-proxy', success: true, proxyPath });
435
+
436
+ // Step 6: Install MCP config in .mcp.json
437
+ const mcpResult = await installMcpServers(targetPath, {
438
+ stitch: { type: 'stdio', command: 'node', args: ['.claude/stitch-proxy.cjs'] }
439
+ });
440
+ steps.push({ step: 'install-mcp', success: true, added: mcpResult.added, skipped: mcpResult.skipped });
441
+
442
+ // Step 7: Health-check
443
+ const healthResult = await healthCheckStitchProxy(proxyPath);
444
+ steps.push({ step: 'health-check', ...healthResult });
445
+
446
+ const allCriticalPassed = steps.every(s =>
447
+ s.success || s.step === 'enable-api' || s.step === 'health-check'
448
+ );
449
+
450
+ return { success: allCriticalPassed, steps, gcpProject };
451
+ }
452
+
453
+ /**
454
+ * Health-check the Stitch proxy by sending an MCP initialize handshake.
455
+ * @param {string} proxyPath - Path to stitch-proxy.cjs
456
+ * @returns {Promise<Object>} { success, protocolVersion?, error? }
457
+ */
458
+ export function healthCheckStitchProxy(proxyPath) {
459
+ return new Promise((resolve) => {
460
+ const initMsg = JSON.stringify({
461
+ jsonrpc: '2.0',
462
+ id: 1,
463
+ method: 'initialize',
464
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'morph-spec', version: '1.0.0' } }
465
+ });
466
+
467
+ const child = spawn('node', [proxyPath], {
468
+ stdio: ['pipe', 'pipe', 'pipe'],
469
+ timeout: 20000,
470
+ });
471
+
472
+ let output = '';
473
+ let timedOut = false;
474
+
475
+ const timer = setTimeout(() => {
476
+ timedOut = true;
477
+ child.kill();
478
+ resolve({ success: false, error: 'Health-check timed out after 20s' });
479
+ }, 20000);
480
+
481
+ child.stdout.on('data', (data) => {
482
+ output += data.toString();
483
+ // Check if we got a response
484
+ const lines = output.split('\n').filter(l => l.trim());
485
+ for (const line of lines) {
486
+ try {
487
+ const resp = JSON.parse(line);
488
+ if (resp.result?.protocolVersion) {
489
+ clearTimeout(timer);
490
+ child.kill();
491
+ resolve({ success: true, protocolVersion: resp.result.protocolVersion });
492
+ return;
493
+ }
494
+ if (resp.error) {
495
+ clearTimeout(timer);
496
+ child.kill();
497
+ resolve({ success: false, error: resp.error.message || 'MCP error' });
498
+ return;
499
+ }
500
+ } catch { /* not valid JSON yet */ }
501
+ }
502
+ });
503
+
504
+ child.on('error', (err) => {
505
+ if (!timedOut) {
506
+ clearTimeout(timer);
507
+ resolve({ success: false, error: err.message });
508
+ }
509
+ });
510
+
511
+ child.on('close', (code) => {
512
+ if (!timedOut) {
513
+ clearTimeout(timer);
514
+ resolve({ success: false, error: output.trim()
515
+ ? `Proxy exited with code ${code} — unexpected output`
516
+ : `Proxy exited with code ${code} and no output`
517
+ });
518
+ }
519
+ });
520
+
521
+ // Send the initialize message
522
+ child.stdin.write(initMsg + '\n');
523
+ child.stdin.end();
524
+ });
525
+ }
526
+
527
+ /**
528
+ * Install a single MCP with provided credentials
529
+ * @param {string} targetPath - Project root directory
530
+ * @param {string} name - MCP name
531
+ * @param {Object} mcpEntry - MCP entry from registry
532
+ * @param {Object} credentialValues - Map of envVar → value
533
+ * @returns {Promise<Object>} Result from installMcpServers
534
+ */
535
+ export async function installMcpWithCredentials(targetPath, name, mcpEntry, credentialValues) {
536
+ const config = patchConfigForPlatform({ ...mcpEntry.install.config });
537
+ config.env = { ...config.env, ...credentialValues };
538
+ return installMcpServers(targetPath, { [name]: config });
539
+ }
540
+
541
+ /**
542
+ * Generate setup instructions for a credential-requiring MCP
543
+ * @param {string} name - MCP name
544
+ * @param {Object} mcpEntry - MCP entry from registry
545
+ * @returns {Object} { configSnippet, credentialUrls, cliCommand }
546
+ */
547
+ export function generateSetupInstructions(name, mcpEntry) {
548
+ const config = mcpEntry.install.config;
549
+ const credentials = mcpEntry.install.credentials || [];
550
+ const setupGuide = mcpEntry.install.setupGuide;
551
+
552
+ // Remote HTTP MCPs — show claude mcp add command instead of config snippet
553
+ if (isRemoteMcp(config)) {
554
+ const configSnippet = JSON.stringify({ [name]: config }, null, 2);
555
+ const credentialUrls = credentials.map(c => ({
556
+ name: c.name,
557
+ envVar: c.envVar,
558
+ helpUrl: c.helpUrl
559
+ }));
560
+ return {
561
+ configSnippet,
562
+ credentialUrls,
563
+ cliCommand: setupGuide || `claude mcp add --transport http ${name} ${config.url}`,
564
+ isRemote: true
565
+ };
566
+ }
567
+
568
+ // Build the config snippet with placeholder values
569
+ const snippetConfig = { ...config };
570
+ if (credentials.length > 0) {
571
+ snippetConfig.env = {};
572
+ for (const cred of credentials) {
573
+ snippetConfig.env[cred.envVar] = cred.secret ? 'YOUR_KEY' : 'YOUR_VALUE';
574
+ }
575
+ }
576
+
577
+ const configSnippet = JSON.stringify({ [name]: snippetConfig }, null, 2);
578
+
579
+ const credentialUrls = credentials.map(c => ({
580
+ name: c.name,
581
+ envVar: c.envVar,
582
+ helpUrl: c.helpUrl
583
+ }));
584
+
585
+ return {
586
+ configSnippet,
587
+ credentialUrls,
588
+ cliCommand: `morph-spec mcp setup ${name}`
589
+ };
590
+ }
591
+
592
+ /**
593
+ * Full orchestrator for MCP setup during init or `mcp setup`
594
+ * @param {string} targetPath - Project root directory
595
+ * @param {string} stack - Detected project stack
596
+ * @param {Array} existingMcps - From detectClaudeConfig().mcpServers
597
+ * @param {Object} options
598
+ * @param {boolean} options.autoOnly - Only handle auto-installable MCPs
599
+ * @param {string[]} [options.integrations] - `project.integrations`, matched like the stack
600
+ * @returns {Object} { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing }
601
+ */
602
+ export function orchestrateMcpSetup(targetPath, stack, existingMcps, options = {}) {
603
+ const registry = loadMcpRegistry();
604
+ const relevant = filterByStack(registry, stack, options.integrations ?? []);
605
+ const installed = getInstalledMcps(existingMcps || [], registry);
606
+
607
+ const autoInstallable = {};
608
+ const alreadyInstalled = {};
609
+ const needsManualSetup = {};
610
+ const notRelevant = {};
611
+ const prerequisitesMissing = {};
612
+
613
+ // Categorize all MCPs
614
+ for (const [name, entry] of Object.entries(registry.mcps)) {
615
+ if (!entry.install) continue;
616
+
617
+ // Check if already installed
618
+ if (installed[name]) {
619
+ alreadyInstalled[name] = { entry, source: installed[name].source };
620
+ continue;
621
+ }
622
+
623
+ // Check if relevant to stack
624
+ if (!relevant[name]) {
625
+ notRelevant[name] = entry;
626
+ continue;
627
+ }
628
+
629
+ // Check prerequisites
630
+ const prereqResults = checkPrerequisites(entry);
631
+ const unmetPrereqs = prereqResults.filter(p => !p.met);
632
+ if (unmetPrereqs.length > 0) {
633
+ prerequisitesMissing[name] = { entry, unmet: unmetPrereqs };
634
+ continue;
635
+ }
636
+
637
+ // Categorize by install type
638
+ if (entry.install.autoInstall) {
639
+ autoInstallable[name] = entry;
640
+ } else if (!options.autoOnly) {
641
+ needsManualSetup[name] = entry;
642
+ }
643
+ }
644
+
645
+ return {
646
+ registry,
647
+ autoInstallable,
648
+ alreadyInstalled,
649
+ needsManualSetup,
650
+ notRelevant,
651
+ prerequisitesMissing
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Format MCP status table for display
657
+ * @param {Object} orchestration - Result from orchestrateMcpSetup
658
+ * @param {Object} justInstalled - Map of names just installed during this run
659
+ * @returns {Array<Object>} Array of { name, status, detail }
660
+ */
661
+ export function formatMcpStatusTable(orchestration, justInstalled = {}) {
662
+ const rows = [];
663
+
664
+ const { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing, registry } = orchestration;
665
+
666
+ // Installed during this run
667
+ for (const [name, entry] of Object.entries(justInstalled)) {
668
+ const mcpEntry = registry.mcps[name];
669
+ rows.push({
670
+ name,
671
+ status: 'installed',
672
+ detail: mcpEntry ? mcpEntry.usage : ''
673
+ });
674
+ }
675
+
676
+ // Auto-installable but not yet installed (and not just-installed)
677
+ for (const [name, entry] of Object.entries(autoInstallable)) {
678
+ if (justInstalled[name]) continue;
679
+ rows.push({
680
+ name,
681
+ status: 'available',
682
+ detail: `Run: morph-spec mcp setup --auto`
683
+ });
684
+ }
685
+
686
+ // Already installed
687
+ for (const [name, { source }] of Object.entries(alreadyInstalled)) {
688
+ rows.push({
689
+ name,
690
+ status: 'already_configured',
691
+ detail: `(${source})`
692
+ });
693
+ }
694
+
695
+ // Needs manual setup
696
+ for (const [name, entry] of Object.entries(needsManualSetup)) {
697
+ rows.push({
698
+ name,
699
+ status: 'needs_setup',
700
+ detail: `Run: morph-spec mcp setup ${name}`
701
+ });
702
+ }
703
+
704
+ // Prerequisites missing
705
+ for (const [name, { unmet }] of Object.entries(prerequisitesMissing)) {
706
+ const missing = unmet.map(p => p.name).join(', ');
707
+ rows.push({
708
+ name,
709
+ status: 'prereq_missing',
710
+ detail: `Missing: ${missing}`
711
+ });
712
+ }
713
+
714
+ // Not relevant
715
+ for (const [name] of Object.entries(notRelevant)) {
716
+ rows.push({
717
+ name,
718
+ status: 'not_relevant',
719
+ detail: 'not relevant to stack'
720
+ });
721
+ }
722
+
723
+ return rows;
724
+ }