failproofai 1.0.10 → 1.0.11-beta.1

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 (297) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +4 -4
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js +4 -4
  8. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.meta +8 -0
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js +4 -4
  19. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.meta +8 -0
  23. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  24. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  26. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/invite/route.js +4 -4
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/audit/status/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -4
  36. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -3
  38. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/auth/status/route.js +3 -3
  40. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  42. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  44. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/audit/page.js +4 -4
  46. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/index.html +1 -1
  49. package/.next/standalone/.next/server/app/index.meta +8 -0
  50. package/.next/standalone/.next/server/app/index.rsc +14 -14
  51. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  52. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  53. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  54. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  55. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  56. package/.next/standalone/.next/server/app/page.js +4 -4
  57. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  61. package/.next/standalone/.next/server/app/policies/page.js +8 -8
  62. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/page.js +5 -5
  67. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  70. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  71. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
  73. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  76. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  77. package/.next/standalone/.next/server/app/projects/page.js +5 -5
  78. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +3 -3
  81. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  82. package/.next/standalone/.next/server/app/settings/page.js +9 -9
  83. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  84. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  85. package/.next/standalone/.next/server/chunks/{[externals]__0qxhecv._.js → [externals]__0d7i9ea._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/{[externals]__0sg9-3p._.js → [externals]__0hjaw70._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/[externals]__1_9ze9b._.js +3 -0
  88. package/.next/standalone/.next/server/chunks/[externals]__1oenfmn._.js +3 -0
  89. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08dchfu._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ga9hs7._.js +3 -0
  91. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l3yhx4._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0moxohc._.js +22 -0
  93. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z2ll-a._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +11 -11
  96. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1autwno._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1v25n-6._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/_1fz8m31._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/_1jrv3hg._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/_1nx8fim._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/_1ogwigf._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/{_1q5i8mb._.js → _1q27azx._.js} +2 -2
  107. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0el6rg8._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0ol5pa3._.js} +2 -2
  109. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1rqmk27._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1w7tiq4._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0oqrl71._.js → [root-of-the-server]__042u-jn._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  114. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1-bm5zf._.js → [root-of-the-server]__0502lat._.js} +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0_gn5iz._.js +4 -0
  116. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0f765xr._.js +4 -0
  117. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01bmjsj._.js → [root-of-the-server]__0h38gzz._.js} +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hoaz1g._.js +4 -0
  119. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ouobn4._.js → [root-of-the-server]__0mxmwtx._.js} +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__19evfi8._.js → [root-of-the-server]__0uescqv._.js} +1 -1
  121. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13d-wb6._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1484zv2._.js +3 -0
  123. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1mf3zp6._.js → [root-of-the-server]__19dutr7._.js} +4 -4
  124. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1u6sn69._.js → [root-of-the-server]__1ab4ifb._.js} +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1fbrk56._.js +4 -0
  126. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1um4yxn._.js +4 -0
  128. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1wb4yt2._.js +4 -0
  129. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1y3n3cn._.js +4 -0
  130. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  131. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  132. package/.next/standalone/.next/server/chunks/ssr/{_0yq5_3a._.js → _1ffxwxz._.js} +1 -1
  133. package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +2 -2
  134. package/.next/standalone/.next/server/chunks/ssr/{_042cgl1._.js → _1o95rf0._.js} +2 -2
  135. package/.next/standalone/.next/server/chunks/ssr/{_1zopuov._.js → _1thr5rf._.js} +2 -2
  136. package/.next/standalone/.next/server/chunks/ssr/{_16pa9ht._.js → _1v1o2ya._.js} +2 -2
  137. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +12 -14
  138. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  139. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  140. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  141. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  142. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  143. package/.next/standalone/.next/server/chunks/ssr/node_modules_1hl81y4._.js +1 -1
  144. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0nkkbfv._.js +6 -0
  145. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  146. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  147. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  148. package/.next/standalone/.next/server/middleware.js +1 -1
  149. package/.next/standalone/.next/server/pages/404.html +1 -1
  150. package/.next/standalone/.next/server/pages/500.html +1 -1
  151. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  152. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  153. package/.next/standalone/.next/static/chunks/08tmc_7n-rba1.js +1 -0
  154. package/.next/standalone/.next/static/chunks/{3mjfvng11xnoo.js → 09tlrmucn6oq3.js} +1 -1
  155. package/.next/standalone/.next/static/chunks/{078dofaxvjgpd.js → 0i69k917myh7b.js} +1 -1
  156. package/.next/standalone/.next/static/chunks/{03h3b-uomi7_d.js → 0u_nhsujkactp.js} +1 -1
  157. package/.next/standalone/.next/static/chunks/{2j92xul6y24_v.js → 0zp51hzx62i-u.js} +1 -1
  158. package/.next/standalone/.next/static/chunks/{3u1s8dsqiean5.js → 1fz7q0gtnmeod.js} +1 -1
  159. package/.next/standalone/.next/static/chunks/{3ob2161ap37ul.js → 1j75le8wln-26.js} +1 -1
  160. package/.next/standalone/.next/static/chunks/{2kj826h1w9r47.js → 1z4km7tw0_50h.js} +1 -1
  161. package/.next/standalone/.next/static/chunks/{1fqukzzowjflt.js → 239ay_80-xr8z.js} +1 -1
  162. package/.next/standalone/.next/static/chunks/{3nkc2jlgyygda.js → 2c9pc5xco6rnu.js} +1 -1
  163. package/.next/standalone/.next/static/chunks/{3i8rg2fv9nd6e.js → 3n46e45atic-m.js} +1 -1
  164. package/.next/standalone/.next/static/chunks/{0542cuk_va8g1.js → 3sg07wnn969xq.js} +1 -1
  165. package/.next/standalone/.next/static/chunks/{turbopack-11dn2pegmg9_9.js → turbopack-1opxka4dtjbc5.js} +1 -1
  166. package/.next/standalone/app/actions/install-hooks-web.ts +10 -2
  167. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  168. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  169. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  170. package/.next/standalone/node_modules/next/dist/compiled/hash.js/sha256/256.js +1 -0
  171. package/.next/standalone/node_modules/next/dist/compiled/hash.js/sha256/package.json +1 -0
  172. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +17 -17
  173. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +18 -18
  174. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +3 -3
  175. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +13 -13
  176. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  177. package/.next/standalone/node_modules/next/dist/server/base-server.js +1 -2
  178. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  179. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  180. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  181. package/.next/standalone/node_modules/next/dist/server/image-optimizer.js +265 -53
  182. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  183. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +177 -19
  184. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/index.js +36 -17
  185. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/shared-cache-controls.external.js +14 -6
  186. package/.next/standalone/node_modules/next/dist/server/lib/match-next-data-pathname.js +3 -1
  187. package/.next/standalone/node_modules/next/dist/server/lib/node-fs-methods.js +14 -0
  188. package/.next/standalone/node_modules/next/dist/server/lib/route-cache-key.js +77 -0
  189. package/.next/standalone/node_modules/next/dist/server/lib/router-utils/block-cross-site-dev.js +9 -11
  190. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  191. package/.next/standalone/node_modules/next/dist/server/mcp/get-mcp-middleware.js +4 -2
  192. package/.next/standalone/node_modules/next/dist/server/next-server.js +7 -3
  193. package/.next/standalone/node_modules/next/dist/server/response-cache/index.js +32 -16
  194. package/.next/standalone/node_modules/next/dist/server/route-matcher-providers/app-page-route-matcher-provider.js +2 -2
  195. package/.next/standalone/node_modules/next/dist/server/route-modules/pages/pages-handler.js +6 -1
  196. package/.next/standalone/node_modules/next/dist/server/route-modules/route-module.js +9 -2
  197. package/.next/standalone/node_modules/next/dist/server/use-cache/use-cache-wrapper.js +30 -29
  198. package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/app-paths.js +18 -1
  199. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  200. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  201. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  202. package/.next/standalone/node_modules/next/package.json +10 -10
  203. package/.next/standalone/package.json +14 -11
  204. package/.next/standalone/sdk/python/README.md +41 -0
  205. package/.next/standalone/sdk/python/docs/README.md +1 -0
  206. package/.next/standalone/sdk/python/docs/otel/README.md +36 -0
  207. package/.next/standalone/sdk/python/docs/otel/examples/agent_trace.py +30 -0
  208. package/.next/standalone/sdk/python/docs/otel/examples/quickstart.py +22 -0
  209. package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +8 -0
  210. package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +3 -0
  211. package/.next/standalone/sdk/python/failproofai_sdk/integrations/otel.py +189 -0
  212. package/.next/standalone/sdk/python/pyproject.toml +2 -1
  213. package/.next/standalone/sdk/python/tests/test_docs.py +16 -3
  214. package/.next/standalone/sdk/python/tests/test_integrations.py +1 -1
  215. package/.next/standalone/sdk/python/tests/test_otel.py +147 -0
  216. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +4 -1
  217. package/.next/standalone/sdk/python/uv.lock +258 -147
  218. package/.next/standalone/sdk/typescript/README.md +43 -0
  219. package/.next/standalone/sdk/typescript/integration/ai.test.ts +9 -2
  220. package/.next/standalone/sdk/typescript/package-lock.json +57 -5
  221. package/.next/standalone/sdk/typescript/package.json +2 -0
  222. package/.next/standalone/sdk/typescript/src/edge/index.ts +9 -0
  223. package/.next/standalone/sdk/typescript/src/index.ts +2 -0
  224. package/.next/standalone/sdk/typescript/src/integrations/index.ts +4 -1
  225. package/.next/standalone/sdk/typescript/src/integrations/otel.ts +204 -0
  226. package/.next/standalone/sdk/typescript/test/integrations.test.ts +1 -1
  227. package/.next/standalone/sdk/typescript/test/otel.test.ts +125 -0
  228. package/.next/standalone/server.js +1 -1
  229. package/bin/failproofai.mjs +653 -1077
  230. package/dist/cli.mjs +8174 -3930
  231. package/dist/worker.mjs +170 -62
  232. package/hermes-plugin/plugin.yaml +1 -1
  233. package/package.json +14 -11
  234. package/scripts/launch-facts.ts +157 -0
  235. package/scripts/launch-ready.ts +228 -0
  236. package/scripts/launch-screen.ts +202 -0
  237. package/scripts/launch.ts +106 -49
  238. package/scripts/skew-log-filter.ts +42 -0
  239. package/src/audit/cli-login.ts +39 -59
  240. package/src/audit/cli.ts +308 -228
  241. package/src/audit/index.ts +45 -2
  242. package/src/audit/schedule-cli.ts +83 -120
  243. package/src/audit/types.ts +31 -0
  244. package/src/hooks/agent-selection.ts +155 -0
  245. package/src/hooks/backfill-cli.ts +151 -137
  246. package/src/hooks/backfill-request.ts +149 -0
  247. package/src/hooks/cloud-connection.ts +1 -1
  248. package/src/hooks/cloud-enrollment-cli.ts +78 -28
  249. package/src/hooks/cloud-introspect.ts +3 -0
  250. package/src/hooks/collector-config.ts +10 -1
  251. package/src/hooks/config-status.ts +606 -0
  252. package/src/hooks/configure-wizard.ts +915 -989
  253. package/src/hooks/daemon-service.ts +16 -6
  254. package/src/hooks/did-you-mean.ts +185 -0
  255. package/src/hooks/flush-cli.ts +33 -7
  256. package/src/hooks/fp-config.ts +96 -0
  257. package/src/hooks/fp-home.ts +11 -0
  258. package/src/hooks/fp-reset.ts +102 -95
  259. package/src/hooks/handler.ts +4 -0
  260. package/src/hooks/harness-cli.ts +87 -138
  261. package/src/hooks/install-prompt.ts +38 -477
  262. package/src/hooks/integrations.ts +1 -1
  263. package/src/hooks/jev-cli.ts +183 -196
  264. package/src/hooks/manager.ts +554 -358
  265. package/src/hooks/onboarding-attempt.ts +11 -9
  266. package/src/hooks/otel-cli.ts +344 -0
  267. package/src/hooks/otel-output.ts +24 -0
  268. package/src/hooks/otel-writers.ts +225 -0
  269. package/src/hooks/pack-cli.ts +219 -204
  270. package/src/hooks/report-body.ts +50 -0
  271. package/src/hooks/safe-config-write.ts +4 -2
  272. package/src/hooks/session-pause-cli.ts +93 -73
  273. package/src/hooks/tui.ts +849 -389
  274. package/src/hooks/uninstall-cli.ts +136 -149
  275. package/src/hooks/untraced-note.ts +52 -0
  276. package/.next/standalone/.next/server/chunks/[externals]__19_pzeq._.js +0 -3
  277. package/.next/standalone/.next/server/chunks/[externals]__20kzpkf._.js +0 -3
  278. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +0 -3
  279. package/.next/standalone/.next/server/chunks/[root-of-the-server]__05ja_05._.js +0 -3
  280. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0igxt32._.js +0 -3
  281. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zyc_nm._.js +0 -3
  282. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +0 -22
  283. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +0 -3
  284. package/.next/standalone/.next/server/chunks/_185gyk9._.js +0 -3
  285. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +0 -4
  286. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +0 -4
  287. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +0 -4
  288. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0yrsbd_._.js +0 -3
  289. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +0 -4
  290. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +0 -4
  291. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +0 -4
  292. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +0 -4
  293. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1n3w9lb._.js +0 -6
  294. package/.next/standalone/.next/static/chunks/1lyg_puttohbc.js +0 -1
  295. /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → UZKCiP4Q2TeU8zOB-h9Xj}/_buildManifest.js +0 -0
  296. /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → UZKCiP4Q2TeU8zOB-h9Xj}/_clientMiddlewareManifest.js +0 -0
  297. /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → UZKCiP4Q2TeU8zOB-h9Xj}/_ssgManifest.js +0 -0
