terramend 0.2.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 (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { resolveCliModel } from "#app/models";
3
+ import {
4
+ buildUnavailableModelError,
5
+ FREE_FALLBACK_SLUG,
6
+ hasProviderKeyForModel,
7
+ selectFallbackModelIfNeeded,
8
+ } from "#app/utils/byokFallback";
9
+
10
+ describe("FREE_FALLBACK_SLUG", () => {
11
+ it("resolves in the curated catalog", () => {
12
+ expect(resolveCliModel(FREE_FALLBACK_SLUG)).toBe("opencode/big-pickle");
13
+ });
14
+
15
+ it("is opencode/big-pickle", () => {
16
+ expect(FREE_FALLBACK_SLUG).toBe("opencode/big-pickle");
17
+ });
18
+ });
19
+
20
+ describe("selectFallbackModelIfNeeded", () => {
21
+ const empty = new Set<string>();
22
+
23
+ it("falls back to free when the model is unauthorized AND no provider key is present", () => {
24
+ const result = selectFallbackModelIfNeeded({
25
+ resolvedModel: "anthropic/claude-opus-4-7",
26
+ authorized: empty,
27
+ providerKeyPresent: false,
28
+ agentName: "opencode",
29
+ });
30
+ expect(result).toEqual({
31
+ kind: "fallback",
32
+ from: "anthropic/claude-opus-4-7",
33
+ to: FREE_FALLBACK_SLUG,
34
+ });
35
+ });
36
+
37
+ it("uses the resolved model when the claude harness serves it", () => {
38
+ // opencode models can't see CLAUDE_CODE_OAUTH_TOKEN, so `authorized` is
39
+ // empty for anthropic/* — and the OAuth token counts as a present provider
40
+ // key, which would land in `unavailable` and fail the run. resolveAgent
41
+ // picks the claude agent, which brings its own auth; the gate must defer.
42
+ const result = selectFallbackModelIfNeeded({
43
+ resolvedModel: "anthropic/claude-opus-4-8",
44
+ authorized: empty,
45
+ providerKeyPresent: true,
46
+ agentName: "claude",
47
+ });
48
+ expect(result).toEqual({ kind: "use-resolved" });
49
+ });
50
+
51
+ it("reports unavailable (does NOT downgrade) when a provider key IS present but the model is unauthorized", () => {
52
+ // PR #2 scenario: Google key set, but the configured Google model id isn't
53
+ // one OpenCode can route → fail loudly instead of silently serving free.
54
+ const result = selectFallbackModelIfNeeded({
55
+ resolvedModel: "google/gemini-3.5-flash-lite",
56
+ authorized: new Set(["google/gemini-3.5-flash", "google/gemini-3.1-pro"]),
57
+ providerKeyPresent: true,
58
+ agentName: "opencode",
59
+ });
60
+ expect(result).toEqual({ kind: "unavailable", model: "google/gemini-3.5-flash-lite" });
61
+ });
62
+
63
+ it("uses the resolved model when it IS authorized (regardless of key presence)", () => {
64
+ const result = selectFallbackModelIfNeeded({
65
+ resolvedModel: "anthropic/claude-opus-4-7",
66
+ authorized: new Set(["anthropic/claude-opus-4-7"]),
67
+ providerKeyPresent: true,
68
+ agentName: "opencode",
69
+ });
70
+ expect(result).toEqual({ kind: "use-resolved" });
71
+ });
72
+
73
+ it("uses the resolved model when none is resolved (auto-select path)", () => {
74
+ const result = selectFallbackModelIfNeeded({
75
+ resolvedModel: undefined,
76
+ authorized: empty,
77
+ providerKeyPresent: false,
78
+ agentName: "opencode",
79
+ });
80
+ expect(result).toEqual({ kind: "use-resolved" });
81
+ });
82
+
83
+ it("uses the resolved model when it is itself the free fallback", () => {
84
+ const result = selectFallbackModelIfNeeded({
85
+ resolvedModel: FREE_FALLBACK_SLUG,
86
+ authorized: empty,
87
+ providerKeyPresent: false,
88
+ agentName: "opencode",
89
+ });
90
+ expect(result).toEqual({ kind: "use-resolved" });
91
+ });
92
+
93
+ it("uses the resolved model for Bedrock routing (raw model ID has no slash)", () => {
94
+ // resolveModel({slug:"bedrock/byok"}) returns the raw BEDROCK_MODEL_ID
95
+ // value (e.g. "eu.anthropic.claude-opus-4-7"), which has no `/`. the
96
+ // routing validator (validateBedrockSetup) owns auth + region + model-id
97
+ // checking for this path, not the BYOK fallback gate.
98
+ const result = selectFallbackModelIfNeeded({
99
+ resolvedModel: "eu.anthropic.claude-opus-4-7",
100
+ authorized: empty,
101
+ providerKeyPresent: true,
102
+ agentName: "claude",
103
+ });
104
+ expect(result).toEqual({ kind: "use-resolved" });
105
+ });
106
+
107
+ it("the no-slash skip holds on its own (opencode agent, key present)", () => {
108
+ // distinguishes the raw-ID guard from the claude-agent guard right after
109
+ // it: with agentName "opencode" + a present key, deleting the no-slash
110
+ // check would mis-route this to `unavailable` and fail the run.
111
+ const result = selectFallbackModelIfNeeded({
112
+ resolvedModel: "amazon.titan-text-express-v1",
113
+ authorized: empty,
114
+ providerKeyPresent: true,
115
+ agentName: "opencode",
116
+ });
117
+ expect(result).toEqual({ kind: "use-resolved" });
118
+ });
119
+
120
+ it("uses the resolved model when stored minimax-m2.5-free resolves to big-pickle", () => {
121
+ const result = selectFallbackModelIfNeeded({
122
+ resolvedModel: resolveCliModel("opencode/minimax-m2.5-free"),
123
+ authorized: empty,
124
+ providerKeyPresent: false,
125
+ agentName: "opencode",
126
+ });
127
+ expect(result).toEqual({ kind: "use-resolved" });
128
+ });
129
+ });
130
+
131
+ describe("hasProviderKeyForModel", () => {
132
+ afterEach(() => {
133
+ vi.unstubAllEnvs();
134
+ });
135
+
136
+ it("is true when a Google key is present for a resolved Google model", () => {
137
+ vi.stubEnv("GOOGLE_GENERATIVE_AI_API_KEY", "xxx");
138
+ expect(hasProviderKeyForModel("google/gemini-3.5-flash-lite")).toBe(true);
139
+ });
140
+
141
+ it("is true for the alternate Google key env var", () => {
142
+ vi.stubEnv("GEMINI_API_KEY", "xxx");
143
+ expect(hasProviderKeyForModel("google/gemini-3.1-pro-preview")).toBe(true);
144
+ });
145
+
146
+ it("is false when no key for that provider is present", () => {
147
+ vi.stubEnv("GEMINI_API_KEY", "");
148
+ vi.stubEnv("GOOGLE_GENERATIVE_AI_API_KEY", "");
149
+ expect(hasProviderKeyForModel("google/gemini-3.5-flash-lite")).toBe(false);
150
+ });
151
+
152
+ it("detects the Anthropic OAuth token shape as a present key", () => {
153
+ vi.stubEnv("ANTHROPIC_API_KEY", "");
154
+ vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", "tok");
155
+ expect(hasProviderKeyForModel("anthropic/claude-opus-4-8")).toBe(true);
156
+ });
157
+
158
+ it("is false for an unknown provider (no catalog env vars)", () => {
159
+ expect(hasProviderKeyForModel("madeup/model")).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe("buildUnavailableModelError", () => {
164
+ it("lists same-provider authorized models and names the provider", () => {
165
+ const msg = buildUnavailableModelError({
166
+ model: "google/gemini-3.5-flash-lite",
167
+ authorized: new Set([
168
+ "google/gemini-3.5-flash",
169
+ "google/gemini-3.1-pro",
170
+ "anthropic/claude-opus-4-8",
171
+ ]),
172
+ });
173
+ expect(msg).toContain(
174
+ 'model "google/gemini-3.5-flash-lite" is not available to your Google key',
175
+ );
176
+ expect(msg).toContain(" - google/gemini-3.5-flash");
177
+ expect(msg).toContain(" - google/gemini-3.1-pro");
178
+ // unrelated provider is filtered out when same-provider matches exist
179
+ expect(msg).not.toContain("anthropic/claude-opus-4-8");
180
+ });
181
+
182
+ it("falls back to the full authorized list when no same-provider model is authorized", () => {
183
+ const msg = buildUnavailableModelError({
184
+ model: "google/gemini-3.5-flash-lite",
185
+ authorized: new Set(["anthropic/claude-opus-4-8"]),
186
+ });
187
+ expect(msg).toContain(" - anthropic/claude-opus-4-8");
188
+ });
189
+
190
+ it("lists the authorized models sorted, not in Set insertion order", () => {
191
+ const msg = buildUnavailableModelError({
192
+ model: "google/gemini-3.5-flash-lite",
193
+ authorized: new Set(["google/z-model", "google/a-model"]),
194
+ });
195
+ expect(msg.indexOf("google/a-model")).toBeLessThan(msg.indexOf("google/z-model"));
196
+ });
197
+
198
+ it("handles an empty authorized set without throwing", () => {
199
+ const msg = buildUnavailableModelError({
200
+ model: "google/gemini-3.5-flash-lite",
201
+ authorized: new Set(),
202
+ });
203
+ expect(msg).toContain("does not authorize any model");
204
+ });
205
+ });
@@ -0,0 +1,128 @@
1
+ import type { AgentId } from "#app/external";
2
+ import { getModelEnvVars, getProviderDisplayName } from "#app/models";
3
+
4
+ /**
5
+ * Slug we fall back to when a BYOK-required model is configured but the
6
+ * runner has no provider key in env. Picked because it's free, stable, and
7
+ * currently served by OpenCode Zen without a key.
8
+ *
9
+ * The slug is intentionally hard-coded and not a config knob — the
10
+ * fallback is a safety net, not a user-facing preference, and adding a
11
+ * config surface here would just push the same "what to fall back to"
12
+ * decision into another setting that goes stale the same way.
13
+ */
14
+ export const FREE_FALLBACK_SLUG = "opencode/big-pickle";
15
+
16
+ /**
17
+ * Outcome of the BYOK model gate.
18
+ *
19
+ * - `use-resolved`: run the configured model as-is.
20
+ * - `fallback`: the runner has NO provider key for this model's provider,
21
+ * so swap to the free OpenCode slug — a genuine no-key safety net.
22
+ * - `unavailable`: a provider key IS present but the configured model is
23
+ * not one OpenCode can route with it. This is almost always a wrong or
24
+ * mistyped model id (or a key scoped to other models). We must NOT
25
+ * silently downgrade to the free model — that hides the misconfiguration
26
+ * and produces a free run the user didn't ask for (see PR #2). The caller
27
+ * fails loudly with the authorized-model list instead.
28
+ */
29
+ export type FallbackDecision =
30
+ | { kind: "use-resolved" }
31
+ | { kind: "fallback"; from: string; to: string }
32
+ | { kind: "unavailable"; model: string };
33
+
34
+ function hasEnvVar(name: string): boolean {
35
+ const value = process.env[name];
36
+ return typeof value === "string" && value.length > 0;
37
+ }
38
+
39
+ /**
40
+ * Does the runner have a provider credential for `resolvedModel`'s provider?
41
+ *
42
+ * Uses the provider→envVars map in `models.ts` (provider-level, since a
43
+ * resolved specifier like `google/gemini-3.5-flash-lite` won't match a catalog
44
+ * model key and correctly falls through to the provider's env vars). A present
45
+ * key is what distinguishes the two not-authorized situations: key present =
46
+ * wrong/unavailable model id (fail loudly); no key = genuine BYOK gap (free
47
+ * fallback).
48
+ */
49
+ export function hasProviderKeyForModel(resolvedModel: string): boolean {
50
+ return getModelEnvVars(resolvedModel).some(hasEnvVar);
51
+ }
52
+
53
+ /**
54
+ * Decide whether to run the configured model, fall back to the free model, or
55
+ * fail because the model is unavailable to the present key.
56
+ *
57
+ * `authorized` is OpenCode's authoritative "what can I route right now"
58
+ * snapshot, captured after Codex auth.json is in place.
59
+ *
60
+ * Skip cases (always `use-resolved`, without consulting `authorized`):
61
+ * - No resolved model: auto-select handles it downstream.
62
+ * - Resolved model is the free fallback already.
63
+ * - Resolved model is a raw Bedrock / Vertex ID (no `/`): the routing
64
+ * validators (`validateBedrockSetup` / `validateVertexSetup`) cover
65
+ * auth + region/location/model-id; `opencode models` does not.
66
+ * - The selected agent is `claude`: the Claude Code harness brings its own
67
+ * auth and `resolveAgent` only returns it when that auth is present.
68
+ * `opencode models` can't see `CLAUDE_CODE_OAUTH_TOKEN`, so without this
69
+ * an OAuth-subscription run on an Anthropic model would land in
70
+ * `unavailable` (the token counts as a present provider key) and fail a
71
+ * run the claude harness serves fine. `validateAgentApiKey` still covers
72
+ * the claude path with its own Anthropic auth check.
73
+ */
74
+ export function selectFallbackModelIfNeeded(input: {
75
+ resolvedModel: string | undefined;
76
+ authorized: Set<string>;
77
+ /** whether a provider key for the resolved model's provider is present in env */
78
+ providerKeyPresent: boolean;
79
+ /** which agent harness `resolveAgent` picks for the resolved model */
80
+ agentName: AgentId;
81
+ }): FallbackDecision {
82
+ if (!input.resolvedModel) return { kind: "use-resolved" };
83
+ if (input.resolvedModel === FREE_FALLBACK_SLUG) return { kind: "use-resolved" };
84
+ if (!input.resolvedModel.includes("/")) return { kind: "use-resolved" };
85
+ if (input.agentName === "claude") return { kind: "use-resolved" };
86
+ if (input.authorized.has(input.resolvedModel)) return { kind: "use-resolved" };
87
+
88
+ // resolved model is NOT in OpenCode's authorized set. split the two cases:
89
+ if (input.providerKeyPresent) {
90
+ // a key for this provider is configured — the model id is wrong or not
91
+ // available to this key. surface it; do not silently serve a free model.
92
+ return { kind: "unavailable", model: input.resolvedModel };
93
+ }
94
+ // no key at all → genuine BYOK gap → free safety net so the run still works.
95
+ return { kind: "fallback", from: input.resolvedModel, to: FREE_FALLBACK_SLUG };
96
+ }
97
+
98
+ /**
99
+ * Loud, actionable error for the `unavailable` decision: a provider key is
100
+ * present but the configured model isn't one the key can serve. Lists the
101
+ * models OpenCode CAN route (same-provider first) so the user can copy a valid
102
+ * slug instead of getting a silent free downgrade.
103
+ */
104
+ export function buildUnavailableModelError(input: {
105
+ model: string;
106
+ authorized: Set<string>;
107
+ }): string {
108
+ const provider = input.model.slice(0, input.model.indexOf("/"));
109
+ const providerLabel = getProviderDisplayName(input.model) ?? provider;
110
+ const all = [...input.authorized].sort();
111
+ const sameProvider = all.filter((m) => m.startsWith(`${provider}/`));
112
+ const shown = sameProvider.length > 0 ? sameProvider : all;
113
+ const list =
114
+ shown.length > 0
115
+ ? shown.map((m) => ` - ${m}`).join("\n")
116
+ : " (none — your key does not authorize any model OpenCode can route)";
117
+
118
+ return [
119
+ `model "${input.model}" is not available to your ${providerLabel} key.`,
120
+ ``,
121
+ `A provider credential is present, so Terramend did not fall back to the free model —`,
122
+ `it surfaces this instead so you can pick a valid id. Models your key can serve:`,
123
+ ``,
124
+ list,
125
+ ``,
126
+ `Set the model (the \`model\` input, or \`TERRAMEND_MODEL\`) to one of the slugs above.`,
127
+ ].join("\n");
128
+ }
@@ -0,0 +1,179 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { resolveModelSlug } from "#app/models";
3
+ import { preflightClaudeSubscription } from "#app/utils/claudeSubscription";
4
+
5
+ type FetchArgs = [input: string | URL | Request, init?: RequestInit];
6
+
7
+ function stubFetch(impl: () => Promise<Response>) {
8
+ const mock = vi.fn(impl);
9
+ vi.stubGlobal("fetch", mock);
10
+ return mock;
11
+ }
12
+
13
+ function firstCall(mock: { mock: { calls: unknown[][] } }): FetchArgs {
14
+ const call = mock.mock.calls[0];
15
+ if (!call) throw new Error("expected fetch to have been called");
16
+ return call as FetchArgs;
17
+ }
18
+
19
+ function requestBody(mock: { mock: { calls: unknown[][] } }): Record<string, unknown> {
20
+ const [, init] = firstCall(mock);
21
+ if (!init || typeof init.body !== "string") throw new Error("expected a string request body");
22
+ return JSON.parse(init.body) as Record<string, unknown>;
23
+ }
24
+
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals();
27
+ });
28
+
29
+ describe("preflightClaudeSubscription", () => {
30
+ it("returns usable on 200 OK and probes with the run's model on the OAuth surface", async () => {
31
+ const mock = stubFetch(async () => new Response("{}", { status: 200 }));
32
+
33
+ const result = await preflightClaudeSubscription({
34
+ token: "oauth-tok",
35
+ model: "claude-fable-5",
36
+ });
37
+
38
+ expect(result).toEqual({ usable: true });
39
+ expect(mock).toHaveBeenCalledTimes(1);
40
+ const [url, init] = firstCall(mock);
41
+ expect(url).toBe("https://api.anthropic.com/v1/messages");
42
+ if (!init) throw new Error("expected a request init");
43
+ expect(init.method).toBe("POST");
44
+ expect(init.headers).toMatchObject({
45
+ authorization: "Bearer oauth-tok",
46
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
47
+ "anthropic-version": "2023-06-01",
48
+ "content-type": "application/json",
49
+ "x-app": "cli",
50
+ });
51
+ expect(requestBody(mock)).toMatchObject({ model: "claude-fable-5", max_tokens: 1 });
52
+ });
53
+
54
+ it("falls back to the registry-resolved haiku probe model when no model is set", async () => {
55
+ const mock = stubFetch(async () => new Response("{}", { status: 200 }));
56
+
57
+ await preflightClaudeSubscription({ token: "oauth-tok", model: undefined });
58
+
59
+ const resolved = resolveModelSlug("anthropic/claude-haiku");
60
+ if (!resolved) throw new Error("anthropic/claude-haiku missing from registry");
61
+ const expected = resolved.slice(resolved.indexOf("/") + 1);
62
+ const body = requestBody(mock);
63
+ expect(body.model).toBe(expected);
64
+ expect(String(body.model)).not.toContain("/");
65
+ });
66
+
67
+ it("marks the token unusable on 401 with the error.message from the JSON body", async () => {
68
+ stubFetch(
69
+ async () =>
70
+ new Response(JSON.stringify({ error: { message: "OAuth token revoked" } }), {
71
+ status: 401,
72
+ }),
73
+ );
74
+
75
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
76
+
77
+ expect(result).toEqual({ usable: false, reason: "401: OAuth token revoked" });
78
+ });
79
+
80
+ it("marks the token unusable on 429 (subscription limit hit)", async () => {
81
+ stubFetch(
82
+ async () =>
83
+ new Response(JSON.stringify({ error: { message: "You've hit your Opus limit" } }), {
84
+ status: 429,
85
+ }),
86
+ );
87
+
88
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-opus-4-8" });
89
+
90
+ expect(result).toEqual({ usable: false, reason: "429: You've hit your Opus limit" });
91
+ });
92
+
93
+ it.each([400, 403, 500, 529])("fails open (usable) on status %d", async (status) => {
94
+ stubFetch(async () => new Response("nope", { status }));
95
+
96
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
97
+
98
+ expect(result).toEqual({ usable: true });
99
+ });
100
+
101
+ it("fails open when fetch itself throws (network error / timeout)", async () => {
102
+ stubFetch(async () => {
103
+ throw new TypeError("fetch failed");
104
+ });
105
+
106
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
107
+
108
+ expect(result).toEqual({ usable: true });
109
+ });
110
+
111
+ it("uses a raw excerpt of a non-JSON 401 body, capped at 200 chars", async () => {
112
+ const html = `<html>upstream error${"x".repeat(300)}</html>`;
113
+ stubFetch(async () => new Response(html, { status: 401 }));
114
+
115
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
116
+
117
+ expect(result.usable).toBe(false);
118
+ if (result.usable) throw new Error("expected an unusable result");
119
+ expect(result.reason).toBe(`401: ${html.slice(0, 200)}`);
120
+ });
121
+
122
+ it("uses the raw excerpt when the JSON body has no error.message string", async () => {
123
+ const body = JSON.stringify({ error: "rate_limited" });
124
+ stubFetch(async () => new Response(body, { status: 429 }));
125
+
126
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
127
+
128
+ expect(result).toEqual({ usable: false, reason: `429: ${body}` });
129
+ });
130
+
131
+ it("degrades to an empty reason body when reading the response body fails", async () => {
132
+ const fake = {
133
+ status: 401,
134
+ text: () => Promise.reject(new Error("body stream interrupted")),
135
+ } as unknown as Response;
136
+ stubFetch(async () => fake);
137
+
138
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
139
+
140
+ expect(result).toEqual({ usable: false, reason: "401: " });
141
+ });
142
+
143
+ it("sends the exact probe body Anthropic's OAuth gate validates", async () => {
144
+ // the system-identity block and 1-token cap are what make the probe both
145
+ // accepted by the OAuth surface and effectively free — pin the full shape.
146
+ const mock = stubFetch(async () => new Response("{}", { status: 200 }));
147
+
148
+ await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
149
+
150
+ expect(requestBody(mock)).toEqual({
151
+ model: "claude-fable-5",
152
+ max_tokens: 1,
153
+ system: "You are Claude Code, Anthropic's official CLI for Claude.",
154
+ messages: [{ role: "user", content: "ok" }],
155
+ });
156
+ });
157
+
158
+ it("uses the raw excerpt when error.message exists but is not a string", async () => {
159
+ const body = JSON.stringify({ error: { message: 42 } });
160
+ stubFetch(async () => new Response(body, { status: 401 }));
161
+
162
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
163
+
164
+ expect(result).toEqual({ usable: false, reason: `401: ${body}` });
165
+ });
166
+
167
+ it.each([
168
+ "null",
169
+ "42",
170
+ '"just a string"',
171
+ JSON.stringify({ no_error_key: 1 }),
172
+ ])("uses the raw excerpt for JSON body %s (no extractable error.message)", async (body) => {
173
+ stubFetch(async () => new Response(body, { status: 429 }));
174
+
175
+ const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
176
+
177
+ expect(result).toEqual({ usable: false, reason: `429: ${body}` });
178
+ });
179
+ });
@@ -0,0 +1,93 @@
1
+ import { resolveModelSlug } from "#app/models";
2
+
3
+ /**
4
+ * identity block Anthropic's OAuth gate validates on `/v1/messages` calls
5
+ * authenticated with a Claude Code OAuth token. haiku is currently exempt
6
+ * but sending it costs nothing and survives the exemption being removed.
7
+ */
8
+ const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
9
+
10
+ // fallback probe model for runs with no explicit model (claude-code picks its
11
+ // own default there, so per-model accuracy is moot — only whether the
12
+ // subscription answers at all). registry-resolved so a catalog bump keeps
13
+ // the id fresh.
14
+ const fallbackResolve = resolveModelSlug("anthropic/claude-haiku");
15
+ if (!fallbackResolve) {
16
+ throw new Error("claudeSubscription preflight: anthropic/claude-haiku missing from registry");
17
+ }
18
+ const FALLBACK_PROBE_MODEL = fallbackResolve.slice(fallbackResolve.indexOf("/") + 1);
19
+
20
+ export type SubscriptionPreflight = { usable: true } | { usable: false; reason: string };
21
+
22
+ /**
23
+ * preflight a Claude subscription OAuth token (`CLAUDE_CODE_OAUTH_TOKEN`)
24
+ * with a 1-token Messages call, so the agent can fall back to
25
+ * `ANTHROPIC_API_KEY` when the subscription is exhausted or revoked instead
26
+ * of failing the whole run at its first model call. rides the same de-facto
27
+ * OAuth surface Claude Code itself uses: Bearer auth + the
28
+ * `claude-code-20250219,oauth-2025-04-20` betas + the identity system prompt.
29
+ *
30
+ * probes the run's own model when known — subscription limits can be
31
+ * per-model ("You've hit your Opus limit"), so a cheaper stand-in could pass
32
+ * preflight and still leave the run dead on arrival.
33
+ *
34
+ * fail-open by design: only 401 (revoked/expired token) and 429
35
+ * (session/weekly/per-model limit) mark the token unusable. network errors,
36
+ * 5xx, and request-shape drift (400) all keep today's subscription-first
37
+ * behavior, so the preflight can never fail a run that would have worked —
38
+ * the worst wrong answer is a run that bills the API key instead of the
39
+ * subscription.
40
+ */
41
+ export async function preflightClaudeSubscription(params: {
42
+ token: string;
43
+ /** bare Anthropic model id the run will use (e.g. "claude-fable-5") */
44
+ model: string | undefined;
45
+ }): Promise<SubscriptionPreflight> {
46
+ let res: Response;
47
+ try {
48
+ res = await fetch("https://api.anthropic.com/v1/messages", {
49
+ method: "POST",
50
+ headers: {
51
+ authorization: `Bearer ${params.token}`,
52
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
53
+ "anthropic-version": "2023-06-01",
54
+ "content-type": "application/json",
55
+ "x-app": "cli",
56
+ },
57
+ body: JSON.stringify({
58
+ model: params.model ?? FALLBACK_PROBE_MODEL,
59
+ max_tokens: 1,
60
+ system: CLAUDE_CODE_IDENTITY,
61
+ messages: [{ role: "user", content: "ok" }],
62
+ }),
63
+ signal: AbortSignal.timeout(10_000),
64
+ });
65
+ } catch {
66
+ // network failure / timeout says nothing about the credential
67
+ return { usable: true };
68
+ }
69
+ if (res.status !== 401 && res.status !== 429) return { usable: true };
70
+ const body = await res.text().catch(() => "");
71
+ return { usable: false, reason: `${res.status}: ${extractApiErrorMessage(body)}` };
72
+ }
73
+
74
+ /** pull `error.message` out of an Anthropic error payload, else a raw excerpt */
75
+ function extractApiErrorMessage(body: string): string {
76
+ try {
77
+ const parsed: unknown = JSON.parse(body);
78
+ if (
79
+ typeof parsed === "object" &&
80
+ parsed !== null &&
81
+ "error" in parsed &&
82
+ typeof parsed.error === "object" &&
83
+ parsed.error !== null &&
84
+ "message" in parsed.error &&
85
+ typeof parsed.error.message === "string"
86
+ ) {
87
+ return parsed.error.message;
88
+ }
89
+ } catch {
90
+ // not json — fall through to the raw excerpt
91
+ }
92
+ return body.slice(0, 200);
93
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * CLI utilities
3
+ */
4
+
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+
8
+ // re-export logging utilities for backward compatibility
9
+ export {
10
+ formatIndentedField,
11
+ formatJsonValue,
12
+ formatUsageSummary,
13
+ log,
14
+ writeSummary,
15
+ } from "#app/utils/log";
16
+
17
+ /**
18
+ * Finds a CLI executable path by checking if it's installed globally
19
+ * @param name The name of the CLI executable to find
20
+ * @returns The path to the CLI executable, or null if not found
21
+ */
22
+ export function findCliPath(name: string): string | null {
23
+ const result = spawnSync("which", [name], { encoding: "utf-8" });
24
+ if (result.status === 0 && result.stdout) {
25
+ const cliPath = result.stdout.trim();
26
+ if (cliPath && existsSync(cliPath)) {
27
+ return cliPath;
28
+ }
29
+ }
30
+ return null;
31
+ }