package/src/hooks/tui.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * 24-bit, the xterm-256 cube, or the 16 basic ANSI hues.
19
19
  */
20
20
  import * as readline from "node:readline";
21
+ import { version as cliVersion } from "../../package.json";
21
22
 
22
23
  export type TTYIn = NodeJS.ReadableStream & {
23
24
  isTTY?: boolean;
@@ -40,6 +41,12 @@ export interface SelectOneOptions<T> {
40
41
  choices: SelectChoice<T>[];
41
42
  /** Static info lines rendered under the question (e.g. a review summary). */
42
43
  body?: string[];
44
+ /** Grey text after the heading — what the list is, e.g. "9 found on this machine". */
45
+ meta?: string;
46
+ /** The lines the prompt collapses to once answered, instead of `✓ <label>`. */
47
+ collapsed?: (value: T) => string[];
48
+ /** Let space pick the row as well as enter, for a menu that always took both. */
49
+ spaceSelects?: boolean;
43
50
  stdin?: TTYIn;
44
51
  stdout?: TTYOut;
45
52
  }
@@ -87,7 +94,23 @@ export interface MultiSelectOptions<T> {
87
94
  message: string;
88
95
  choices: MultiChoice<T>[];
89
96
  minSelected?: number;
97
+ /** What `✕` says when enter is pressed with fewer than `minSelected` ticked. */
98
+ minMessage?: string;
99
+ /** Replaces the key hints at the foot. Rarely right: the default IS the design. */
90
100
  hint?: string;
101
+ /**
102
+ * Grey text after the heading, redrawn on every toggle. Defaults to
103
+ * "n of m selected"; a function gets what is ticked now; `null` shows none.
104
+ */
105
+ meta?: string | null | ((checked: T[]) => string);
106
+ /**
107
+ * A tag after a row's name, already coloured — "will be added", "new on this
108
+ * machine" — recomputed on every toggle, so it can describe the change a
109
+ * toggle would make. Shown instead of the row's hint when present.
110
+ */
111
+ tag?: (value: T, checked: boolean) => string | undefined;
112
+ /** The lines the prompt collapses to once answered, instead of `✓ <summary>`. */
113
+ collapsed?: (values: T[]) => string[];
91
114
  /** Noun used when collapsing many selections to a count (e.g. "assistants"). */
92
115
  summaryNoun?: string;
93
116
  stdin?: TTYIn;
@@ -100,21 +123,15 @@ const ESC = "\x1B";
100
123
  export const TOKEN_ON_ARGV =
101
124
  "--token was on the command line: your shell history has it, and while this command ran any process of yours could read it from the process list.";
102
125
 
103
- // ── glyphs ────────────────────────────────────────────────────────────────
104
- // Exported so the other branded prompts (install-prompt.ts) share the exact
105
- // same set instead of hand-syncing copies.
106
- export const BAR = "│";
107
- const BAR_END = "└";
108
- export const STEP_ACTIVE = "◆";
109
- export const STEP_DONE = "◇";
110
126
  const RADIO_ON = "●";
111
127
  const RADIO_OFF = "○";
112
- export const CHECK_ON = "◼";
113
- export const CHECK_OFF = "◻";
114
- export const CARET = "❯";
128
+ // The redesign's boxes: ■ ticked, □ not. Two new names rather than new values
129
+ // for the two above, which install-prompt.ts still draws its own menus with.
130
+ const CHECK_SELECTED = "■";
131
+ const CHECK_EMPTY = "□";
115
132
  /** The return key, as the multi-select hints already spell it. */
116
133
  export const CARET_RETURN = "↵";
117
- const MARK = "\u25AE\u25AE"; // ▮▮ — the brand mark, per the design system
134
+ // ▮▮ — the brand mark, per the design system
118
135
 
119
136
  // ── color ─────────────────────────────────────────────────────────────────
120
137
  // The single source of truth for the brand palette — exported (via `paint`)
@@ -126,11 +143,11 @@ interface Hue {
126
143
  * xterm-256 index for the middle tier, verified as the nearest cube entry by
127
144
  * Euclidean distance rather than eyeballed.
128
145
  *
129
- * Optional because `dim` has no honest answer: its `basic` is SGR 2, an
130
- * ATTRIBUTE that steps down whatever foreground the user's theme is already
131
- * using, and the cube's nearest grey (243, #767676) is a fixed colour that
132
- * fights every theme that is not ours. So dim keeps the attribute at all
133
- * three tiers and this stays undefined.
146
+ * Optional because the greys (`dim`, `ink3`, `track`) have no honest answer:
147
+ * their `basic` is SGR 2, an ATTRIBUTE that steps down whatever foreground the
148
+ * user's theme is already using, and the cube's nearest grey (243, #767676)
149
+ * is a fixed colour that fights every theme that is not ours. So a grey keeps
150
+ * the attribute below 24-bit and this stays undefined.
134
151
  */
135
152
  c256?: number;
136
153
  basic: string;
@@ -149,29 +166,30 @@ const HUES = {
149
166
  pink: { rgb: [228, 88, 125], c256: 168, basic: "95" }, // #e4587d — selection, enabled, the mark, the brand
150
167
  warn: { rgb: [227, 179, 65], c256: 179, basic: "33" },
151
168
  dim: { rgb: [107, 118, 132], basic: "2" },
169
+ // The 2026-10 screen language's two greys. Both are the designed hex at 24-bit
170
+ // and the SGR 2 ATTRIBUTE below it, for the reason `dim` gives above: a fixed
171
+ // cube grey fights every theme that is not ours, and the basic tier's "bright
172
+ // black" (90) is the BACKGROUND colour in Solarized Dark, so a label drawn in
173
+ // it would vanish.
174
+ //
175
+ // There is no `ink2`: descriptions use the terminal's own foreground at every
176
+ // tier. The designed #a7adb6 is tuned for a dark ground, nothing here can tell
177
+ // which ground it is on, and on a light one it measures about 2.2:1 — half of
178
+ // what today's dim descriptions get. `paint().ink2` is therefore the identity.
179
+ ink3: { rgb: [118, 127, 139], basic: "2" }, // #767f8b — kv labels, heading meta, key hints
180
+ track: { rgb: [62, 67, 76], basic: "2" }, // #3e434c — the empty part of a progress bar, never text
181
+ // A state, never branding: the ✕ on a failed line. 203 rather than 204, which
182
+ // is the nearest cube entry by distance but differs from the brand pink's 168
183
+ // only in its red channel and reads as pink at that tier — the one deliberate,
184
+ // eyeballed exception to the nearest-entry rule above.
185
+ err: { rgb: [240, 113, 120], c256: 203, basic: "31" }, // #f07178
152
186
  } satisfies Record<string, Hue>;
153
187
 
154
188
  export function colorsEnabled(out: TTYOut): boolean {
155
189
  return !!out.isTTY && !process.env.NO_COLOR;
156
190
  }
157
191
 
158
- /**
159
- * The raw ANSI opening sequence for a brand role, for callers that assemble
160
- * their own strings instead of using `paint()`'s wrappers (`src/audit/cli.ts`).
161
- * That file previously hardcoded its own 256-colour palette — a green and a
162
- * blue that appear nowhere in the brand — so `audit` looked like a different
163
- * product from `config`. Routing it here
164
- * keeps HUES the single source of truth: change a hue once and every surface
165
- * follows. Paints at the deepest tier the terminal admits to; the caller still
166
- * decides *whether* to colour at all.
167
- */
168
- export function brandAnsi(role: keyof typeof HUES): string {
169
- return `${ESC}[${fg(HUES[role], colorTier())}m`;
170
- }
171
-
172
192
  export const ANSI_RESET = `${ESC}[0m`;
173
- export const ANSI_BOLD = `${ESC}[1m`;
174
- export const ANSI_DIM = `${ESC}[2m`;
175
193
 
176
194
  /** How much colour this terminal will actually render. */
177
195
  type ColorTier = "truecolor" | "ansi256" | "basic";
@@ -237,6 +255,13 @@ export function paint(on: boolean) {
237
255
  // to `pink` there, at which point it is deleted.
238
256
  softPink: mk(HUES.pink),
239
257
  warn: mk(HUES.warn),
258
+ // The 2026-10 roles. `ink2` is the terminal's own foreground on purpose —
259
+ // see the note on HUES — so it is the identity at every tier, and exists
260
+ // so a call site says which role it means rather than leaving text bare.
261
+ ink2: (s: string): string => s,
262
+ ink3: mk(HUES.ink3),
263
+ track: mk(HUES.track),
264
+ err: mk(HUES.err),
240
265
  };
241
266
  }
242
267
 
@@ -247,7 +272,6 @@ export function paint(on: boolean) {
247
272
  // (▀ top, ▄ bottom); `t` = teal, `p` = pink, `.` = transparent. Shown when
248
273
  // there's room, else a compact one-liner.
249
274
  const LOGO_MIN_COLS = 22;
250
- const TAGLINE = "end-to-end failure layer for ai agents";
251
275
 
252
276
  // Three editing rules, all learned the hard way:
253
277
  // 1. The two uprights must be the SAME width. In the artwork both are 93px of
@@ -372,130 +396,13 @@ export function summarize(labels: string[], noun = "selected"): string {
372
396
  return `${labels.length} ${noun} · ${head} +${labels.length - 3}`;
373
397
  }
374
398
 
375
- function writeLines(out: TTYOut, lines: string[]): void {
376
- const cols = out.columns || 80;
377
- out.write(lines.map((l) => (l === "" ? l : truncate(l, cols))).join("\n") + "\n");
378
- }
379
-
380
399
  // ── framing ─────────────────────────────────────────────────────────────────
381
400
 
382
- /** The logomark + wordmark + tagline block (with a 2-space margin), color-aware.
383
- * Shared by the wizard intro and the dashboard launch banner. On a too-narrow
384
- * target it collapses to a single compact line. */
385
- export function renderBrandLogo(stdout: TTYOut = process.stdout): string[] {
386
- const c = paint(colorsEnabled(stdout));
387
- const cols = stdout.columns || 80;
388
- if (cols < LOGO_MIN_COLS) {
389
- return [`${c.pink(MARK)} fa${c.pink("il")}proof ai ${c.dim("· " + TAGLINE)}`];
390
- }
391
- // NO_COLOR / non-TTY forces `basic`, which renderLogo draws monochrome — so
392
- // the mark never emits an escape byte on a stream that asked for none.
393
- const tier: ColorTier = colorsEnabled(stdout) ? colorTier() : "basic";
394
- const lines = renderLogo(tier).map((l) => ` ${l}`);
395
- lines.push("");
396
- lines.push(` fa${c.pink("il")}proof ai`);
397
- lines.push(` ${c.dim(TAGLINE)}`);
398
- return lines;
399
- }
400
-
401
- /** Print the flow header — the brand logo, tagline, and the opening step. */
402
- export function intro(message: string, stdout: TTYOut = process.stdout): void {
403
- if (!stdout.isTTY) return;
404
- const c = paint(colorsEnabled(stdout));
405
- const lines: string[] = ["", ...renderBrandLogo(stdout)];
406
- lines.push(c.dim(BAR));
407
- lines.push(`${c.guide(STEP_ACTIVE)} ${c.bold(message)}`);
408
- writeLines(stdout, lines);
409
- }
410
-
411
- /** The branded splash the dashboard prints on launch — logomark, wordmark, and a
412
- * tidy version/links column. Returned as ready-to-print lines (caller writes
413
- * them). Degrades to plain text off a TTY so it stays clean in piped logs. */
414
- export function renderLaunchBanner(version: string, stdout: TTYOut = process.stdout): string[] {
415
- // paint() is identity when colors are off, so the link rows are built once
416
- // and only the header block differs between TTY and piped output.
417
- const c = paint(colorsEnabled(stdout));
418
- const row = (label: string, value: string) => ` ${c.guide(label.padEnd(9))}${value}`;
419
- const header = stdout.isTTY
420
- ? renderBrandLogo(stdout)
421
- : [" failproof ai", ` ${TAGLINE}`];
422
- return [
423
- "",
424
- ...header,
425
- "",
426
- row("version", c.pink(version)),
427
- row("star", c.dim("https://github.com/failproofai/failproofai")),
428
- row("docs", c.dim("https://docs.befailproof.ai/introduction")),
429
- row("discord", c.dim("https://discord.befailproof.ai/")),
430
- row("reddit", c.dim("https://www.reddit.com/r/failproofai/")),
431
- "",
432
- ];
433
- }
434
-
435
- /**
436
- * Print a step that is already SETTLED — the `◇ / message / summary` block
437
- * `selectOne` leaves behind when it resolves.
438
- *
439
- * Extracted because a flow assembled out of `promptText` had no way to show its
440
- * own history: `intro` opens the spine and `outro` closes it, and everything in
441
- * between was bare lines that made the frame look like it belonged to a
442
- * different command. `summary` is the answer, dimmed under the question, which
443
- * is what makes a completed step readable at a glance rather than a heading
444
- * with nothing under it.
445
- */
446
- export function step(
447
- message: string,
448
- summary?: string | string[],
449
- stdout: TTYOut = process.stdout,
450
- ): void {
451
- const c = paint(colorsEnabled(stdout));
452
- const rows = summary === undefined ? [] : Array.isArray(summary) ? summary : [summary];
453
- if (!stdout.isTTY) {
454
- if (rows.length) stdout.write(`${message}: ${rows.join(" ")}\n`);
455
- return;
456
- }
457
- // Truncated to the terminal, because a summary that wraps loses the spine on
458
- // its second row and the block stops reading as one step.
459
- const cols = stdout.columns || 80;
460
- const lines = [c.dim(BAR), truncate(`${c.dim(STEP_DONE)} ${message}`, cols - 1)];
461
- for (const r of rows) lines.push(truncate(`${c.dim(BAR)} ${c.dim(r)}`, cols - 1));
462
- writeLines(stdout, lines);
463
- }
464
-
465
- /**
466
- * Open a step and leave the cursor on it, for a prompt that draws its own line.
467
- *
468
- * The counterpart to {@link step}: `◆` in teal with the question in bold, then
469
- * a spine row the prompt is expected to hang off via `PromptTextOptions.prefix`.
470
- */
471
- export function stepOpen(message: string, stdout: TTYOut = process.stdout): void {
472
- const c = paint(colorsEnabled(stdout));
473
- if (!stdout.isTTY) return;
474
- writeLines(stdout, [c.dim(BAR), `${c.guide(STEP_ACTIVE)} ${c.bold(message)}`]);
475
- }
476
-
477
- /** Close the flow with a terminating └ line — pink on success, dim on cancel. */
478
- export function outro(
479
- message: string,
480
- opts: { ok?: boolean } = {},
481
- stdout: TTYOut = process.stdout,
482
- ): void {
483
- const c = paint(colorsEnabled(stdout));
484
- const ok = opts.ok !== false;
485
- if (!stdout.isTTY) {
486
- stdout.write(message + "\n");
487
- return;
488
- }
489
- const end = ok ? c.pink(BAR_END) : c.dim(BAR_END);
490
- const text = ok ? c.pink(message) : c.dim(message);
491
- writeLines(stdout, [c.dim(BAR), `${end} ${text}`]);
492
- }
493
-
494
401
  // ── shared render engine ─────────────────────────────────────────────────────
495
402
 
496
403
  type Region = { lastCount: number };
497
404
 
498
- const WINDOW = 8; // visible rows before the checklist scrolls
405
+ const WINDOW = 12; // visible rows before a list scrolls; 12 reproduces every frame in the redesign
499
406
 
500
407
  /**
501
408
  * Redraw the region as ONE atomic frame.
@@ -510,12 +417,24 @@ const WINDOW = 8; // visible rows before the checklist scrolls
510
417
  * the terminal to hold what it has until the reset rather than painting each
511
418
  * chunk as it lands. Terminals that do not implement it ignore an unknown
512
419
  * private mode, so it costs nothing where it does not help.
420
+ *
421
+ * `frame` is for a caller that has to change a terminal mode alongside a frame
422
+ * — hide the cursor on the first, show it on the last — inside the SAME write,
423
+ * and for a last frame that must not be cut: lines are truncated so each one is
424
+ * exactly one row and the next cursor-up lands where it should, but a frame
425
+ * nothing will repaint stays in scrollback, where a cut is permanent.
513
426
  */
514
- function repaint(out: TTYOut, region: Region, lines: string[]): void {
427
+ function repaint(
428
+ out: TTYOut,
429
+ region: Region,
430
+ lines: string[],
431
+ frame: { lead?: string; tail?: string; cut?: boolean } = {},
432
+ ): void {
515
433
  const cols = out.columns || 80;
516
- const body = lines.map((l) => (l === "" ? l : truncate(l, cols))).join("\n") + "\n";
434
+ const cut = frame.cut ?? true;
435
+ const body = lines.map((l) => (l === "" || !cut ? l : truncate(l, cols))).join("\n") + "\n";
517
436
  const clear = region.lastCount > 0 ? `${ESC}[${region.lastCount}A${ESC}[J` : "";
518
- out.write(`${ESC}[?2026h${clear}${body}${ESC}[?2026l`);
437
+ out.write(`${ESC}[?2026h${frame.lead ?? ""}${clear}${body}${frame.tail ?? ""}${ESC}[?2026l`);
519
438
  region.lastCount = lines.length;
520
439
  }
521
440
 
@@ -586,8 +505,10 @@ interface PromptSpec<R> {
586
505
  /** Static info lines rendered under the question. */
587
506
  body?: string[];
588
507
  choices: Array<{ label: string; section?: string }>;
589
- /** Row content after the gutter, e.g. "● Label hint". */
508
+ /** A whole row, cursor gutter included: " › ■ name hint". */
590
509
  renderRow: (index: number, active: boolean, budget: number) => string;
510
+ /** Grey text after the heading, re-read on every frame. */
511
+ meta?: () => string | null | undefined;
591
512
  /** Extra line(s) above the footer (e.g. a min-selected warning). */
592
513
  warnLine?: () => string | null;
593
514
  /** When set, ← resolves `BACK` so the caller can step backwards. */
@@ -597,8 +518,19 @@ interface PromptSpec<R> {
597
518
  footer: string;
598
519
  /** Handle non-navigation keys. `{done}` finishes, `"redraw"` repaints. */
599
520
  onKey: (key: readline.Key, cursor: number) => { done: R } | "redraw" | undefined;
600
- /** One-line ◇ summary for the collapsed log entry. */
521
+ /** The answer, said once the prompt collapses: `✓ <this>`. */
601
522
  summaryFor: (result: R | null) => string;
523
+ /** The collapsed lines, when a caller says the answer its own way. */
524
+ collapsed?: (result: R) => string[];
525
+ }
526
+
527
+ /**
528
+ * A prompt's heading: the question in bold UPPERCASE, with grey meta after it —
529
+ * the same shape as `screenKit().head`, so a picker and the screen around it
530
+ * read as one page.
531
+ */
532
+ function promptHeading(c: ReturnType<typeof paint>, message: string, meta?: string | null): string {
533
+ return c.bold(message.toUpperCase()) + (meta ? ` ${c.ink3(meta)}` : "");
602
534
  }
603
535
 
604
536
  function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
@@ -609,8 +541,8 @@ function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
609
541
 
610
542
  const build = (): string[] => {
611
543
  const cols = stdout.columns || 80;
612
- const lines: string[] = [c.dim(BAR), `${c.guide(STEP_ACTIVE)} ${c.bold(p.message)}`];
613
- for (const b of p.body ?? []) lines.push(`${c.dim(BAR)} ${c.dim(b)}`);
544
+ const lines: string[] = [promptHeading(c, p.message, p.meta?.())];
545
+ for (const b of p.body ?? []) lines.push(`${INDENT}${c.ink2(b)}`);
614
546
 
615
547
  const rows = displayRows(choices);
616
548
  let cursorRow = 0;
@@ -620,22 +552,25 @@ function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
620
552
  const { start, end } = viewport(rows, cursorRow, WINDOW);
621
553
  const above = rows.slice(0, start).filter((r) => r.kind === "item").length;
622
554
  const below = rows.slice(end).filter((r) => r.kind === "item").length;
623
- if (above > 0) lines.push(`${c.dim(BAR)} ${c.dim(`↑ ${above} more`)}`);
555
+ if (above > 0) lines.push(` ${c.ink2(`↑ ${above} more`)}`);
624
556
 
625
- const budget = Math.max(6, cols - nameCol - 10);
557
+ // The room a row's hint has: the terminal, less the 3-column cursor gutter,
558
+ // the name column and the gap after it. A checklist's box takes two more,
559
+ // which its renderRow subtracts itself.
560
+ const budget = Math.max(6, cols - nameCol - 6);
626
561
  for (let ri = start; ri < end; ri++) {
627
562
  const row = rows[ri];
628
563
  if (row.kind === "header") {
629
- lines.push(`${c.dim(BAR)} ${c.dim(row.text)}`);
564
+ lines.push(`${INDENT}${c.bold(row.text)}`);
630
565
  } else {
631
- lines.push(`${c.dim(BAR)} ${p.renderRow(row.index, row.index === cursor, budget)}`);
566
+ lines.push(p.renderRow(row.index, row.index === cursor, budget));
632
567
  }
633
568
  }
634
- if (below > 0) lines.push(`${c.dim(BAR)} ${c.dim(`↓ ${below} more`)}`);
569
+ if (below > 0) lines.push(` ${c.ink2(`↓ ${below} more`)}`);
635
570
 
636
571
  const warn = p.warnLine?.();
637
- if (warn) lines.push(`${c.dim(BAR)} ${warn}`);
638
- lines.push(`${c.dim(BAR)} ${c.dim(p.footer)}`);
572
+ if (warn) lines.push(warn);
573
+ lines.push("", c.ink3(p.footer));
639
574
  return lines;
640
575
  };
641
576
 
@@ -650,12 +585,18 @@ function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
650
585
  // `resolve(result)`, so pressing ← never settled the promise and the wizard
651
586
  // stopped responding entirely. `selectOne` fell through to `String(value)`
652
587
  // and rendered the literal text `Symbol(failproofai.back)`.
653
- const summary = (result as unknown) === BACK ? "back" : p.summaryFor(result);
654
- repaint(stdout, region, [
655
- c.dim(BAR),
656
- `${c.dim(STEP_DONE)} ${p.message}`,
657
- `${c.dim(BAR)} ${c.dim(summary)}`,
658
- ]);
588
+ const heading = promptHeading(c, p.message);
589
+ const lines =
590
+ (result as unknown) === BACK
591
+ ? [heading, c.ink3("back")]
592
+ : result === null
593
+ ? [heading, c.ink3(p.summaryFor(null))]
594
+ : p.collapsed
595
+ ? p.collapsed(result)
596
+ : [heading, `${c.guide("✓")} ${p.summaryFor(result)}`];
597
+ // Not cut: nothing repaints this frame, so it stays in scrollback, where a
598
+ // cut is permanent — and a summary names agents, hosts and orgs.
599
+ repaint(stdout, region, lines, { cut: false });
659
600
  };
660
601
 
661
602
  return new Promise<R | null>((resolve) => {
@@ -750,22 +691,23 @@ export function selectOne<T>(opts: SelectOneOptions<T>): Promise<T | Back | null
750
691
  c,
751
692
  body: opts.body,
752
693
  choices,
694
+ meta: () => opts.meta,
753
695
  renderRow: (index, active, budget) => {
754
696
  const choice = choices[index];
755
- const dot = active ? c.pink(RADIO_ON) : c.dim(RADIO_OFF);
756
- const rawLabel = choice.label.padEnd(nameCol);
757
- const label = active ? c.pinkBold(rawLabel) : rawLabel;
697
+ const rawLabel = choice.hint ? choice.label.padEnd(nameCol) : choice.label;
698
+ const label = active ? c.bold(rawLabel) : rawLabel;
758
699
  const hint = choice.hint
759
- ? ` ${c.dim(ellipsize(choice.hint, hintBudget(choice.label, nameCol, budget)))}`
700
+ ? ` ${c.ink2(ellipsize(choice.hint, hintBudget(choice.label, nameCol, budget)))}`
760
701
  : "";
761
- return `${dot} ${label}${hint}`;
702
+ return `${active ? c.pink(" › ") : " "}${label}${hint}`;
762
703
  },
763
704
  allowBack: opts.allowBack,
764
- footer: opts.allowBack
765
- ? "↑/↓ navigate · enter to select · ← back · esc to cancel"
766
- : "↑/↓ navigate · enter to select · esc to cancel",
705
+ footer: ["↑↓ move", "enter select", ...(opts.allowBack ? ["← back"] : []), "esc cancel"].join(" · "),
767
706
  onKey: (key, cursor) =>
768
- key.name === "return" ? { done: choices[cursor].value } : undefined,
707
+ key.name === "return" || (opts.spaceSelects && key.name === "space")
708
+ ? { done: choices[cursor].value }
709
+ : undefined,
710
+ collapsed: opts.collapsed,
769
711
  summaryFor: (value) =>
770
712
  value === null
771
713
  ? "cancelled"
@@ -800,27 +742,40 @@ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null
800
742
  message: opts.message,
801
743
  c,
802
744
  choices,
745
+ meta: () => {
746
+ if (opts.meta === null) return null;
747
+ if (typeof opts.meta === "string") return opts.meta;
748
+ if (typeof opts.meta === "function") {
749
+ return opts.meta(choices.filter((_, i) => checked[i]).map((ch) => ch.value));
750
+ }
751
+ // Selector rows ("Everything available") are not one of the things counted.
752
+ const counted = choices.map((ch, i) => (ch.summaryExclude ? null : checked[i])).filter((v) => v !== null);
753
+ return `${counted.filter(Boolean).length} of ${counted.length} selected`;
754
+ },
803
755
  renderRow: (index, active, budget) => {
804
756
  const choice = choices[index];
805
- const caret = active ? c.pink(CARET) : " ";
806
- // Locked rows use the teal guide hue rather than selection pink, so they
757
+ // Locked rows use the mint guide hue rather than selection pink, so they
807
758
  // read as "already true" instead of "you picked this"; an unchecked
808
- // locked row is a dim empty box — nothing there yet.
759
+ // locked row is an empty grey box — nothing there yet.
809
760
  const box = choice.locked
810
761
  ? checked[index]
811
- ? c.guide(CHECK_ON)
812
- : c.dim(CHECK_OFF)
762
+ ? c.guide(CHECK_SELECTED)
763
+ : c.ink3(CHECK_EMPTY)
813
764
  : checked[index]
814
- ? c.pink(CHECK_ON)
815
- : c.dim(CHECK_OFF);
816
- const rawLabel = choice.label.padEnd(nameCol);
817
- const label = active ? c.pinkBold(rawLabel) : checked[index] ? rawLabel : c.dim(rawLabel);
818
- const hint = choice.hint
819
- ? ` ${c.dim(ellipsize(choice.hint, hintBudget(choice.label, nameCol, budget)))}`
820
- : "";
821
- return `${caret} ${box} ${label}${hint}`;
765
+ ? c.pink(CHECK_SELECTED)
766
+ : c.ink3(CHECK_EMPTY);
767
+ const tag = opts.tag?.(choice.value, checked[index]);
768
+ const rawLabel = tag || choice.hint ? choice.label.padEnd(nameCol) : choice.label;
769
+ const label = active ? c.bold(rawLabel) : checked[index] ? rawLabel : c.ink2(rawLabel);
770
+ const trail = tag
771
+ ? ` ${tag}`
772
+ : choice.hint
773
+ ? ` ${c.ink2(ellipsize(choice.hint, hintBudget(choice.label, nameCol, budget - 2)))}`
774
+ : "";
775
+ return `${active ? c.pink(" › ") : " "}${box} ${label}${trail}`;
822
776
  },
823
- warnLine: () => (warn ? c.warn(`Select at least ${minSelected}.`) : null),
777
+ warnLine: () =>
778
+ warn ? `${c.err("✕")} ${opts.minMessage ?? `Select at least ${minSelected}.`}` : null,
824
779
  allowBack: opts.allowBack,
825
780
  // Reads the SAME `checked` array the prompt is driving, so what the caller
826
781
  // learns is exactly what was on screen when ← was pressed.
@@ -829,9 +784,10 @@ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null
829
784
  : undefined,
830
785
  footer:
831
786
  opts.hint ??
832
- (opts.allowBack
833
- ? "↑/↓ move · space select · ctrl+a all · ← back · enter confirm"
834
- : "↑/↓ move · space select · ctrl+a all · enter confirm"),
787
+ ["↑↓ move", "space toggle", "a all", ...(opts.allowBack ? ["← back"] : []), "enter confirm", "esc cancel"].join(
788
+ " · ",
789
+ ),
790
+ collapsed: opts.collapsed,
835
791
  onKey: (key, cursor) => {
836
792
  if (key.name === "space") {
837
793
  if (choices[cursor]?.locked) return "redraw"; // always on — not a choice
@@ -839,7 +795,9 @@ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null
839
795
  warn = false;
840
796
  return "redraw";
841
797
  }
842
- if (key.ctrl && key.name === "a") {
798
+ // Plain `a`, as the redesign's hints spell it; ctrl+a stays as an alias,
799
+ // since GNU screen swallows it as its command prefix anyway.
800
+ if (key.name === "a") {
843
801
  const allOn = choices.every((ch, i) => checked[i] || ch.locked);
844
802
  for (let i = 0; i < checked.length; i++) {
845
803
  checked[i] = choices[i]?.locked ? true : !allOn;
@@ -895,6 +853,15 @@ export interface PromptTextOptions {
895
853
  * the scrollback of every recording of that session.
896
854
  */
897
855
  mask?: boolean;
856
+ /**
857
+ * Draw the 2026-10 field instead of the spine prompt: ` <message> › <value>`
858
+ * — the label in the terminal's own ink at a ten-column label, a pink `›`, the
859
+ * hint in the label grey, and a refusal as a `✕` line under the field that is
860
+ * cleared again as soon as the value changes. `prefix` is ignored. For the
861
+ * screens that have moved to the new language; the spine form stays for the
862
+ * rest until they move.
863
+ */
864
+ field?: boolean;
898
865
  /** Return an error string to reject and re-ask, or null to accept. */
899
866
  validate?: (value: string) => string | null;
900
867
  stdin?: TTYIn;
@@ -959,9 +926,38 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
959
926
 
960
927
  return new Promise((resolve) => {
961
928
  let value = "";
929
+ // Whether a field's `✕` line is on the row under it right now.
930
+ let refused = false;
931
+ /**
932
+ * The field form, in ONE write per keystroke. The refusal goes on the row
933
+ * under the field and the field is drawn LAST, so the cursor ends where the
934
+ * typing is — the spine form moves back up after its error line and leaves
935
+ * the cursor at the end of the error instead. A refusal still on screen is
936
+ * cleared the moment the value changes, so a corrected answer never sits
937
+ * above the complaint about the old one.
938
+ */
939
+ const drawField = (cols: number, shown: string, error?: string) => {
940
+ const hint = hintText && value.length === 0 ? ` ${c.ink3(hintText)}` : "";
941
+ const line = truncate(`${INDENT}${opts.message.padEnd(10)}${c.pink("›")} ${shown}${hint}`, cols - 1);
942
+ if (error) {
943
+ const refusal = truncate(`${c.err("✕")} ${error}`, cols - 1);
944
+ stdout.write(`\n\x1b[2K${refusal}\x1b[1A\r\x1b[2K${line}`);
945
+ refused = true;
946
+ return;
947
+ }
948
+ // Down, clear, back up: the row under the field exists, because the
949
+ // refusal being cleared was written there.
950
+ const clearRefusal = refused ? "\x1b[1B\x1b[2K\x1b[1A" : "";
951
+ stdout.write(`${clearRefusal}\r\x1b[2K${line}`);
952
+ refused = false;
953
+ };
962
954
  const draw = (error?: string) => {
963
955
  const cols = stdout.columns || 80;
964
956
  const shown = opts.mask ? "•".repeat(value.length) : value;
957
+ if (opts.field) {
958
+ drawField(cols, shown, error);
959
+ return;
960
+ }
965
961
  // The hint is a PLACEHOLDER — an example of what belongs here — so it
966
962
  // steps aside as soon as there is a real answer to look at. Keeping both
967
963
  // on one line put the example and the input side by side, which is the
@@ -992,7 +988,10 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
992
988
  stdin.removeListener("keypress", onKey);
993
989
  stdin.setRawMode?.(wasRaw ?? false);
994
990
  stdin.pause();
995
- stdout.write("\n");
991
+ // Past a field's refusal, when one is still showing (a cancel right after
992
+ // it), so whatever prints next starts on a clean row instead of on top of
993
+ // the complaint.
994
+ stdout.write(refused ? "\n\n" : "\n");
996
995
  };
997
996
 
998
997
  function onKey(str: string | undefined, key: readline.Key): void {
@@ -1029,6 +1028,326 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
1029
1028
  });
1030
1029
  }
1031
1030
 
1031
+ /**
1032
+ * Run something that writes straight to the terminal — `sudo -v` and its
1033
+ * password prompt — then erase whatever it wrote, so the step around it can
1034
+ * collapse to one line.
1035
+ *
1036
+ * The rows are reserved FIRST: `reserve` newlines then back up, so that what
1037
+ * the child prints never scrolls the screen. A scroll would move the saved
1038
+ * cursor position off the line it was saved on, and the erase would then land
1039
+ * below the prompt instead of on it. Three failed sudo attempts print about
1040
+ * six lines, so ten is enough room. Without a terminal it just runs.
1041
+ */
1042
+ export function collapseAfter<T>(stdout: TTYOut, reserve: number, run: () => T): T {
1043
+ if (!stdout.isTTY) return run();
1044
+ stdout.write(`${"\n".repeat(reserve)}${ESC}[${reserve}A${ESC}7`);
1045
+ try {
1046
+ return run();
1047
+ } finally {
1048
+ stdout.write(`${ESC}8${ESC}[J`);
1049
+ }
1050
+ }
1051
+
1052
+ // ── the cloud-key prompt ─────────────────────────────────────────────────────
1053
+
1054
+ /** What `promptCloudKey` resolves when Tab is pressed: no new connection. */
1055
+ export const OPEN_SOURCE: unique symbol = Symbol("failproofai.open-source");
1056
+ export type OpenSource = typeof OPEN_SOURCE;
1057
+
1058
+ /**
1059
+ * What a key check found, in words the prompt prints as they are. `ok` with a
1060
+ * `line` is a key that works for part of what it was asked: the caller says
1061
+ * which part, as a `▲` line after the prompt collapses. `usable` marks a SAVED
1062
+ * key that could not be checked (offline, a timeout) rather than one that was
1063
+ * refused, so Enter can still keep it.
1064
+ */
1065
+ export type KeyVerdict = { ok: true; line?: string } | { ok: false; line: string; usable?: boolean };
1066
+
1067
+ export type CloudKeyAnswer =
1068
+ | { kind: "typed"; key: string; note?: string }
1069
+ | { kind: "saved"; verified: boolean };
1070
+
1071
+ export interface CloudKeyPromptOptions {
1072
+ /** The heading, e.g. "Connect to cloud". */
1073
+ message: string;
1074
+ /** Grey text after the heading: where the key will be sent. */
1075
+ meta?: string;
1076
+ /** What Tab does, for the key hints: "use open source instead", or "skip". */
1077
+ tabHint: string;
1078
+ /** The `✕` line for Enter on an empty field with no saved key to use. */
1079
+ emptyError: string;
1080
+ /** A key already on this machine: drawn at once, checked in the background. */
1081
+ saved?: { masked: string; check: () => Promise<KeyVerdict> };
1082
+ /** Checks a typed key. Never called for one shorter than `minLength`. */
1083
+ check: (key: string) => Promise<KeyVerdict>;
1084
+ minLength?: number;
1085
+ /** The lines the prompt collapses to once answered. */
1086
+ collapsed?: (answer: CloudKeyAnswer | OpenSource) => string[];
1087
+ stdin?: TTYIn;
1088
+ stdout?: TTYOut;
1089
+ }
1090
+
1091
+ /**
1092
+ * One masked `API key ›` field, with Tab as the way out to open source.
1093
+ *
1094
+ * Its own prompt rather than options on `promptText`, which keeps a one-row
1095
+ * rule its other callers depend on. This one needs rows above and below the
1096
+ * field: a saved key's state, a spinner while a key is checked, and a `✕` line
1097
+ * that keeps the typed key in place so Enter can retry it.
1098
+ *
1099
+ * Three rules hold for every state:
1100
+ * - Tab inside a paste is part of the paste. A pasted key arrives as keys,
1101
+ * so a tab in it would otherwise switch to open source halfway through.
1102
+ * Bracketed paste is switched on for exactly this, and Enter inside a paste
1103
+ * (a copied trailing newline) never submits either.
1104
+ * - Keys are ignored while a typed key is being checked, Esc excepted: a
1105
+ * check can take ten seconds and leaving must stay possible.
1106
+ * - The terminal is put back on every way out: cursor, raw mode and paste
1107
+ * mode, including a process exit while the prompt is open.
1108
+ */
1109
+ export function promptCloudKey(opts: CloudKeyPromptOptions): Promise<CloudKeyAnswer | OpenSource | null> {
1110
+ const stdin: TTYIn = opts.stdin ?? process.stdin;
1111
+ const stdout: TTYOut = opts.stdout ?? process.stdout;
1112
+ // Nobody is there to type a key. The wizard never asks without a terminal;
1113
+ // this answers the same way a cancel would rather than hang on a pipe.
1114
+ if (!stdin.isTTY || !stdout.isTTY) return Promise.resolve(null);
1115
+
1116
+ const c = paint(colorsEnabled(stdout));
1117
+ const kit = screenKit({ cols: stdout.columns || 80, color: colorsEnabled(stdout) });
1118
+ const minLength = opts.minLength ?? 8;
1119
+ const spinnerFrames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
1120
+ const region: Region = { lastCount: 0 };
1121
+
1122
+ let typed = "";
1123
+ let inPaste = false;
1124
+ // The saved key's state: being checked, works, could not be checked, or
1125
+ // refused — and a refused key is treated as no saved key at all.
1126
+ let savedState: "checking" | "works" | "unchecked" | "refused" | "none" = opts.saved ? "checking" : "none";
1127
+ let savedLine = "";
1128
+ let enterWaiting = false;
1129
+ let checkingTyped = false;
1130
+ let fieldError = "";
1131
+ let refusedOnce = false;
1132
+ let frame = 0;
1133
+ let spinning = false;
1134
+ let settled = false;
1135
+ let spinDelay: ReturnType<typeof setTimeout> | null = null;
1136
+ let spinTimer: ReturnType<typeof setInterval> | null = null;
1137
+
1138
+ const savedUsable = (): boolean => savedState === "checking" || savedState === "works" || savedState === "unchecked";
1139
+
1140
+ const spinGlyph = (): string => (spinning ? c.ink3(spinnerFrames[frame % spinnerFrames.length]) : " ");
1141
+
1142
+ const build = (): string[] => {
1143
+ const lines: string[] = [promptHeading(c, opts.message, opts.meta)];
1144
+ if (opts.saved) {
1145
+ if (savedState === "checking") lines.push(` ${spinGlyph()} Checking your saved key ${opts.saved.masked}…`);
1146
+ else if (savedState === "works") lines.push(kit.ok(savedLine));
1147
+ else if (savedState === "unchecked" || savedState === "refused") lines.push(kit.caution(savedLine));
1148
+ }
1149
+ lines.push(`${INDENT}${"API key".padEnd(10)}${c.pink("› ")}${"•".repeat(typed.length)}`);
1150
+ if (checkingTyped) {
1151
+ lines.push(` ${spinGlyph()} Checking the key…`);
1152
+ return lines;
1153
+ }
1154
+ if (fieldError) lines.push(kit.fail(fieldError));
1155
+ // "type to replace it", not the reference's "type a new key to replace it":
1156
+ // the reference is drawn at 104 columns, and at 80 the longer line is cut
1157
+ // through the Tab hint, which is the only place Tab is mentioned.
1158
+ const hints =
1159
+ fieldError && refusedOnce
1160
+ ? ["enter try again", `tab ${opts.tabHint}`, "esc cancel"]
1161
+ : savedUsable() && typed.length === 0
1162
+ ? ["enter use saved key", "type to replace it", `tab ${opts.tabHint}`]
1163
+ : ["enter connect", `tab ${opts.tabHint}`, "esc cancel"];
1164
+ lines.push("", kit.keys(hints));
1165
+ return lines;
1166
+ };
1167
+
1168
+ const draw = (): void => {
1169
+ if (!settled) repaint(stdout, region, build());
1170
+ };
1171
+
1172
+ const stopSpinner = (): void => {
1173
+ if (spinDelay) clearTimeout(spinDelay);
1174
+ if (spinTimer) clearInterval(spinTimer);
1175
+ spinDelay = null;
1176
+ spinTimer = null;
1177
+ spinning = false;
1178
+ };
1179
+ // 150 ms before the first frame, so a check that answers at once never
1180
+ // flashes a spinner; 80 ms a frame after that.
1181
+ const startSpinner = (): void => {
1182
+ stopSpinner();
1183
+ spinDelay = setTimeout(() => {
1184
+ spinning = true;
1185
+ draw();
1186
+ spinTimer = setInterval(() => {
1187
+ frame++;
1188
+ draw();
1189
+ }, 80);
1190
+ spinTimer.unref?.();
1191
+ }, 150);
1192
+ spinDelay.unref?.();
1193
+ };
1194
+
1195
+ const restoreTerminal = (): void => {
1196
+ stdout.write(`${ESC}[?2004l`);
1197
+ showCursor(stdout);
1198
+ };
1199
+
1200
+ return new Promise((resolve) => {
1201
+ const wasRaw = stdin.isRaw;
1202
+ const finish = (answer: CloudKeyAnswer | OpenSource | null): void => {
1203
+ if (settled) return;
1204
+ stopSpinner();
1205
+ stdin.removeListener("keypress", onKey);
1206
+ stdin.setRawMode?.(wasRaw ?? false);
1207
+ stdin.pause();
1208
+ process.removeListener("exit", restoreTerminal);
1209
+ const lines =
1210
+ answer === null
1211
+ ? [promptHeading(c, opts.message), c.ink3("cancelled")]
1212
+ : (opts.collapsed?.(answer) ?? [promptHeading(c, opts.message)]);
1213
+ // The last frame, left in scrollback: never cut (see `repaint`). It names
1214
+ // the org and the host the key went to.
1215
+ repaint(stdout, region, lines, { cut: false });
1216
+ settled = true;
1217
+ restoreTerminal();
1218
+ resolve(answer);
1219
+ };
1220
+
1221
+ const keepSaved = (): void => {
1222
+ if (savedState === "checking") {
1223
+ // Enter waits for a check still in flight, and says so by spinning.
1224
+ enterWaiting = true;
1225
+ return;
1226
+ }
1227
+ finish({ kind: "saved", verified: savedState === "works" });
1228
+ };
1229
+
1230
+ const submit = (): void => {
1231
+ if (typed.length === 0) {
1232
+ if (savedUsable()) return keepSaved();
1233
+ fieldError = opts.emptyError;
1234
+ refusedOnce = false;
1235
+ return draw();
1236
+ }
1237
+ if (typed.length < minLength) {
1238
+ fieldError = "That looks too short to be a key.";
1239
+ refusedOnce = false;
1240
+ return draw();
1241
+ }
1242
+ checkingTyped = true;
1243
+ fieldError = "";
1244
+ startSpinner();
1245
+ draw();
1246
+ const key = typed;
1247
+ opts.check(key).then(
1248
+ (verdict) => {
1249
+ if (settled) return;
1250
+ checkingTyped = false;
1251
+ stopSpinner();
1252
+ if (verdict.ok) return finish({ kind: "typed", key, note: verdict.line });
1253
+ fieldError = verdict.line;
1254
+ refusedOnce = true;
1255
+ draw();
1256
+ },
1257
+ (err: unknown) => {
1258
+ if (settled) return;
1259
+ checkingTyped = false;
1260
+ stopSpinner();
1261
+ fieldError = `Couldn't check the key: ${err instanceof Error ? err.message : String(err)}`;
1262
+ refusedOnce = true;
1263
+ draw();
1264
+ },
1265
+ );
1266
+ };
1267
+
1268
+ function onKey(str: string | undefined, key: readline.Key): void {
1269
+ if (!key || settled) return;
1270
+ if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") return finish(null);
1271
+ if (key.name === "paste-start") {
1272
+ inPaste = true;
1273
+ return;
1274
+ }
1275
+ if (key.name === "paste-end") {
1276
+ inPaste = false;
1277
+ return draw();
1278
+ }
1279
+ if (checkingTyped || enterWaiting) return;
1280
+ if (key.name === "tab") {
1281
+ if (!inPaste) finish(OPEN_SOURCE);
1282
+ return;
1283
+ }
1284
+ if (key.name === "return" || key.name === "enter") {
1285
+ if (!inPaste) submit();
1286
+ return;
1287
+ }
1288
+ if (key.name === "backspace") {
1289
+ typed = typed.slice(0, -1);
1290
+ if (fieldError) fieldError = "";
1291
+ return draw();
1292
+ }
1293
+ // A key is never whitespace, so a copied newline or space is dropped
1294
+ // rather than becoming part of what gets sent.
1295
+ if (str && !key.ctrl && !key.meta) {
1296
+ const clean = str.replace(/\s+/g, "");
1297
+ if (!clean) return;
1298
+ typed += clean;
1299
+ if (fieldError) fieldError = "";
1300
+ if (!inPaste) draw();
1301
+ }
1302
+ }
1303
+
1304
+ process.once("exit", restoreTerminal);
1305
+ readline.emitKeypressEvents(stdin);
1306
+ stdin.setRawMode?.(true);
1307
+ stdin.resume();
1308
+ hideCursor(stdout);
1309
+ stdout.write(`${ESC}[?2004h`);
1310
+ stdin.on("keypress", onKey);
1311
+ if (opts.saved) startSpinner();
1312
+ draw();
1313
+
1314
+ opts.saved?.check().then(
1315
+ (verdict) => {
1316
+ if (settled) return;
1317
+ if (verdict.ok) {
1318
+ savedState = "works";
1319
+ savedLine = verdict.line ?? `Your saved key works: ${opts.saved?.masked}`;
1320
+ } else {
1321
+ savedState = verdict.usable ? "unchecked" : "refused";
1322
+ savedLine = verdict.line;
1323
+ }
1324
+ if (!checkingTyped) stopSpinner();
1325
+ if (enterWaiting) {
1326
+ enterWaiting = false;
1327
+ if (savedUsable()) return keepSaved();
1328
+ // Enter was pressed on an empty field, and the saved key it waited
1329
+ // for turned out refused: answer it as `submit` answers an empty
1330
+ // field, rather than redrawing as if nothing had been pressed.
1331
+ fieldError = opts.emptyError;
1332
+ refusedOnce = false;
1333
+ }
1334
+ draw();
1335
+ },
1336
+ (err: unknown) => {
1337
+ if (settled) return;
1338
+ savedState = "unchecked";
1339
+ savedLine = `Couldn't check your saved key: ${err instanceof Error ? err.message : String(err)}`;
1340
+ if (!checkingTyped) stopSpinner();
1341
+ if (enterWaiting) {
1342
+ enterWaiting = false;
1343
+ return keepSaved();
1344
+ }
1345
+ draw();
1346
+ },
1347
+ );
1348
+ });
1349
+ }
1350
+
1032
1351
  // ── the kit ──────────────────────────────────────────────────────────────────
1033
1352
  /**
1034
1353
  * The block builders every PRINTED surface is assembled from.
@@ -1043,9 +1362,7 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
1043
1362
  * answers to "how does this product state a fact".
1044
1363
  *
1045
1364
  * These are the one answer. Every builder is pure — `(spec, opts) => string[]` —
1046
- * so a surface can be asserted at any width, with color on or off, without a pty;
1047
- * that is the same shape `renderBrandLogo`, `reviewLines` and `buildSummary`
1048
- * already have, and the reason they are the only rendering we can currently test.
1365
+ * so a surface can be asserted at any width, with color on or off, without a pty.
1049
1366
  *
1050
1367
  * Callers pass `optsFor(stdout)` and print with `printBlock`, which owns the
1051
1368
  * outer margins so no surface has to remember them.
@@ -1202,7 +1519,11 @@ function closeSgr(text: string): string {
1202
1519
  * single-ellipsis cut when it is plain. */
1203
1520
  function fit(cell: string, width: number): string {
1204
1521
  if (visibleWidth(cell) <= width) return cell;
1205
- return cell.includes(ESC) ? closeSgr(truncate(cell, width)) : ellipsize(cell, width);
1522
+ // A coloured cell is cut on its visible width and still ends on `…`: cut
1523
+ // bare, a shortened `description · observe` lost its tag with nothing to
1524
+ // say any of it was missing.
1525
+ if (cell.includes(ESC)) return width <= 1 ? "…" : `${closeSgr(truncate(cell, width - 1))}…`;
1526
+ return ellipsize(cell, width);
1206
1527
  }
1207
1528
 
1208
1529
  /** Pad a possibly-coloured cell to `width` visible columns. */
@@ -1246,10 +1567,17 @@ export function stack(...groups: Array<string[] | null | undefined>): string[] {
1246
1567
  * a URL, a session id. Cutting those loses characters silently and with no
1247
1568
  * ellipsis to admit it; letting the terminal wrap keeps them copyable, which is
1248
1569
  * the entire reason they are printed.
1570
+ *
1571
+ * Colour is decided again from the stream written to. A command renders its
1572
+ * lines before it knows whether they succeed, so they are drawn for stdout,
1573
+ * and a failure then goes to stderr: with stdout a terminal and stderr
1574
+ * redirected, escape codes reached the log. A stream that cannot show colour
1575
+ * gets none, whatever the lines were drawn for.
1249
1576
  */
1250
1577
  export function printBlock(stdout: TTYOut, lines: string[]): void {
1251
1578
  if (lines.length === 0) return;
1252
- stdout.write(["", ...lines.map(closeSgr), ""].join("\n") + "\n");
1579
+ const shown = colorsEnabled(stdout) ? lines.map(closeSgr) : lines.map((l) => l.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""));
1580
+ stdout.write(["", ...shown, ""].join("\n") + "\n");
1253
1581
  }
1254
1582
 
1255
1583
  /**
@@ -1482,21 +1810,6 @@ export function chip(state: ChipState, opts?: RenderOpts): string {
1482
1810
  return pad(painted, CHIP_WIDTH);
1483
1811
  }
1484
1812
 
1485
- /** A bulleted list, wrapped, with continuation lines aligned under the text —
1486
- * `uninstall` prints 200-column bullets today that wrap into column 0. */
1487
- export function bullets(items: string[], opts?: RenderOpts): string[] {
1488
- const { cols, c } = ctx(opts);
1489
- const budget = Math.max(8, cols - INDENT.length - 4);
1490
- const out: string[] = [];
1491
- for (const item of items) {
1492
- const [first, ...rest] = wrap(item, budget);
1493
- if (first === undefined) continue;
1494
- out.push(`${INDENT}${c.pink("•")} ${first}`);
1495
- for (const line of rest) out.push(`${INDENT} ${line}`);
1496
- }
1497
- return out;
1498
- }
1499
-
1500
1813
  /** A dim aside under a block. */
1501
1814
  export function note(text: string, opts?: RenderOpts): string[] {
1502
1815
  const { cols, c } = ctx(opts);
@@ -1534,12 +1847,6 @@ export function warning(lines: string[], opts?: RenderOpts): string[] {
1534
1847
  return gutterBlock(c.warn("\u25B2"), lines, opts);
1535
1848
  }
1536
1849
 
1537
- /** The same, for the ones that destroy something. */
1538
- export function danger(lines: string[], opts?: RenderOpts): string[] {
1539
- const { c } = ctx(opts);
1540
- return gutterBlock(c.pink("!"), lines, opts);
1541
- }
1542
-
1543
1850
  /** Nothing to show, said the same way everywhere: what is empty, then the one
1544
1851
  * command that changes that. */
1545
1852
  export function emptyState(
@@ -1549,132 +1856,6 @@ export function emptyState(
1549
1856
  return stack(note(spec.what, opts), spec.cmd ? nextStep(spec.cmd, spec.hint, opts) : null);
1550
1857
  }
1551
1858
 
1552
- export interface HelpSpec {
1553
- usage: Array<[string, string?]>;
1554
- options?: Array<[string, string]>;
1555
- examples?: string[];
1556
- /** Free lines above the first section. */
1557
- lead?: string[];
1558
- }
1559
-
1560
- /**
1561
- * The description column for one block of entries, derived from its widest
1562
- * described name and capped.
1563
- *
1564
- * Its own step because the callers differ on the SCOPE they compute it over —
1565
- * {@link helpBlock} across usage and options together, {@link helpScreen} per
1566
- * section — and neither should be restating the cap or the floor.
1567
- */
1568
- export function helpColumn(entries: Array<[string, string?]>): number {
1569
- // Only DESCRIBED entries set the column. A bare usage line — `failproofai
1570
- // flush [--wait] [--timeout <secs>]`, which describes itself — is 44
1571
- // characters and has nothing in the second column, so letting it vote pushed
1572
- // every real description on the screen out to column 38.
1573
- const named = entries.filter(([, d]) => d).map(([n]) => visibleWidth(n));
1574
- return Math.min(34, Math.max(12, ...named));
1575
- }
1576
-
1577
- /** `<name> <description>` at a column the caller fixed. */
1578
- export function helpEntries(
1579
- items: Array<[string, string?]>,
1580
- nameWidth: number,
1581
- opts?: RenderOpts,
1582
- ): string[] {
1583
- const { cols, c } = ctx(opts);
1584
- const out: string[] = [];
1585
- for (const [raw, description] of items) {
1586
- // Pink is what you TYPE, dim is what it means, bold is the section it sits
1587
- // in. Three tones, the same three on every screen, so a flag looks like a
1588
- // flag whether you found it on `publish --help` or on the index. Decoration
1589
- // only: the column already separates the two halves, so the screen reads
1590
- // the same piped to a file or under NO_COLOR.
1591
- const name = c.pink(raw);
1592
- if (!description) {
1593
- out.push(`${INDENT}${name}`);
1594
- continue;
1595
- }
1596
- const budget = Math.max(12, cols - INDENT.length * 2 - nameWidth - 2);
1597
- const [first, ...rest] = wrap(description, budget);
1598
- // A name wider than the column takes its own line rather than shoving the
1599
- // description out — the top-level help has several of these today.
1600
- if (visibleWidth(raw) > nameWidth) {
1601
- out.push(`${INDENT}${name}`);
1602
- for (const line of [first, ...rest]) {
1603
- if (line !== undefined) out.push(`${INDENT}${" ".repeat(nameWidth)} ${c.dim(line)}`);
1604
- }
1605
- continue;
1606
- }
1607
- // Padded on the VISIBLE width, so a painted name still lands the column
1608
- // where an unpainted one does.
1609
- const gap = nameWidth - visibleWidth(raw);
1610
- out.push(`${INDENT}${name}${" ".repeat(gap)} ${c.dim(first ?? "")}`);
1611
- for (const line of rest) out.push(`${INDENT}${" ".repeat(nameWidth)} ${c.dim(line)}`);
1612
- }
1613
- return out;
1614
- }
1615
-
1616
- /**
1617
- * A usage/options/examples block, on one column across all three.
1618
- *
1619
- * The plain shape, for a surface with nothing else to say. A screen with prose
1620
- * sections, a heading, or a footer wants {@link helpScreen}, which is what the
1621
- * twelve `--help` screens use.
1622
- */
1623
- export function helpBlock(spec: HelpSpec, opts?: RenderOpts): string[] {
1624
- const nameWidth = helpColumn([...spec.usage, ...(spec.options ?? [])]);
1625
- const section = (label: string, body: string[]): string[] =>
1626
- body.length === 0 ? [] : [...rule(label, opts), ...body];
1627
- return stack(
1628
- spec.lead ? spec.lead.map((l) => `${INDENT}${l}`) : null,
1629
- section("usage", helpEntries(spec.usage, nameWidth, opts)),
1630
- section("options", helpEntries(spec.options ?? [], nameWidth, opts)),
1631
- section("examples", (spec.examples ?? []).map((e) => `${INDENT}${e}`)),
1632
- );
1633
- }
1634
-
1635
- /**
1636
- * Help is the one family of screens read at every width that belongs to no
1637
- * terminal in particular. Capped at 80 so `publish --help` is the same shape in
1638
- * a maximised window as in a tmux pane — and never wider than the terminal it
1639
- * is actually in, so the cap can only ever narrow.
1640
- */
1641
- export const HELP_COLS = 80;
1642
-
1643
- /** {@link optsFor}, capped to {@link HELP_COLS}. Every help screen uses this. */
1644
- export function helpOptsFor(stdout: TTYOut = process.stdout): Required<RenderOpts> {
1645
- const base = optsFor(stdout);
1646
- return { cols: Math.min(base.cols, HELP_COLS), color: base.color };
1647
- }
1648
-
1649
- export interface HelpHeading {
1650
- /** The command being documented. Omitted for the top-level index. */
1651
- command?: string;
1652
- version: string;
1653
- /** One line: what this command is for. */
1654
- tagline: string;
1655
- }
1656
-
1657
- /**
1658
- * The two lines every help screen opens with.
1659
- *
1660
- * The wordmark carries the same pink on `il` that {@link renderBrandLogo} tints,
1661
- * so a help screen and the wizard are visibly one product rather than two that
1662
- * happen to ship together. The version sits right-aligned and dim because it is
1663
- * the fact you want when a help screen and its binary disagree, and the fact you
1664
- * never want in the way otherwise.
1665
- */
1666
- export function helpHeading(spec: HelpHeading, opts?: RenderOpts): string[] {
1667
- const { cols, c } = ctx(opts);
1668
- const mark = `${c.bold("fa")}${c.pink("il")}${c.bold("proofai")}`;
1669
- const left = `${INDENT}${mark}${spec.command ? ` ${c.bold(spec.command)}` : ""}`;
1670
- const right = c.dim(`v${spec.version}`);
1671
- const gap = cols - visibleWidth(left) - visibleWidth(right) - INDENT.length;
1672
- // Too narrow for both: the version goes under rather than wrapping into the
1673
- // middle of the name, where it reads as part of the command.
1674
- const head = gap < 2 ? [left, `${INDENT}${right}`] : [left + " ".repeat(gap) + right];
1675
- return [...head, ...note(spec.tagline, opts)];
1676
- }
1677
-
1678
1859
  /**
1679
1860
  * A section of a help screen: a table of names, a paragraph, or lines that are
1680
1861
  * already laid out. Every one of them gets the same `rule` heading, which is
@@ -1688,46 +1869,325 @@ export type HelpSection =
1688
1869
  | { label: string; prose: string }
1689
1870
  | { label: string; lines: string[] };
1690
1871
 
1691
- export interface HelpScreenSpec extends HelpHeading {
1692
- sections: HelpSection[];
1693
- /** Dim closing lines — where to read more. */
1694
- footer?: string[];
1695
- }
1696
-
1872
+ // ── the 2026-10 screen language ─────────────────────────────────────────────
1697
1873
  /**
1698
- * A whole `--help`, assembled the same way twelve times.
1874
+ * The redesign's building blocks: open text on the terminal — the wordmark
1875
+ * header, UPPERCASE headings at column 0, rows indented two, colour by role, and
1876
+ * no boxes or rules. The byte-level target is `reference/screens.js` →
1877
+ * `FP.primitives` in the design handoff; the bytes differ only where the tiers
1878
+ * above say how a colour is spelled.
1699
1879
  *
1700
- * Before this, every screen was its own template literal: `USAGE` here and
1701
- * `Usage:` there, a description column hand-counted per file, no version on the
1702
- * page, and no colour on any of them while the index it was reached from had
1703
- * all three. The screens still say exactly what their authors wrote — this owns
1704
- * only the shape.
1880
+ * Commands, URLs, paths and ids are never cut: they are the part a person
1881
+ * copies, and half of one runs the wrong thing. Prose may be shortened with `…`
1882
+ * when `fit` is on, which a caller sets only for a terminal — piped output is
1883
+ * never cut.
1884
+ *
1885
+ * ONE exported factory rather than a dozen functions, on purpose. The CLI ships
1886
+ * as a single scope-hoisted bundle, and a new top-level name that matches a
1887
+ * local inside a builtin policy or an audit detector (`cmd`, `row`, `head`, …)
1888
+ * makes the bundler rename that local. That changes the function source the
1889
+ * audit cache keys on (`engineVersion` / `detectorVersion`), and every user's
1890
+ * history is rescanned from zero at upgrade. Everything here lives inside the
1891
+ * factory, so the bundle gains exactly one name.
1705
1892
  */
1706
- export function helpScreen(spec: HelpScreenSpec, opts?: RenderOpts): string[] {
1893
+ export interface ScreenKitOpts extends RenderOpts {
1894
+ /** The version the header shows. Defaults to this build's. */
1895
+ version?: string;
1896
+ /** Shorten prose that does not fit `cols`, ending on `…`. Terminal only. */
1897
+ fit?: boolean;
1898
+ }
1899
+
1900
+ export function screenKit(opts: ScreenKitOpts = {}) {
1707
1901
  const { cols, c } = ctx(opts);
1708
- const width = Math.max(12, cols - INDENT.length * 2);
1709
- const groups: Array<string[] | null> = [helpHeading(spec, opts)];
1710
- for (const section of spec.sections) {
1711
- // Per SECTION, not per screen. One column across the whole page is what a
1712
- // screen of like-shaped entries wants, and it is exactly wrong on a screen
1713
- // that has both: `failproofai policies show <owner>/<repo>` is 40 columns
1714
- // and `--all` is 5, so a shared column left every flag description hanging
1715
- // 34 columns out with nothing under it. What made two screens read as two
1716
- // products was the INDENT and the heading, and those are shared here.
1717
- const body =
1718
- "entries" in section
1719
- ? [
1720
- ...helpEntries(section.entries, helpColumn(section.entries), opts),
1721
- ...(section.after?.length
1722
- ? ["", ...section.after.map((l) => (l.trim() === "" ? "" : `${INDENT}${l}`))]
1723
- : []),
1724
- ]
1725
- : "prose" in section
1726
- ? wrap(section.prose, width).map((l) => `${INDENT}${l}`)
1727
- : section.lines.map((l) => (l.trim() === "" ? "" : `${INDENT}${l}`));
1728
- if (body.length === 0) continue;
1729
- groups.push([...rule(section.label, opts), ...body]);
1730
- }
1731
- if (spec.footer?.length) groups.push(spec.footer.map((l) => `${INDENT}${c.dim(l)}`));
1732
- return stack(...groups);
1902
+ const colour = opts.color ?? false;
1903
+ const version = opts.version ?? cliVersion;
1904
+ const sep = c.ink3(" · ");
1905
+ // Never paint nothing: an empty span is two escapes with no text between.
1906
+ const tint = (paintFn: (s: string) => string, s: string): string => (s ? paintFn(s) : "");
1907
+
1908
+ /** Typeable: every command on a screen is pink. */
1909
+ const cmd = (s: string): string => c.pink(s);
1910
+ const on = c.guide(RADIO_ON);
1911
+ const off = c.ink3(RADIO_OFF);
1912
+ /** A row whose thing could not run — a file that is missing or will not load. */
1913
+ const failed = c.err("✕");
1914
+ /** Picked and not picked: a pack's defaults on `policies show`, a checklist row. */
1915
+ const selected = c.pink("■");
1916
+ const unselected = c.ink3("□");
1917
+ /** Grey text that qualifies a value rather than being one: `observe` beside a pack. */
1918
+ const meta = (s: string): string => tint(c.ink3, s);
1919
+
1920
+ /** Prose shortened to `room` columns when fitting is on; untouched otherwise. */
1921
+ const prose = (s: string, room: number): string =>
1922
+ opts.fit && visibleWidth(s) > room ? fit(s, Math.max(1, room)) : s;
1923
+
1924
+ /** `failproof ai v1.0.11 · Context` — the line every screen opens with. */
1925
+ const header = (context?: string): string =>
1926
+ `${c.bold("fa")}${c.pinkBold("il")}${c.bold("proof ai")} ${c.ink3(`v${version}`)}` +
1927
+ (context ? `${sep}${context}` : "");
1928
+
1929
+ /** An UPPERCASE heading, with optional grey meta after it. */
1930
+ const head = (text: string, meta?: string): string =>
1931
+ c.bold(text.toUpperCase()) + (meta ? ` ${c.ink3(meta)}` : "");
1932
+
1933
+ /**
1934
+ * `name description` rows on one column for the block: at least `minCol`
1935
+ * wide, and always two spaces past the widest name. The design fixes its
1936
+ * columns by hand, and the longest real policy name is exactly the 32 it
1937
+ * fixes, so a fixed column runs that name into its description. Names are
1938
+ * never cut; descriptions are prose and give up the room.
1939
+ */
1940
+ const rows = (items: Array<[string, string?]>, minCol = 0): string[] => {
1941
+ const width = Math.max(minCol, ...items.map(([name, desc]) => (desc ? visibleWidth(name) + 2 : 0)));
1942
+ return items.map(([name, desc]) => {
1943
+ if (!desc) return `${INDENT}${name}`;
1944
+ const lead = `${INDENT}${pad(name, width)}`;
1945
+ return lead + prose(c.ink2(desc), cols - visibleWidth(lead));
1946
+ });
1947
+ };
1948
+
1949
+ /**
1950
+ * Grey labels and their values, on one column at least nine wide — where the
1951
+ * reference starts its values. Values are never cut: they are URLs, ids and
1952
+ * counts, and a line that outgrows the terminal is left for it to wrap.
1953
+ *
1954
+ * An empty label continues the row above it, under the same value column —
1955
+ * `config --status` lists each paused session that way.
1956
+ */
1957
+ const kv = (items: Array<[string, string]>, minLabel = 9): string[] => {
1958
+ const width = Math.max(minLabel, ...items.map(([label]) => visibleWidth(label))) + 2;
1959
+ return items.map(([label, value]) =>
1960
+ value ? `${INDENT}${pad(tint(c.ink3, label), width)}${value}` : `${INDENT}${tint(c.ink3, label)}`,
1961
+ );
1962
+ };
1963
+
1964
+ /** `✓ Done, said in the past tense.` with optional grey detail. */
1965
+ const ok = (text: string, detail?: string): string =>
1966
+ `${c.guide("✓")} ${text}${detail ? ` ${c.ink3(detail)}` : ""}`;
1967
+
1968
+ /** `▲ Needs attention. · the fix` */
1969
+ const caution = (text: string, fix?: string): string =>
1970
+ `${c.warn("▲")} ${text}${fix ? `${sep}${cmd(fix)}` : ""}`;
1971
+
1972
+ /** `✕ Failed, said plainly. · the fix` */
1973
+ const fail = (text: string, fix?: string): string =>
1974
+ `${c.err("✕")} ${text}${fix ? `${sep}${cmd(fix)}` : ""}`;
1975
+
1976
+ /**
1977
+ * A message as every notice prints it: the first line after one state glyph,
1978
+ * each later line a fix line indented two (keeping any indentation of its
1979
+ * own), and each `backticked` span painted as a command with the backticks
1980
+ * dropped. A message author writes `Run \`failproofai help\` for usage.`,
1981
+ * and it reads `Run failproofai help for usage.` in a pipe, with the
1982
+ * command in pink on a terminal. Unbalanced backticks are left as written
1983
+ * rather than guessed at.
1984
+ *
1985
+ * Never cut: a notice holds the reason something failed and the command that
1986
+ * fixes it, and half of either is worse than a line the terminal wraps.
1987
+ */
1988
+ const notice = (state: "ok" | "caution" | "fail", message: string): string[] => {
1989
+ const typeable = (line: string): string =>
1990
+ (line.split("`").length - 1) % 2 === 0 ? line.replace(/`([^`]+)`/g, (_whole, typed: string) => cmd(typed)) : line;
1991
+ const glyph = state === "ok" ? c.guide("✓") : state === "caution" ? c.warn("▲") : c.err("✕");
1992
+ const [first = "", ...rest] = message.split("\n");
1993
+ return [
1994
+ `${glyph} ${typeable(first)}`,
1995
+ ...rest.map((line) => (line.trim() === "" ? "" : `${INDENT}${typeable(line)}`)),
1996
+ ];
1997
+ };
1998
+
1999
+ /**
2000
+ * `Continue? y/N` — the one question a destructive command asks, after the
2001
+ * caller has printed what it would do. Only `y` or `yes`, in any case, then
2002
+ * Enter, is a yes. Enter alone, anything else, Esc, Ctrl+C, Ctrl+D or the
2003
+ * input closing is a no: the default answer to "remove these?" has to be the
2004
+ * one that removes nothing, and one stray key must not be consent. Keys are
2005
+ * read the way the pickers read them (raw, echoed here), so it behaves the
2006
+ * same on every terminal. Callers ask only when there is one to answer on.
2007
+ */
2008
+ const confirm = (io: { stdin: TTYIn; stdout: TTYOut }, question = "Continue?"): Promise<boolean> =>
2009
+ new Promise((resolve) => {
2010
+ const { stdin, stdout } = io;
2011
+ let typed = "";
2012
+ let settled = false;
2013
+ const wasRaw = stdin.isRaw;
2014
+ const settle = (answer: boolean): void => {
2015
+ if (settled) return;
2016
+ settled = true;
2017
+ stdin.removeListener("keypress", onKey);
2018
+ stdin.removeListener("end", onEnd);
2019
+ stdin.setRawMode?.(wasRaw ?? false);
2020
+ stdin.pause();
2021
+ stdout.write("\n");
2022
+ resolve(answer);
2023
+ };
2024
+ function onEnd(): void {
2025
+ settle(false);
2026
+ }
2027
+ function onKey(sequence: string | undefined, key: readline.Key | undefined): void {
2028
+ const name = key?.name;
2029
+ if ((key?.ctrl && (name === "c" || name === "d")) || name === "escape") settle(false);
2030
+ else if (name === "return" || name === "enter") settle(/^y(es)?$/i.test(typed.trim()));
2031
+ else if (name === "backspace") {
2032
+ if (typed) {
2033
+ typed = typed.slice(0, -1);
2034
+ stdout.write("\b \b");
2035
+ }
2036
+ } else if (sequence && sequence.length === 1 && sequence >= " " && !key?.ctrl && !key?.meta) {
2037
+ typed += sequence;
2038
+ stdout.write(sequence);
2039
+ }
2040
+ }
2041
+ stdout.write(`${question} ${c.ink3("y/N")} `);
2042
+ readline.emitKeypressEvents(stdin);
2043
+ stdin.setRawMode?.(true);
2044
+ stdin.on("keypress", onKey);
2045
+ stdin.on("end", onEnd);
2046
+ stdin.resume();
2047
+ });
2048
+
2049
+ /** Lowercase key hints at the foot of an interactive screen. */
2050
+ const keys = (list: string[]): string => c.ink3(list.join(" · "));
2051
+
2052
+ /**
2053
+ * A progress bar `width` cells wide and `fraction` full: the filled part pink,
2054
+ * the rest the track grey. With colour off the rest is blank, because one
2055
+ * unbroken run of `━` would show no progress at all.
2056
+ */
2057
+ const bar = (width: number, fraction: number): string => {
2058
+ const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width);
2059
+ const rest = Math.max(0, width - filled);
2060
+ return tint(c.pink, "━".repeat(filled)) + (colour ? tint(c.track, "━".repeat(rest)) : " ".repeat(rest));
2061
+ };
2062
+
2063
+ /**
2064
+ * The logomark with a two-space margin, or nothing on a terminal too narrow
2065
+ * to draw it — the old `▮▮` one-liner is retired with the rest of that mark.
2066
+ * Callers show it only on a TTY, and only on bare `failproofai` and at the
2067
+ * start of a `config` run.
2068
+ */
2069
+ const logo = (): string[] => {
2070
+ if (cols < LOGO_MIN_COLS) return [];
2071
+ const tier: ColorTier = colour ? colorTier() : "basic";
2072
+ return renderLogo(tier).map((line) => `${INDENT}${line}`);
2073
+ };
2074
+
2075
+ /** A command's `--help`: usage, options and examples. Nothing else. */
2076
+ const helpPage = (spec: {
2077
+ name: string;
2078
+ usage: Array<[string, string?]>;
2079
+ options?: Array<[string, string]>;
2080
+ examples?: string[];
2081
+ /** The options column, when the design fixes a wider one than the data needs. */
2082
+ optionsCol?: number;
2083
+ }): string[] => {
2084
+ // Option descriptions name flags and agent ids (`-u -c`, `goose`), which are
2085
+ // never cut: one too wide for the terminal wraps under its own column
2086
+ // rather than ending on `…`. Usage descriptions are prose, and shorten.
2087
+ const options = (items: Array<[string, string]>, minCol: number): string[] => {
2088
+ const width = Math.max(minCol, ...items.map(([name, desc]) => (desc ? visibleWidth(name) + 2 : 0)));
2089
+ return items.flatMap(([name, desc]) => {
2090
+ if (!desc) return [`${INDENT}${name}`];
2091
+ const lead = `${INDENT}${pad(name, width)}`;
2092
+ const room = cols - visibleWidth(lead);
2093
+ if (!opts.fit || visibleWidth(desc) <= room) return [lead + c.ink2(desc)];
2094
+ const under = " ".repeat(visibleWidth(lead));
2095
+ return wrapAnsi(desc, Math.max(1, room)).map((part, i) => (i === 0 ? lead : under) + c.ink2(part));
2096
+ });
2097
+ };
2098
+ const out = [header(spec.name), "", head("Usage"), ...rows(spec.usage)];
2099
+ if (spec.options?.length) out.push("", head("Options"), ...options(spec.options, spec.optionsCol ?? 0));
2100
+ if (spec.examples?.length) out.push("", head("Examples"), ...spec.examples.map((e) => `${INDENT}${cmd(e)}`));
2101
+ return out;
2102
+ };
2103
+
2104
+ /**
2105
+ * A block that redraws in place: a progress screen.
2106
+ *
2107
+ * Every frame is ONE write, through the same atomic `repaint` the prompts use,
2108
+ * and frames are held to one per 100 ms — the design's progress tempo — with
2109
+ * the newest always landing. `build` runs only for a frame that is actually
2110
+ * drawn, so a caller can ask after every event it hears about.
2111
+ *
2112
+ * The cursor is hidden while the block is live and shown again when it ends:
2113
+ * on `done`, on exit, and on SIGINT or SIGTERM. A signal is re-raised once the
2114
+ * cursor is back, so Ctrl+C still stops the command exactly as it did before
2115
+ * anything was listening for it. TTY only: everywhere else the caller prints
2116
+ * its last screen once, with no frames.
2117
+ */
2118
+ const live = (out: TTYOut) => {
2119
+ const region: Region = { lastCount: 0 };
2120
+ const every = 100;
2121
+ let lastAt = 0;
2122
+ let pending: (() => string[]) | null = null;
2123
+ let timer: ReturnType<typeof setTimeout> | null = null;
2124
+ let hidden = false;
2125
+ let over = false;
2126
+
2127
+ const showAgain = (): void => {
2128
+ out.write(`${ESC}[?25h`);
2129
+ };
2130
+ const release = (): void => {
2131
+ process.removeListener("exit", showAgain);
2132
+ process.removeListener("SIGINT", onSignal);
2133
+ process.removeListener("SIGTERM", onSignal);
2134
+ };
2135
+ const onSignal = (signal: NodeJS.Signals): void => {
2136
+ over = true;
2137
+ if (timer) clearTimeout(timer);
2138
+ release();
2139
+ showAgain();
2140
+ process.kill(process.pid, signal);
2141
+ };
2142
+ const flush = (): void => {
2143
+ timer = null;
2144
+ const build = pending;
2145
+ pending = null;
2146
+ if (!build || over) return;
2147
+ lastAt = Date.now();
2148
+ let lead = "";
2149
+ if (!hidden) {
2150
+ hidden = true;
2151
+ lead = `${ESC}[?25l`;
2152
+ process.once("exit", showAgain);
2153
+ process.on("SIGINT", onSignal);
2154
+ process.on("SIGTERM", onSignal);
2155
+ }
2156
+ repaint(out, region, build(), { lead });
2157
+ };
2158
+
2159
+ return {
2160
+ /** Ask for a frame; it is drawn now, or as soon as the tempo allows. */
2161
+ draw(build: () => string[]): void {
2162
+ if (over) return;
2163
+ pending = build;
2164
+ if (timer) return;
2165
+ const wait = lastAt + every - Date.now();
2166
+ if (wait <= 0) {
2167
+ flush();
2168
+ return;
2169
+ }
2170
+ timer = setTimeout(flush, wait);
2171
+ timer.unref?.();
2172
+ },
2173
+ /**
2174
+ * Draw the last frame at once and give the cursor back. Never cut: nothing
2175
+ * repaints it, so it stays in scrollback exactly as written.
2176
+ */
2177
+ done(lines: string[]): void {
2178
+ if (over) return;
2179
+ over = true;
2180
+ if (timer) clearTimeout(timer);
2181
+ timer = null;
2182
+ pending = null;
2183
+ release();
2184
+ repaint(out, region, lines, { cut: false, tail: hidden ? `${ESC}[?25h` : "" });
2185
+ },
2186
+ };
2187
+ };
2188
+
2189
+ return {
2190
+ cols, sep, cmd, on, off, failed, selected, unselected, meta,
2191
+ header, head, rows, kv, ok, caution, fail, notice, confirm, keys, bar, logo, helpPage, live,
2192
+ };
1733
2193
  }