alysis-code 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,2409 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import traceback
6
+ from dataclasses import dataclass, replace
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+ from urllib.parse import urlparse
11
+
12
+ import httpx
13
+ import typer
14
+ from click import Abort
15
+ from rich.console import Console, Group
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from ..config import (
21
+ AppConfig,
22
+ ConfigError,
23
+ config_path,
24
+ credentials_path,
25
+ default_sessions_dir,
26
+ load_config,
27
+ save_config,
28
+ save_persisted_profile_key,
29
+ set_config_value,
30
+ )
31
+ from ..llm.factory import make_llm_client
32
+ from ..llm.types import LLMError
33
+ from ..profile_presets import (
34
+ NATIVE_PROFILE_PROTOCOLS,
35
+ PROFILE_PRESETS,
36
+ ProfilePreset,
37
+ advanced_provider_selection_presets,
38
+ canonical_model_alias_for_preset,
39
+ make_profile_from_preset,
40
+ model_options_for_preset,
41
+ preset_protocol_summary,
42
+ preset_selection_label,
43
+ provider_selection_presets,
44
+ )
45
+ from ..profiles import (
46
+ SUBSCRIPTION_SELECTION_REQUIRED_KEY,
47
+ ProfileSpec,
48
+ active_subscription_selection_ready,
49
+ add_profile,
50
+ get_profile,
51
+ set_active_profile,
52
+ subscription_selection_supported,
53
+ update_profile,
54
+ )
55
+ from ..provider_diagnostics import provider_diagnostic_warning_lines
56
+ from ..provider_model_catalog import (
57
+ ProviderModelCatalogError,
58
+ ProviderModelOption,
59
+ discover_provider_models,
60
+ )
61
+ from ..reasoning_contracts import (
62
+ UNKNOWN_CONTRACT,
63
+ reasoning_contract_for,
64
+ reasoning_labels_allowed_by_contract,
65
+ )
66
+ from ..sandbox_doctor import (
67
+ BubblewrapInstallPlan,
68
+ SandboxDiagnostic,
69
+ SandboxPullResult,
70
+ detect_bubblewrap_install_plan,
71
+ diagnose_sandbox,
72
+ format_sandbox_problem_message,
73
+ install_bubblewrap,
74
+ pull_sandbox_images,
75
+ )
76
+ from ..sandbox_settings import apply_sandbox_mode_to_config
77
+ from ..surface.console import make_console
78
+ from ..workspace_binding import WorkspaceBindingError, resolve_workspace_binding
79
+
80
+ _DEFAULT_WORKSPACE_KEY = "default_workspace_path"
81
+ # Explicit "the user has completed first-run setup" marker. Persisted here (and
82
+ # read by the first-run gate in commands/startup.py) so that whether to route a
83
+ # launch into setup no longer relies on the unreliable "a model happens to be
84
+ # configured" heuristic.
85
+ _ONBOARDED_KEY = "onboarded"
86
+ _CUSTOM_MODEL_VALUE = "__custom_model__"
87
+ _CUSTOM_PROVIDER_KEY = "custom"
88
+ _NVIDIA_PRESET_KEY = "nvidia"
89
+ _MODEL_REASONING_SETUP_PRESET_KEYS = frozenset({_NVIDIA_PRESET_KEY, "zai-coding-plan"})
90
+ _SETUP_REASONING_LABELS: tuple[str, ...] = (
91
+ "off",
92
+ "minimal",
93
+ "low",
94
+ "medium",
95
+ "high",
96
+ "xhigh",
97
+ "max",
98
+ "ultra",
99
+ "auto",
100
+ )
101
+ _MAX_API_KEY_VALIDATION_ATTEMPTS = 3
102
+ _FALLBACK_VALIDATION_MODEL = "gpt-5.4-mini"
103
+ _VALIDATION_TIMEOUT_S = 8.0
104
+ _GEMINI_VALIDATION_TIMEOUT_S = 20.0
105
+ _GEMINI_VALIDATION_REASONING_EFFORT = "low"
106
+ _ADVANCED_PROVIDER_PRESETS_VALUE = "__advanced_provider_presets__"
107
+ _NATIVE_EXECUTION_VALUE = "__native_execution__"
108
+ _SUBSCRIPTION_EXECUTION_VALUE = "__subscription_execution__"
109
+ _RUNTIME_EXECUTION_PREFIX = "runtime:"
110
+
111
+ _ValidationStatus = Literal[
112
+ "validated",
113
+ "failed",
114
+ "inconclusive",
115
+ "skipped",
116
+ "model_not_found",
117
+ ]
118
+
119
+
120
+ class _SetupCancelled(Exception):
121
+ pass
122
+
123
+
124
+ class _GoBack(Exception):
125
+ """Raised by a wizard step when the user pressed Esc to go back."""
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class _ExecutionStepResult:
130
+ backend: Literal["native", "delegated"]
131
+ runtime: str | None = None
132
+ label: str = "API key"
133
+ description: str = ""
134
+ auth_hint: str = ""
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class _DelegatedAuthResult:
139
+ connected: bool
140
+ summary: str
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class _ProfileStepResult:
145
+ profile: ProfileSpec
146
+ label: str
147
+ preset: ProfilePreset | None
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class _ApiKeyStepResult:
152
+ api_key: str
153
+ validation_status: _ValidationStatus
154
+ validation_message: str = ""
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class _ModelStepResult:
159
+ model: str
160
+ custom: bool = False
161
+ reasoning_label: str = "auto"
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class _WorkspaceStepResult:
166
+ workspace: str
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class _SandboxStepResult:
171
+ ready: bool
172
+ status: str
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class _ApiKeyValidationResult:
177
+ status: _ValidationStatus
178
+ message: str = ""
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class _ResolvedValidationModel:
183
+ model: str
184
+ used_fallback: bool
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class _FileSnapshot:
189
+ path: Path
190
+ existed: bool
191
+ data: bytes | None
192
+
193
+
194
+ def run_setup_wizard() -> bool:
195
+ """Run the first-time setup wizard."""
196
+ console = _resolve_console()
197
+ escape_unavailable_reason = _escape_capture_unavailable_reason()
198
+ if escape_unavailable_reason is not None:
199
+ console.print(
200
+ f"[dim]Note: this terminal does not support Esc/back navigation ({escape_unavailable_reason}). "
201
+ "Use Ctrl+C to cancel.[/dim]"
202
+ )
203
+
204
+ execution_result: _ExecutionStepResult | None = None
205
+ profile_result: _ProfileStepResult | None = None
206
+ api_key_result: _ApiKeyStepResult | None = None
207
+ model_result: _ModelStepResult | None = None
208
+ workspace_result: _WorkspaceStepResult | None = None
209
+ step_idx = 0
210
+
211
+ try:
212
+ while True:
213
+ steps = _setup_step_names(execution_result)
214
+ if step_idx >= len(steps):
215
+ break
216
+ step_name = steps[step_idx]
217
+ try:
218
+ if step_name == "welcome":
219
+ _print_welcome(console)
220
+ _wait_to_begin()
221
+ elif step_name == "execution":
222
+ previous_execution = execution_result
223
+ execution_result = _prompt_execution(
224
+ console,
225
+ previous=previous_execution,
226
+ )
227
+ if previous_execution is not None and execution_result != previous_execution:
228
+ profile_result = None
229
+ api_key_result = None
230
+ model_result = None
231
+ elif step_name == "profile":
232
+ previous_profile = profile_result
233
+ new_profile = _prompt_profile(console, previous=previous_profile)
234
+ if previous_profile is not None and new_profile != previous_profile:
235
+ api_key_result = None
236
+ model_result = None
237
+ profile_result = new_profile
238
+ elif step_name == "api_key":
239
+ if profile_result is None:
240
+ raise ConfigError("Provider profile step was skipped.")
241
+ api_key_result = _prompt_api_key(
242
+ console,
243
+ profile_result,
244
+ previous=api_key_result,
245
+ )
246
+ elif step_name == "model":
247
+ if profile_result is None or api_key_result is None:
248
+ raise ConfigError("Provider profile or API key step was skipped.")
249
+ model_result, api_key_result = _prompt_model(
250
+ console,
251
+ profile_result,
252
+ api_key_result=api_key_result,
253
+ previous=model_result,
254
+ )
255
+ elif step_name == "workspace":
256
+ workspace_result = _prompt_workspace(console, previous=workspace_result)
257
+ step_idx += 1
258
+ except _GoBack:
259
+ if step_idx == 0:
260
+ if _confirm_cancel(console, prompt="Cancel setup? [y/N]"):
261
+ raise _SetupCancelled() from None
262
+ continue
263
+ step_idx -= 1
264
+ continue
265
+ except (KeyboardInterrupt, Abort, EOFError):
266
+ if _confirm_cancel(console):
267
+ raise _SetupCancelled() from None
268
+ continue
269
+
270
+ if execution_result is None or workspace_result is None:
271
+ raise ConfigError("Setup did not collect all required inputs.")
272
+
273
+ if execution_result.backend == "delegated":
274
+ cfg = _commit_delegated_setup(
275
+ execution_result=execution_result,
276
+ workspace_result=workspace_result,
277
+ console=console,
278
+ )
279
+ auth_result = _maybe_connect_delegated_runtime(
280
+ console=console,
281
+ cfg=cfg,
282
+ execution_result=execution_result,
283
+ )
284
+ if cfg.execution.backend == "native":
285
+ _print_provider_diagnostic_warnings(console, cfg)
286
+ _prompt_and_check_sandbox(console, cfg)
287
+ _print_delegated_setup_complete(
288
+ console=console,
289
+ execution_result=execution_result,
290
+ workspace_result=workspace_result,
291
+ auth_result=auth_result,
292
+ cfg=cfg,
293
+ )
294
+ return True
295
+
296
+ if profile_result is None or api_key_result is None or model_result is None:
297
+ raise ConfigError("Setup did not collect all required native-provider inputs.")
298
+
299
+ cfg = _commit_setup(
300
+ profile_result=profile_result,
301
+ api_key_result=api_key_result,
302
+ model_result=model_result,
303
+ workspace_result=workspace_result,
304
+ console=console,
305
+ )
306
+ _print_provider_diagnostic_warnings(console, cfg)
307
+ sandbox_result = _prompt_and_check_sandbox(console, cfg)
308
+ _print_setup_complete(
309
+ console=console,
310
+ profile_result=profile_result,
311
+ api_key_result=api_key_result,
312
+ model_result=model_result,
313
+ workspace_result=workspace_result,
314
+ sandbox_result=sandbox_result,
315
+ )
316
+ _maybe_offer_alysis_login(console, profile_result=profile_result, cfg=cfg)
317
+ return True
318
+ except _SetupCancelled:
319
+ console.print()
320
+ console.print("[yellow]Setup cancelled. No changes saved.[/yellow]")
321
+ return False
322
+ except (ConfigError, OSError) as exc:
323
+ console.print(f"[red]Setup failed:[/red] {exc}")
324
+ return False
325
+ except Exception as exc: # noqa: BLE001 - boundary translates unknown failures
326
+ log_path = _write_exception_log("setup_wizard", exc)
327
+ console.print(f"[red]Setup failed:[/red] {exc}")
328
+ if log_path is not None:
329
+ console.print(f"[dim]Details saved to: {log_path}[/dim]")
330
+ return False
331
+
332
+
333
+ def _maybe_offer_alysis_login(
334
+ console: Console, *, profile_result: _ProfileStepResult, cfg: AppConfig
335
+ ) -> None:
336
+ """If the user chose the hosted MiMo (login-based) preset, offer to connect now.
337
+
338
+ That preset authenticates via `alysis login` instead of an API key.
339
+ Running the handshake here saves a separate step; declining is harmless (the
340
+ profile is already configured and `alysis login` can be run any time).
341
+ """
342
+ from ..alysis_cloud import PROFILE_KEY
343
+
344
+ preset = profile_result.preset
345
+ if preset is None or preset.key != PROFILE_KEY:
346
+ return
347
+
348
+ console.print()
349
+ try:
350
+ connect = _prompt_yes_no(
351
+ "Connect your Alysis Code account now? [Y/n]",
352
+ default=True,
353
+ )
354
+ except _GoBack:
355
+ connect = False
356
+
357
+ if not connect:
358
+ console.print("[dim]Run `alysis login` whenever you're ready to connect.[/dim]")
359
+ return
360
+
361
+ from .. import account_login
362
+
363
+ try:
364
+ result = account_login.login(
365
+ cfg, output_write=lambda message: console.print(message, highlight=False)
366
+ )
367
+ except (account_login.AlysisLoginError, ConfigError) as exc:
368
+ console.print(f"[yellow]{exc}[/yellow]")
369
+ console.print("[dim]You can finish this later with `alysis login`.[/dim]")
370
+ return
371
+
372
+ who = f" as [bold]{result.email}[/bold]" if result.email else ""
373
+ console.print(f"[green]Logged in{who}.[/green] Your Alysis Code account is connected.")
374
+ console.print(
375
+ f"[dim]Default model: [/dim][bold]{result.model}[/bold][dim] · "
376
+ "switch with /model in chat or `alysis config`.[/dim]"
377
+ )
378
+
379
+
380
+ def _resolve_console() -> Console:
381
+ return make_console()
382
+
383
+
384
+ def _print_welcome(console: Console) -> None:
385
+ body = Group(
386
+ Text("Alysis Code is a coding agent that runs in your terminal."),
387
+ Text("First choose how you want to connect Alysis Code to AI models."),
388
+ Text(""),
389
+ Text(" API key: connect directly to a supported model provider."),
390
+ Text(" AI subscription: sign in through a supported provider's official client."),
391
+ Text(""),
392
+ Text("Both paths ask for the workspace folder you want to work on."),
393
+ Text(""),
394
+ Text("[Enter] begin [Esc] cancel", style="dim"),
395
+ )
396
+ console.print()
397
+ console.print(Panel(body, title="Welcome to Alysis Code", border_style="cyan"))
398
+
399
+
400
+ def _wait_to_begin() -> None:
401
+ value = _esc_aware_text_input("Press Enter to begin", default="", show_default=False)
402
+ if _is_cancel_token(value):
403
+ raise _GoBack()
404
+
405
+
406
+ def _runtime_setup_options() -> tuple[Any, ...]:
407
+ from ..provider_auth import provider_auth_setup_options
408
+
409
+ return tuple(provider_auth_setup_options())
410
+
411
+
412
+ def _connection_method_picker_rows() -> list[tuple[str, str, str]]:
413
+ return [
414
+ (
415
+ _NATIVE_EXECUTION_VALUE,
416
+ "Use an API key",
417
+ "Connect directly to a supported model provider.",
418
+ ),
419
+ (
420
+ _SUBSCRIPTION_EXECUTION_VALUE,
421
+ "Use an AI subscription",
422
+ "Sign in through a supported provider's official client.",
423
+ ),
424
+ ]
425
+
426
+
427
+ def _subscription_picker_rows() -> list[tuple[str, str, str]]:
428
+ rows: list[tuple[str, str, str]] = []
429
+ for option in _runtime_setup_options():
430
+ runtime_id = str(getattr(option, "id", "") or "").strip()
431
+ label = str(getattr(option, "label", "") or runtime_id).strip()
432
+ description = str(getattr(option, "description", "") or "").strip()
433
+ if not runtime_id or not label:
434
+ continue
435
+ rows.append((f"{_RUNTIME_EXECUTION_PREFIX}{runtime_id}", label, description))
436
+ return rows
437
+
438
+
439
+ def _connect_provider_picker_rows() -> list[tuple[str, str, str]]:
440
+ """One merged "Connect a provider" list.
441
+
442
+ Subscription sign-ins and API-key providers appear side by side, so the
443
+ auth method is a property of the row rather than an upfront branch.
444
+ Subscription rows lead (including the Alysis Code account itself), then the
445
+ hosted providers in preset order; the advanced sub-list (local endpoints,
446
+ custom URLs, and legacy presets) stays behind the final row. Values are
447
+ either a preset key, a ``runtime:<id>`` subscription value, or
448
+ :data:`_ADVANCED_PROVIDER_PRESETS_VALUE`.
449
+ """
450
+ rows: list[tuple[str, str, str]] = []
451
+ # The Alysis Code account leads: browser sign-in, no API key, free daily
452
+ # hosted DeepSeek included — the zero-friction way in.
453
+ rows.append(
454
+ (
455
+ "alysis",
456
+ "Alysis Code account",
457
+ "Free daily DeepSeek included — browser sign-in, no API key.",
458
+ )
459
+ )
460
+ for value, label, description in _subscription_picker_rows():
461
+ rows.append((value, label, description or "Browser sign-in with your subscription."))
462
+ rows.extend(
463
+ (preset.key, preset_selection_label(preset), "API key") for preset in _setup_presets()
464
+ )
465
+ rows.append(
466
+ (
467
+ _ADVANCED_PROVIDER_PRESETS_VALUE,
468
+ "Advanced / local / compatibility providers",
469
+ "Local endpoints (Ollama, LM Studio, vLLM), custom URLs, and legacy "
470
+ "OpenAI-compatible presets.",
471
+ )
472
+ )
473
+ return rows
474
+
475
+
476
+ def _runtime_setup_option(runtime_id: str) -> Any:
477
+ normalized = str(runtime_id or "").strip()
478
+ for option in _runtime_setup_options():
479
+ if str(getattr(option, "id", "") or "").strip() == normalized:
480
+ return option
481
+ raise ConfigError(f"Unknown AI subscription connection: {normalized or '(empty)'}")
482
+
483
+
484
+ def _execution_result_from_value(value: str) -> _ExecutionStepResult:
485
+ selected = str(value or "").strip()
486
+ if selected == _NATIVE_EXECUTION_VALUE:
487
+ return _ExecutionStepResult(backend="native")
488
+ if not selected.startswith(_RUNTIME_EXECUTION_PREFIX):
489
+ raise ConfigError(f"Unknown connection method: {selected or '(empty)'}")
490
+ runtime_id = selected.removeprefix(_RUNTIME_EXECUTION_PREFIX).strip()
491
+ option = _runtime_setup_option(runtime_id)
492
+ return _ExecutionStepResult(
493
+ backend="delegated",
494
+ runtime=runtime_id,
495
+ label=str(getattr(option, "label", "") or runtime_id).strip(),
496
+ description=str(getattr(option, "description", "") or "").strip(),
497
+ auth_hint=str(getattr(option, "auth_hint", "") or "").strip(),
498
+ )
499
+
500
+
501
+ def _prompt_execution(
502
+ console: Console,
503
+ *,
504
+ previous: _ExecutionStepResult | None = None,
505
+ ) -> _ExecutionStepResult:
506
+ current_method = (
507
+ _SUBSCRIPTION_EXECUTION_VALUE
508
+ if previous is not None and previous.backend == "delegated"
509
+ else _NATIVE_EXECUTION_VALUE
510
+ )
511
+ while True:
512
+ selected_method = _run_wizard_picker(
513
+ console=console,
514
+ title="Connection Method",
515
+ subtitle="How would you like to connect Alysis Code to AI models?",
516
+ rows=_connection_method_picker_rows(),
517
+ current_value=current_method,
518
+ cancel_hint="back",
519
+ invalid_hint="Pick a connection method or press Esc to go back.",
520
+ )
521
+ if selected_method is None:
522
+ raise _GoBack()
523
+ if selected_method == _NATIVE_EXECUTION_VALUE:
524
+ result = _execution_result_from_value(selected_method)
525
+ console.print("[green]Connection method selected:[/green] Use an API key")
526
+ return result
527
+ if selected_method != _SUBSCRIPTION_EXECUTION_VALUE:
528
+ raise ConfigError(f"Unknown connection method: {selected_method or '(empty)'}")
529
+
530
+ rows = _subscription_picker_rows()
531
+ if not rows:
532
+ raise ConfigError("No AI subscription connections are available.")
533
+ current_runtime = rows[0][0]
534
+ if previous is not None and previous.backend == "delegated" and previous.runtime:
535
+ previous_runtime = f"{_RUNTIME_EXECUTION_PREFIX}{previous.runtime}"
536
+ if any(value == previous_runtime for value, _label, _description in rows):
537
+ current_runtime = previous_runtime
538
+ selected_runtime = _run_wizard_picker(
539
+ console=console,
540
+ title="AI Subscription",
541
+ subtitle="Choose the subscription you want to connect.",
542
+ rows=rows,
543
+ current_value=current_runtime,
544
+ cancel_hint="connection methods",
545
+ invalid_hint="Pick an AI subscription or press Esc to go back.",
546
+ )
547
+ if selected_runtime is None:
548
+ current_method = _SUBSCRIPTION_EXECUTION_VALUE
549
+ continue
550
+ result = _execution_result_from_value(selected_runtime)
551
+ console.print("[green]Connection method selected:[/green] Use an AI subscription")
552
+ console.print(f"[green]Subscription selected:[/green] {result.label}")
553
+ return result
554
+
555
+
556
+ def _setup_step_names(
557
+ execution_result: _ExecutionStepResult | None,
558
+ ) -> tuple[str, ...]:
559
+ if execution_result is None:
560
+ return ("welcome", "execution")
561
+ if execution_result.backend == "delegated":
562
+ return ("welcome", "execution", "workspace")
563
+ return (
564
+ "welcome",
565
+ "execution",
566
+ "profile",
567
+ "api_key",
568
+ "model",
569
+ "workspace",
570
+ )
571
+
572
+
573
+ def _prompt_profile(
574
+ console: Console,
575
+ *,
576
+ previous: _ProfileStepResult | None = None,
577
+ ) -> _ProfileStepResult:
578
+ rows = _provider_picker_rows(_setup_presets())
579
+ current_value = rows[0][0]
580
+ if previous is not None:
581
+ current_value = previous.preset.key if previous.preset is not None else _CUSTOM_PROVIDER_KEY
582
+ selected = _run_wizard_picker(
583
+ console=console,
584
+ title="Provider Profile",
585
+ subtitle="Pick the provider you want Alysis Code to use.",
586
+ rows=rows,
587
+ current_value=current_value,
588
+ cancel_hint="back",
589
+ invalid_hint="Pick a provider first; you'll enter the key in the next step.",
590
+ )
591
+ if selected == _ADVANCED_PROVIDER_PRESETS_VALUE:
592
+ advanced_rows = [
593
+ (preset.key, preset_selection_label(preset), _preset_description(preset))
594
+ for preset in _advanced_setup_presets()
595
+ ]
596
+ selected = _run_wizard_picker(
597
+ console=console,
598
+ title="Advanced Provider Profile",
599
+ subtitle="Pick a local, custom, or legacy OpenAI-compatible provider preset.",
600
+ rows=advanced_rows,
601
+ current_value=advanced_rows[0][0] if advanced_rows else "openai",
602
+ cancel_hint="back",
603
+ invalid_hint="Pick a provider preset or press Esc to go back.",
604
+ )
605
+ if selected is None:
606
+ raise _GoBack()
607
+
608
+ preset = _preset_by_key(selected)
609
+ if preset is None:
610
+ raise ConfigError(f"Unknown provider preset: {selected}")
611
+ if preset.key == _CUSTOM_PROVIDER_KEY:
612
+ profile = _prompt_custom_profile(
613
+ previous.profile if previous is not None and previous.preset is None else None
614
+ )
615
+ return _ProfileStepResult(profile=profile, label=profile.name, preset=None)
616
+
617
+ profile = make_profile_from_preset(preset)
618
+ console.print(f"[green]Provider selected:[/green] {preset.label}")
619
+ _print_preset_warning(console, preset)
620
+ return _ProfileStepResult(profile=profile, label=preset.label, preset=preset)
621
+
622
+
623
+ def _prompt_custom_profile(previous: ProfileSpec | None = None) -> ProfileSpec:
624
+ while True:
625
+ name = (
626
+ _esc_aware_text_input(
627
+ "Profile name",
628
+ default=(previous.name if previous is not None else "custom"),
629
+ show_default=True,
630
+ )
631
+ .strip()
632
+ .lower()
633
+ )
634
+ if _is_cancel_token(name):
635
+ raise _GoBack()
636
+ if name:
637
+ break
638
+ _resolve_console().print("[red]Profile name is required.[/red]")
639
+ while True:
640
+ base_url = _esc_aware_text_input(
641
+ "Base URL",
642
+ default=(previous.base_url if previous is not None else ""),
643
+ show_default=previous is not None,
644
+ ).strip()
645
+ if _is_cancel_token(base_url):
646
+ raise _GoBack()
647
+ if base_url:
648
+ break
649
+ _resolve_console().print("[red]Base URL is required.[/red]")
650
+ headers = _prompt_extra_headers(previous.extra_headers if previous is not None else None)
651
+ return ProfileSpec(
652
+ name=name,
653
+ protocol=previous.protocol if previous is not None else "openai_compat",
654
+ base_url=base_url,
655
+ api_key_env=previous.api_key_env if previous is not None else None,
656
+ auth_provider=previous.auth_provider if previous is not None else None,
657
+ extra_headers=headers,
658
+ default_model=previous.default_model if previous is not None else "",
659
+ reasoning_effort=previous.reasoning_effort if previous is not None else None,
660
+ reasoning_trace_adapter=(
661
+ previous.reasoning_trace_adapter if previous is not None else "auto"
662
+ ),
663
+ web_search_adapter=previous.web_search_adapter if previous is not None else "auto",
664
+ web_search_model=previous.web_search_model if previous is not None else "",
665
+ notes=previous.notes if previous is not None else "Custom OpenAI-compatible endpoint.",
666
+ cache_capability=previous.cache_capability if previous is not None else None,
667
+ )
668
+
669
+
670
+ def _prompt_extra_headers(previous: dict[str, str] | None = None) -> dict[str, str]:
671
+ previous_text = ", ".join(f"{key}={value}" for key, value in (previous or {}).items())
672
+ while True:
673
+ headers_raw = _esc_aware_text_input(
674
+ "Extra headers (k=v, comma-separated)",
675
+ default=previous_text,
676
+ show_default=bool(previous_text),
677
+ ).strip()
678
+ if _is_cancel_token(headers_raw):
679
+ raise _GoBack()
680
+ headers: dict[str, str] = {}
681
+ valid = True
682
+ for item in headers_raw.split(","):
683
+ text = item.strip()
684
+ if not text:
685
+ continue
686
+ if "=" not in text:
687
+ _resolve_console().print("[red]Extra headers must use k=v syntax.[/red]")
688
+ valid = False
689
+ break
690
+ key, value = text.split("=", 1)
691
+ if key.strip() and value.strip():
692
+ headers[key.strip()] = value.strip()
693
+ if valid:
694
+ return headers
695
+
696
+
697
+ def _prompt_api_key(
698
+ console: Console,
699
+ profile_result: _ProfileStepResult,
700
+ *,
701
+ previous: _ApiKeyStepResult | None = None,
702
+ ) -> _ApiKeyStepResult:
703
+ profile = profile_result.profile
704
+ console.print()
705
+ console.print(Panel(_api_key_panel_text(profile_result), title="API Key", border_style="cyan"))
706
+ required = bool(profile.api_key_env)
707
+ attempts = 0
708
+ last_key = previous.api_key if previous is not None else ""
709
+ while attempts < _MAX_API_KEY_VALIDATION_ATTEMPTS:
710
+ prompt_label = "Paste your API key" if required else "API key (optional)"
711
+ if previous is not None:
712
+ prompt_label += " (Enter to keep current)"
713
+ value = _esc_aware_text_input(
714
+ prompt_label,
715
+ default="",
716
+ hide_input=True,
717
+ show_default=False,
718
+ ).strip()
719
+ if _is_cancel_token(value):
720
+ raise _GoBack()
721
+ if not value and previous is not None:
722
+ return previous
723
+ if not value:
724
+ if required:
725
+ console.print("[red]API key is required to continue.[/red]")
726
+ attempts += 1
727
+ continue
728
+ console.print("[yellow]No API key stored for this profile.[/yellow]")
729
+ return _ApiKeyStepResult(
730
+ api_key="",
731
+ validation_status="skipped",
732
+ validation_message="No API key provided.",
733
+ )
734
+
735
+ last_key = value
736
+ console.print("[dim]Validating...[/dim]")
737
+ validation = _validate_api_key(
738
+ profile=profile,
739
+ api_key=value,
740
+ suggested_models=_suggested_models(profile_result),
741
+ validation_model=_validation_model_hint(profile_result),
742
+ )
743
+ if validation.status == "validated":
744
+ console.print("[green]Key validated.[/green]")
745
+ if validation.message:
746
+ console.print(f"[yellow]{validation.message}[/yellow]")
747
+ return _ApiKeyStepResult(
748
+ api_key=value,
749
+ validation_status="validated",
750
+ validation_message=validation.message,
751
+ )
752
+ if validation.status == "inconclusive":
753
+ console.print(f"[yellow]{validation.message} Continuing without validation.[/yellow]")
754
+ return _ApiKeyStepResult(
755
+ api_key=value,
756
+ validation_status="inconclusive",
757
+ validation_message=validation.message,
758
+ )
759
+ if validation.status == "model_not_found":
760
+ console.print(
761
+ f"[yellow]{validation.message} We'll verify your chosen model next.[/yellow]"
762
+ )
763
+ return _ApiKeyStepResult(
764
+ api_key=value,
765
+ validation_status="model_not_found",
766
+ validation_message=validation.message,
767
+ )
768
+
769
+ attempts += 1
770
+ console.print(
771
+ f"[yellow]Key validation failed:[/yellow] {validation.message or 'provider rejected the key'}"
772
+ )
773
+ if attempts >= _MAX_API_KEY_VALIDATION_ATTEMPTS:
774
+ console.print(
775
+ "[yellow]Continuing with the last key. You can fix it later in /config.[/yellow]"
776
+ )
777
+ return _ApiKeyStepResult(
778
+ api_key=last_key,
779
+ validation_status="failed",
780
+ validation_message=validation.message,
781
+ )
782
+ if not _prompt_yes_no("Re-enter API key now? [Y/n]", default=True):
783
+ console.print(
784
+ "[yellow]Continuing without validation. You can fix it later in /config.[/yellow]"
785
+ )
786
+ return _ApiKeyStepResult(
787
+ api_key=value,
788
+ validation_status="failed",
789
+ validation_message=validation.message,
790
+ )
791
+
792
+ return _ApiKeyStepResult(
793
+ api_key=last_key,
794
+ validation_status="failed",
795
+ validation_message="Provider rejected the API key.",
796
+ )
797
+
798
+
799
+ def _api_key_panel_text(profile_result: _ProfileStepResult) -> str:
800
+ profile = profile_result.profile
801
+ lines = [f"Alysis Code will use {profile_result.label}."]
802
+ lines.append(
803
+ "Protocol: "
804
+ + (
805
+ f"native first-party ({profile.protocol})"
806
+ if profile.protocol in NATIVE_PROFILE_PROTOCOLS
807
+ else "OpenAI-compatible"
808
+ )
809
+ )
810
+ if profile.base_url:
811
+ lines.append(f"Provider URL: {profile.base_url}")
812
+ if profile.api_key_env:
813
+ lines.append(f"The key can also be set via env: {profile.api_key_env}")
814
+ else:
815
+ lines.append("This profile does not declare a required API key env variable.")
816
+ lines.append("")
817
+ lines.append("Enter to confirm. Esc to go back. Ctrl+C to cancel.")
818
+ return "\n".join(lines)
819
+
820
+
821
+ def _suggested_models(profile_result: _ProfileStepResult) -> tuple[str, ...]:
822
+ if profile_result.preset is None:
823
+ return ()
824
+ return tuple(
825
+ str(model).strip() for model in profile_result.preset.suggested_models if str(model).strip()
826
+ )
827
+
828
+
829
+ def _validation_model_hint(profile_result: _ProfileStepResult) -> str:
830
+ preset = profile_result.preset
831
+ if preset is None:
832
+ return ""
833
+ model = str(preset.validation_model or "").strip()
834
+ if not model:
835
+ return ""
836
+ return canonical_model_alias_for_preset(preset, model)
837
+
838
+
839
+ def _validate_api_key(
840
+ *,
841
+ profile: ProfileSpec,
842
+ api_key: str,
843
+ model: str | None = None,
844
+ suggested_models: tuple[str, ...] = (),
845
+ validation_model: str | None = None,
846
+ transport: httpx.BaseTransport | None = None,
847
+ ) -> _ApiKeyValidationResult:
848
+ if not api_key.strip():
849
+ return _ApiKeyValidationResult(status="skipped", message="No API key provided.")
850
+ base_url = str(profile.base_url or "").strip().rstrip("/")
851
+ if not base_url:
852
+ return _ApiKeyValidationResult(
853
+ status="inconclusive", message="Could not reach provider: Base URL is missing."
854
+ )
855
+
856
+ resolved_validation_model = _resolve_validation_model(
857
+ profile=profile,
858
+ model=model,
859
+ suggested_models=suggested_models,
860
+ validation_model=validation_model,
861
+ )
862
+ validation_profile = ProfileSpec(
863
+ name=profile.name,
864
+ protocol=profile.protocol,
865
+ base_url=base_url,
866
+ api_key_env=profile.api_key_env,
867
+ extra_headers=dict(profile.extra_headers),
868
+ default_model=resolved_validation_model.model,
869
+ reasoning_effort=profile.reasoning_effort,
870
+ reasoning_trace_adapter=profile.reasoning_trace_adapter,
871
+ web_search_adapter=profile.web_search_adapter,
872
+ web_search_model=profile.web_search_model,
873
+ notes=profile.notes,
874
+ )
875
+ cfg = AppConfig(
876
+ model=resolved_validation_model.model,
877
+ provider_retry_max_retries=0,
878
+ )
879
+ client = make_llm_client(
880
+ cfg=cfg,
881
+ api_key=api_key,
882
+ model=resolved_validation_model.model,
883
+ timeout_s=_validation_timeout_s(validation_profile),
884
+ temperature=0.0,
885
+ reasoning_effort=_validation_reasoning_effort(validation_profile),
886
+ transport=transport,
887
+ profile=validation_profile,
888
+ )
889
+ try:
890
+ client.chat(
891
+ messages=[{"role": "user", "content": "ping"}],
892
+ temperature=0.0,
893
+ )
894
+ except LLMError as exc:
895
+ return _validation_result_from_error(
896
+ exc,
897
+ base_url=base_url,
898
+ model=resolved_validation_model.model,
899
+ )
900
+
901
+ message = ""
902
+ if resolved_validation_model.used_fallback:
903
+ message = (
904
+ f"Validated API key using '{resolved_validation_model.model}'. "
905
+ "The selected model will be verified next."
906
+ )
907
+ return _ApiKeyValidationResult(status="validated", message=message)
908
+
909
+
910
+ def _resolve_validation_model(
911
+ *,
912
+ profile: ProfileSpec,
913
+ model: str | None,
914
+ suggested_models: tuple[str, ...],
915
+ validation_model: str | None = None,
916
+ ) -> _ResolvedValidationModel:
917
+ explicit_model = str(model or "").strip()
918
+ if explicit_model:
919
+ return _ResolvedValidationModel(model=explicit_model, used_fallback=False)
920
+ validation_candidate = str(validation_model or "").strip()
921
+ if validation_candidate:
922
+ return _ResolvedValidationModel(model=validation_candidate, used_fallback=True)
923
+ for candidate in (profile.default_model, *suggested_models):
924
+ normalized = str(candidate or "").strip()
925
+ if normalized:
926
+ return _ResolvedValidationModel(model=normalized, used_fallback=False)
927
+ return _ResolvedValidationModel(model=_FALLBACK_VALIDATION_MODEL, used_fallback=True)
928
+
929
+
930
+ def _validation_timeout_s(profile: ProfileSpec) -> float:
931
+ if _is_gemini_profile(profile):
932
+ return _GEMINI_VALIDATION_TIMEOUT_S
933
+ return _VALIDATION_TIMEOUT_S
934
+
935
+
936
+ def _validation_reasoning_effort(profile: ProfileSpec) -> str | None:
937
+ if _is_gemini_profile(profile):
938
+ return _GEMINI_VALIDATION_REASONING_EFFORT
939
+ if _is_zai_coding_plan_profile(profile):
940
+ # The plan defaults to max. A low-effort one-token validation proves
941
+ # model access while consuming the smallest documented reasoning tier.
942
+ return "low"
943
+ return None
944
+
945
+
946
+ def _is_gemini_profile(profile: ProfileSpec) -> bool:
947
+ name = str(profile.name or "").strip().casefold()
948
+ base_url = str(profile.base_url or "").strip().casefold()
949
+ return name == "gemini" or "generativelanguage.googleapis.com" in base_url
950
+
951
+
952
+ def _is_zai_coding_plan_profile(profile: ProfileSpec) -> bool:
953
+ name = str(profile.name or "").strip().casefold()
954
+ base_url = str(profile.base_url or "").strip().rstrip("/").casefold()
955
+ return name == "zai-coding-plan" or base_url == "https://api.z.ai/api/coding/paas/v4"
956
+
957
+
958
+ def _validation_result_from_error(
959
+ exc: LLMError,
960
+ *,
961
+ base_url: str,
962
+ model: str,
963
+ ) -> _ApiKeyValidationResult:
964
+ status_code = _http_status_from_llm_error(exc)
965
+ error_text = str(exc)
966
+ error_text_lower = error_text.casefold()
967
+
968
+ if status_code == 200:
969
+ return _ApiKeyValidationResult(status="validated")
970
+ if status_code in {401, 403}:
971
+ return _ApiKeyValidationResult(
972
+ status="failed",
973
+ message=f"Provider rejected the API key (HTTP {status_code}).",
974
+ )
975
+ if status_code == 400 and _is_model_not_found_error(error_text_lower):
976
+ return _ApiKeyValidationResult(
977
+ status="model_not_found",
978
+ message=f"Model '{model}' not found at this provider. Pick a different model in the next step.",
979
+ )
980
+ if status_code == 404:
981
+ return _ApiKeyValidationResult(
982
+ status="inconclusive",
983
+ message=f"Provider endpoint not found at {base_url}. Check the base URL.",
984
+ )
985
+ if status_code == 429:
986
+ return _ApiKeyValidationResult(
987
+ status="inconclusive",
988
+ message="Provider rate-limited the validation request. Continuing without validation.",
989
+ )
990
+ if status_code is not None and status_code >= 500:
991
+ return _ApiKeyValidationResult(
992
+ status="inconclusive",
993
+ message=f"Provider returned HTTP {status_code} during validation.",
994
+ )
995
+
996
+ cause = _root_cause(exc)
997
+ if isinstance(cause, httpx.TimeoutException):
998
+ return _ApiKeyValidationResult(
999
+ status="inconclusive",
1000
+ message=f"Could not reach {base_url}: validation request timed out.",
1001
+ )
1002
+ if isinstance(cause, httpx.HTTPError):
1003
+ return _ApiKeyValidationResult(
1004
+ status="inconclusive",
1005
+ message=f"Could not reach {base_url}: {cause}",
1006
+ )
1007
+ return _ApiKeyValidationResult(
1008
+ status="inconclusive",
1009
+ message=f"Could not verify {base_url}: {exc}",
1010
+ )
1011
+
1012
+
1013
+ def _is_model_not_found_error(error_text_lower: str) -> bool:
1014
+ if "not_found" in error_text_lower and "model" in error_text_lower:
1015
+ return True
1016
+ if "model_not_found" in error_text_lower:
1017
+ return True
1018
+ if "model not found" in error_text_lower:
1019
+ return True
1020
+ if re.search(
1021
+ r"\bmodel\b.{0,80}\b(?:does not exist|not exist|not found|unknown)", error_text_lower
1022
+ ):
1023
+ return True
1024
+ return (
1025
+ re.search(
1026
+ r"\b(?:does not exist|not exist|not found|unknown)\b.{0,80}\bmodel\b", error_text_lower
1027
+ )
1028
+ is not None
1029
+ )
1030
+
1031
+
1032
+ def _http_status_from_llm_error(exc: LLMError) -> int | None:
1033
+ match = re.search(r"\bLLM error\s+(\d{3})\b", str(exc))
1034
+ if match is None:
1035
+ return None
1036
+ try:
1037
+ return int(match.group(1))
1038
+ except ValueError:
1039
+ return None
1040
+
1041
+
1042
+ def _root_cause(exc: BaseException) -> BaseException:
1043
+ cause = exc
1044
+ seen: set[int] = set()
1045
+ while cause.__cause__ is not None and id(cause.__cause__) not in seen:
1046
+ seen.add(id(cause))
1047
+ cause = cause.__cause__
1048
+ return cause
1049
+
1050
+
1051
+ def _prompt_model(
1052
+ console: Console,
1053
+ profile_result: _ProfileStepResult,
1054
+ *,
1055
+ api_key_result: _ApiKeyStepResult,
1056
+ previous: _ModelStepResult | None = None,
1057
+ ) -> tuple[_ModelStepResult, _ApiKeyStepResult]:
1058
+ current_previous = previous
1059
+ discovered_models, catalog_warning = _discover_setup_provider_models(
1060
+ profile_result,
1061
+ api_key=api_key_result.api_key,
1062
+ )
1063
+ if catalog_warning:
1064
+ console.print(f"[yellow]{catalog_warning}[/yellow]")
1065
+ while True:
1066
+ model_result = _prompt_model_choice(
1067
+ console,
1068
+ profile_result,
1069
+ previous=current_previous,
1070
+ discovered_models=discovered_models,
1071
+ )
1072
+ updated_api_key_result = _validate_selected_model(
1073
+ console=console,
1074
+ profile_result=profile_result,
1075
+ api_key_result=api_key_result,
1076
+ model_result=model_result,
1077
+ )
1078
+ if updated_api_key_result.validation_status == "model_not_found":
1079
+ current_previous = model_result
1080
+ if model_result.custom and _prompt_yes_no(
1081
+ f"Use custom model '{model_result.model}' anyway? [y/N]",
1082
+ default=False,
1083
+ ):
1084
+ warning = (
1085
+ f"Model '{model_result.model}' was not confirmed; "
1086
+ "provider reported it was missing and you chose to use it anyway."
1087
+ )
1088
+ return model_result, replace(
1089
+ updated_api_key_result,
1090
+ validation_status="inconclusive",
1091
+ validation_message=warning,
1092
+ )
1093
+ continue
1094
+ reasoning_label = _prompt_setup_reasoning_label(
1095
+ console,
1096
+ profile_result=profile_result,
1097
+ model=model_result.model,
1098
+ current=model_result.reasoning_label,
1099
+ )
1100
+ return replace(model_result, reasoning_label=reasoning_label), updated_api_key_result
1101
+
1102
+
1103
+ def _prompt_model_choice(
1104
+ console: Console,
1105
+ profile_result: _ProfileStepResult,
1106
+ *,
1107
+ previous: _ModelStepResult | None = None,
1108
+ discovered_models: tuple[ProviderModelOption, ...] = (),
1109
+ ) -> _ModelStepResult:
1110
+ rows = _model_picker_rows(profile_result, discovered_models=discovered_models)
1111
+ row_values = {value for value, _label, _description in rows}
1112
+ current_value = rows[0][0]
1113
+ if previous is not None:
1114
+ current_value = previous.model if previous.model in row_values else _CUSTOM_MODEL_VALUE
1115
+ selected = _run_wizard_picker(
1116
+ console=console,
1117
+ title="Default Model",
1118
+ subtitle=f"Pick the model Alysis Code will use by default for {profile_result.label}.",
1119
+ rows=rows,
1120
+ current_value=current_value,
1121
+ cancel_hint="back",
1122
+ )
1123
+ if selected is None:
1124
+ raise _GoBack()
1125
+ if selected == _CUSTOM_MODEL_VALUE:
1126
+ default_model = (
1127
+ previous.model if previous is not None and previous.model not in row_values else ""
1128
+ )
1129
+ model = _prompt_custom_model_name(previous=default_model or None)
1130
+ is_custom = True
1131
+ else:
1132
+ model = selected
1133
+ is_custom = False
1134
+ if profile_result.preset is not None:
1135
+ model = canonical_model_alias_for_preset(profile_result.preset, model)
1136
+ if not model.strip():
1137
+ raise ConfigError("Default model is required.")
1138
+ console.print(f"[green]Default model:[/green] {model}")
1139
+ return _ModelStepResult(model=model.strip(), custom=is_custom)
1140
+
1141
+
1142
+ def _validate_selected_model(
1143
+ *,
1144
+ console: Console,
1145
+ profile_result: _ProfileStepResult,
1146
+ api_key_result: _ApiKeyStepResult,
1147
+ model_result: _ModelStepResult,
1148
+ ) -> _ApiKeyStepResult:
1149
+ if not api_key_result.api_key:
1150
+ return api_key_result
1151
+ if api_key_result.validation_status == "failed":
1152
+ console.print(
1153
+ "[yellow]Skipping model validation because the provider already rejected the API key.[/yellow]"
1154
+ )
1155
+ return api_key_result
1156
+
1157
+ console.print("[dim]Validating selected model...[/dim]")
1158
+ validation = _validate_api_key(
1159
+ profile=profile_result.profile,
1160
+ api_key=api_key_result.api_key,
1161
+ model=model_result.model,
1162
+ suggested_models=_suggested_models(profile_result),
1163
+ )
1164
+ if validation.status == "validated":
1165
+ console.print("[green]Model validated.[/green]")
1166
+ elif validation.status == "model_not_found":
1167
+ console.print(f"[red]{validation.message}[/red]")
1168
+ elif validation.status == "failed":
1169
+ console.print(f"[red]{validation.message}[/red]")
1170
+ elif validation.status == "inconclusive":
1171
+ console.print(f"[yellow]{validation.message}[/yellow]")
1172
+ return replace(
1173
+ api_key_result,
1174
+ validation_status=validation.status,
1175
+ validation_message=validation.message,
1176
+ )
1177
+
1178
+
1179
+ def _discover_setup_provider_models(
1180
+ profile_result: _ProfileStepResult,
1181
+ *,
1182
+ api_key: str,
1183
+ transport: httpx.BaseTransport | None = None,
1184
+ ) -> tuple[tuple[ProviderModelOption, ...], str]:
1185
+ preset = profile_result.preset
1186
+ if preset is None or preset.key != _NVIDIA_PRESET_KEY:
1187
+ return (), ""
1188
+ try:
1189
+ options = discover_provider_models(
1190
+ profile=profile_result.profile,
1191
+ api_key=api_key,
1192
+ transport=transport,
1193
+ )
1194
+ except ProviderModelCatalogError:
1195
+ return (), (
1196
+ "NVIDIA's live model catalog is unavailable. Recommended models and "
1197
+ "custom model entry are still available."
1198
+ )
1199
+ return tuple(sorted(options, key=lambda option: option.id.casefold())), ""
1200
+
1201
+
1202
+ def _model_picker_rows(
1203
+ profile_result: _ProfileStepResult,
1204
+ *,
1205
+ discovered_models: tuple[ProviderModelOption, ...] = (),
1206
+ ) -> list[tuple[str, str, str]]:
1207
+ rows: list[tuple[str, str, str]] = []
1208
+ seen: set[str] = set()
1209
+ preset = profile_result.preset
1210
+ if preset is not None:
1211
+ for value, label, description in model_options_for_preset(preset):
1212
+ if value in seen:
1213
+ continue
1214
+ seen.add(value)
1215
+ rows.append((value, label, description))
1216
+ if profile_result.profile.default_model:
1217
+ normalized = str(profile_result.profile.default_model).strip()
1218
+ if preset is not None:
1219
+ normalized = canonical_model_alias_for_preset(preset, normalized)
1220
+ if normalized and normalized not in seen:
1221
+ seen.add(normalized)
1222
+ rows.append((normalized, normalized, "current profile default"))
1223
+ custom_row = (
1224
+ _CUSTOM_MODEL_VALUE,
1225
+ "Type a custom model name",
1226
+ "Use any model supported by this provider",
1227
+ )
1228
+ if preset is not None and preset.key == _NVIDIA_PRESET_KEY:
1229
+ # Keep manual entry reachable without scrolling through NVIDIA's large
1230
+ # live catalog. Curated recommendations remain above it; live inventory
1231
+ # follows below.
1232
+ rows.append(custom_row)
1233
+ for option in discovered_models:
1234
+ model_id = str(option.id or "").strip()
1235
+ if not model_id or model_id in seen:
1236
+ continue
1237
+ seen.add(model_id)
1238
+ description = str(option.description or "").strip()
1239
+ if not description:
1240
+ owner = model_id.partition("/")[0]
1241
+ description = (
1242
+ f"live NVIDIA catalog · hosted model from {owner}"
1243
+ if owner and owner != model_id
1244
+ else "live NVIDIA catalog"
1245
+ )
1246
+ label = option.label or model_id
1247
+ if option.chat_compatibility == "unknown":
1248
+ label = f"{label} (chat compatibility unverified)"
1249
+ description = (
1250
+ f"{description} · provider catalog does not declare endpoint compatibility; "
1251
+ "selection is validated before setup completes"
1252
+ )
1253
+ rows.append((model_id, label, description))
1254
+ if custom_row not in rows:
1255
+ rows.append(custom_row)
1256
+ return rows
1257
+
1258
+
1259
+ def _setup_reasoning_labels(
1260
+ profile_result: _ProfileStepResult,
1261
+ *,
1262
+ model: str,
1263
+ current: str = "auto",
1264
+ ) -> tuple[str, ...]:
1265
+ preset = profile_result.preset
1266
+ if preset is None or preset.key not in _MODEL_REASONING_SETUP_PRESET_KEYS:
1267
+ return ("auto",)
1268
+ contract = reasoning_contract_for(
1269
+ preset.provider_key,
1270
+ model,
1271
+ preset_key=preset.key,
1272
+ )
1273
+ if contract is UNKNOWN_CONTRACT:
1274
+ return ("auto",)
1275
+ labels = reasoning_labels_allowed_by_contract(
1276
+ contract,
1277
+ _SETUP_REASONING_LABELS,
1278
+ current=current if current in _SETUP_REASONING_LABELS else "auto",
1279
+ )
1280
+ return tuple(labels) or ("auto",)
1281
+
1282
+
1283
+ def _prompt_setup_reasoning_label(
1284
+ console: Console,
1285
+ *,
1286
+ profile_result: _ProfileStepResult,
1287
+ model: str,
1288
+ current: str = "auto",
1289
+ ) -> str:
1290
+ preset = profile_result.preset
1291
+ if preset is None or preset.key not in _MODEL_REASONING_SETUP_PRESET_KEYS:
1292
+ return current
1293
+ labels = _setup_reasoning_labels(
1294
+ profile_result,
1295
+ model=model,
1296
+ current=current,
1297
+ )
1298
+ if labels == ("auto",) and preset.key == _NVIDIA_PRESET_KEY:
1299
+ console.print(
1300
+ "[dim]Reasoning controls for this NVIDIA-hosted model are not verified; "
1301
+ "using the provider default without sending a guessed parameter.[/dim]"
1302
+ )
1303
+ return "auto"
1304
+ descriptions = {
1305
+ "off": "disable reasoning using this model's documented provider control",
1306
+ "low": "lower reasoning effort",
1307
+ "medium": "balanced reasoning effort",
1308
+ "high": "full reasoning effort",
1309
+ "max": "maximum reasoning effort",
1310
+ "auto": "use the provider's documented default",
1311
+ }
1312
+ rows = [
1313
+ (label, label, descriptions.get(label, "provider-documented reasoning effort"))
1314
+ for label in labels
1315
+ ]
1316
+ selected = _run_wizard_picker(
1317
+ console=console,
1318
+ title="Reasoning Effort",
1319
+ subtitle=f"Choose a verified reasoning mode for {model} on {preset.label}.",
1320
+ rows=rows,
1321
+ current_value=current if current in labels else "auto",
1322
+ cancel_hint="models",
1323
+ )
1324
+ if selected is None:
1325
+ raise _GoBack()
1326
+ return selected
1327
+
1328
+
1329
+ def _prompt_custom_model_name(*, previous: str | None = None) -> str:
1330
+ while True:
1331
+ value = _esc_aware_text_input(
1332
+ "Model",
1333
+ default=previous or "",
1334
+ show_default=previous is not None,
1335
+ ).strip()
1336
+ if _is_cancel_token(value):
1337
+ raise _GoBack()
1338
+ if value:
1339
+ return value
1340
+ _resolve_console().print("[red]Model is required.[/red]")
1341
+
1342
+
1343
+ def _prompt_workspace(
1344
+ console: Console,
1345
+ *,
1346
+ previous: _WorkspaceStepResult | None = None,
1347
+ ) -> _WorkspaceStepResult:
1348
+ default_workspace = (
1349
+ Path(previous.workspace).expanduser()
1350
+ if previous is not None
1351
+ else _suggest_workspace_default()
1352
+ )
1353
+ console.print()
1354
+ console.print(
1355
+ Panel(
1356
+ Group(
1357
+ Text("Alysis Code reads and edits files inside one folder."),
1358
+ Text(f"Suggestion: {_display_path(default_workspace)}", style="dim"),
1359
+ Text(""),
1360
+ Text("Enter to confirm. Esc to go back. Ctrl+C to cancel.", style="dim"),
1361
+ ),
1362
+ title="Workspace",
1363
+ border_style="cyan",
1364
+ )
1365
+ )
1366
+ while True:
1367
+ raw = _esc_aware_text_input(
1368
+ "Workspace folder",
1369
+ default=os.fspath(default_workspace),
1370
+ show_default=True,
1371
+ )
1372
+ if _is_cancel_token(raw):
1373
+ raise _GoBack()
1374
+ selected = Path(str(raw or default_workspace)).expanduser().resolve()
1375
+ try:
1376
+ binding = resolve_workspace_binding(
1377
+ selected,
1378
+ allow_broad_workspace=True,
1379
+ source="setup_wizard",
1380
+ )
1381
+ except WorkspaceBindingError as exc:
1382
+ console.print(f"[red]{exc}[/red]")
1383
+ continue
1384
+
1385
+ workspace = os.fspath(binding.requested_path)
1386
+ console.print(f"[green]Workspace:[/green] {workspace}")
1387
+ return _WorkspaceStepResult(workspace=workspace)
1388
+
1389
+
1390
+ def _commit_setup(
1391
+ *,
1392
+ profile_result: _ProfileStepResult,
1393
+ api_key_result: _ApiKeyStepResult,
1394
+ model_result: _ModelStepResult,
1395
+ workspace_result: _WorkspaceStepResult,
1396
+ console: Console,
1397
+ ) -> AppConfig:
1398
+ snapshots = [_snapshot_file(config_path()), _snapshot_file(credentials_path())]
1399
+ try:
1400
+ cfg = load_config()
1401
+ cfg.execution.backend = "native"
1402
+ cfg.execution.runtime = None
1403
+ add_profile(cfg, profile_result.profile)
1404
+ set_active_profile(cfg, profile_result.profile.name)
1405
+ set_config_value(cfg, "model", model_result.model)
1406
+ reasoning_label = str(model_result.reasoning_label or "auto").strip().casefold()
1407
+ reasoning_effort = (
1408
+ None
1409
+ if reasoning_label == "auto"
1410
+ else ("none" if reasoning_label == "off" else reasoning_label)
1411
+ )
1412
+ cfg.llm_reasoning_effort = reasoning_effort
1413
+ cfg.llm_enable_thinking = None if reasoning_label == "auto" else reasoning_label != "off"
1414
+ update_profile(
1415
+ cfg,
1416
+ profile_result.profile.name,
1417
+ default_model=model_result.model,
1418
+ reasoning_effort=reasoning_effort,
1419
+ )
1420
+ extra_fields = dict(cfg.extra_fields or {})
1421
+ if reasoning_label not in {"auto", "off"}:
1422
+ extra_fields["llm_thinking_label"] = reasoning_label
1423
+ else:
1424
+ extra_fields.pop("llm_thinking_label", None)
1425
+ extra_fields[_DEFAULT_WORKSPACE_KEY] = workspace_result.workspace
1426
+ extra_fields[_ONBOARDED_KEY] = True
1427
+ extra_fields.pop("subscription_reconnect_required", None)
1428
+ cfg.extra_fields = extra_fields
1429
+ save_config(cfg)
1430
+ if api_key_result.api_key:
1431
+ save_persisted_profile_key(profile_result.profile.name, api_key_result.api_key)
1432
+ console.print("[green]Setup saved.[/green]")
1433
+ return cfg
1434
+ except (ConfigError, OSError) as exc:
1435
+ rollback_errors = _rollback_partial_persistence(snapshots)
1436
+ console.print(f"[red]Failed to save setup:[/red] {exc}")
1437
+ if rollback_errors:
1438
+ for error in rollback_errors:
1439
+ console.print(f"[red]Rollback failed:[/red] {error}")
1440
+ raise ConfigError(
1441
+ f"Failed to save setup: {exc}. Rollback also failed; inspect config and credentials files manually."
1442
+ ) from exc
1443
+ raise ConfigError(f"Failed to save setup: {exc}") from exc
1444
+
1445
+
1446
+ def _commit_delegated_setup(
1447
+ *,
1448
+ execution_result: _ExecutionStepResult,
1449
+ workspace_result: _WorkspaceStepResult,
1450
+ console: Console,
1451
+ ) -> AppConfig:
1452
+ runtime_id = str(execution_result.runtime or "").strip()
1453
+ if execution_result.backend != "delegated" or not runtime_id:
1454
+ raise ConfigError("AI subscription setup requires a provider connection.")
1455
+
1456
+ snapshots = [_snapshot_file(config_path()), _snapshot_file(credentials_path())]
1457
+ try:
1458
+ from ..config import AgentRuntimeSettings
1459
+
1460
+ option = _runtime_setup_option(runtime_id)
1461
+ cfg = load_config()
1462
+ if _is_direct_subscription(runtime_id):
1463
+ profile = _direct_subscription_profile(cfg, runtime_id)
1464
+ add_profile(cfg, profile, allow_auth_profile_update=True)
1465
+ set_active_profile(cfg, profile.name)
1466
+ cfg.execution.backend = "native"
1467
+ cfg.execution.runtime = None
1468
+ cfg.model = profile.default_model
1469
+ effective_effort = (
1470
+ None if profile.reasoning_effort in {None, "auto"} else profile.reasoning_effort
1471
+ )
1472
+ cfg.llm_reasoning_effort = effective_effort
1473
+ cfg.llm_enable_thinking = (
1474
+ None if effective_effort is None else effective_effort != "none"
1475
+ )
1476
+ else:
1477
+ cfg.execution.backend = "delegated"
1478
+ cfg.execution.runtime = runtime_id
1479
+ if runtime_id not in cfg.agent_runtimes:
1480
+ cfg.agent_runtimes[runtime_id] = AgentRuntimeSettings(
1481
+ adapter=str(getattr(option, "adapter", "") or "").strip(),
1482
+ executable=str(getattr(option, "default_executable", "") or "").strip(),
1483
+ )
1484
+ extra_fields = dict(cfg.extra_fields or {})
1485
+ extra_fields[_DEFAULT_WORKSPACE_KEY] = workspace_result.workspace
1486
+ if _is_direct_subscription(runtime_id):
1487
+ extra_fields[_ONBOARDED_KEY] = False
1488
+ extra_fields["subscription_reconnect_required"] = True
1489
+ if not profile.default_model or profile.reasoning_effort is None:
1490
+ extra_fields[SUBSCRIPTION_SELECTION_REQUIRED_KEY] = runtime_id
1491
+ else:
1492
+ extra_fields[_ONBOARDED_KEY] = True
1493
+ extra_fields.pop("subscription_reconnect_required", None)
1494
+ cfg.extra_fields = extra_fields
1495
+ save_config(cfg)
1496
+ console.print("[green]Setup saved.[/green]")
1497
+ return cfg
1498
+ except (ConfigError, OSError, ValueError) as exc:
1499
+ rollback_errors = _rollback_partial_persistence(snapshots)
1500
+ console.print(f"[red]Failed to save setup:[/red] {exc}")
1501
+ if rollback_errors:
1502
+ for error in rollback_errors:
1503
+ console.print(f"[red]Rollback failed:[/red] {error}")
1504
+ raise ConfigError(
1505
+ f"Failed to save setup: {exc}. Rollback also failed; inspect config and credentials files manually."
1506
+ ) from exc
1507
+ raise ConfigError(f"Failed to save setup: {exc}") from exc
1508
+
1509
+
1510
+ def _delegated_runtime_id(execution_result: _ExecutionStepResult) -> str:
1511
+ runtime_id = str(execution_result.runtime or "").strip()
1512
+ if execution_result.backend != "delegated" or not runtime_id:
1513
+ raise ConfigError("AI subscription setup requires a provider connection.")
1514
+ return runtime_id
1515
+
1516
+
1517
+ def _delegated_auth_result(account: Any) -> _DelegatedAuthResult:
1518
+ connected = bool(
1519
+ getattr(account, "verified", True)
1520
+ and (getattr(account, "authenticated", False) or getattr(account, "connected", False))
1521
+ )
1522
+ label = str(getattr(account, "account_label", "") or "").strip()
1523
+ detail = str(getattr(account, "detail", "") or "").strip()
1524
+ if connected:
1525
+ summary = f"connected as {label}" if label else "connected"
1526
+ if detail and detail.casefold() not in summary.casefold():
1527
+ summary += f" ({detail})"
1528
+ return _DelegatedAuthResult(connected=True, summary=summary)
1529
+ summary = "not connected"
1530
+ if detail:
1531
+ summary += f" ({detail})"
1532
+ return _DelegatedAuthResult(connected=False, summary=summary)
1533
+
1534
+
1535
+ def _check_delegated_runtime_connection(
1536
+ cfg: AppConfig,
1537
+ execution_result: _ExecutionStepResult,
1538
+ ) -> _DelegatedAuthResult:
1539
+ """Read the provider runtime's opaque account status without accessing credentials."""
1540
+
1541
+ runtime_id = _delegated_runtime_id(execution_result)
1542
+ try:
1543
+ if _is_direct_subscription(runtime_id):
1544
+ from ..provider_auth import create_provider_auth
1545
+
1546
+ return _delegated_auth_result(create_provider_auth(runtime_id).account_status())
1547
+ from ..agent_runtimes.service import runtime_connection_snapshot
1548
+
1549
+ snapshot = runtime_connection_snapshot(cfg, runtime_id)
1550
+ except Exception as exc: # noqa: BLE001 - setup must survive unavailable runtimes
1551
+ return _DelegatedAuthResult(
1552
+ connected=False,
1553
+ summary=f"not connected (status check failed: {exc})",
1554
+ )
1555
+ return _delegated_auth_result(snapshot.account)
1556
+
1557
+
1558
+ def _login_delegated_runtime(
1559
+ cfg: AppConfig,
1560
+ execution_result: _ExecutionStepResult,
1561
+ ) -> _DelegatedAuthResult:
1562
+ """Run provider-owned browser login and return only its opaque account status."""
1563
+
1564
+ runtime_id = _delegated_runtime_id(execution_result)
1565
+ try:
1566
+ if _is_direct_subscription(runtime_id):
1567
+ from ..provider_auth import create_provider_auth
1568
+
1569
+ adapter = create_provider_auth(runtime_id)
1570
+ account = adapter.login(method="browser")
1571
+ if account.connected:
1572
+ _sync_direct_subscription_model(cfg, runtime_id, adapter=adapter)
1573
+ return _delegated_auth_result(account)
1574
+ from ..agent_runtimes.service import login_runtime
1575
+
1576
+ account = login_runtime(cfg, runtime_id, method_id="browser")
1577
+ except Exception as exc: # noqa: BLE001 - login is optional after config was saved
1578
+ return _DelegatedAuthResult(
1579
+ connected=False,
1580
+ summary=f"not connected ({exc})",
1581
+ )
1582
+ return _delegated_auth_result(account)
1583
+
1584
+
1585
+ def _maybe_connect_delegated_runtime(
1586
+ *,
1587
+ console: Console,
1588
+ cfg: AppConfig,
1589
+ execution_result: _ExecutionStepResult,
1590
+ ) -> _DelegatedAuthResult:
1591
+ runtime_id = _delegated_runtime_id(execution_result)
1592
+ auth_result = _check_delegated_runtime_connection(cfg, execution_result)
1593
+ if auth_result.connected:
1594
+ if _is_direct_subscription(runtime_id):
1595
+ try:
1596
+ _sync_direct_subscription_model(cfg, runtime_id)
1597
+ except Exception as exc: # noqa: BLE001
1598
+ return _DelegatedAuthResult(
1599
+ connected=False,
1600
+ summary=f"connected, but model discovery failed ({exc})",
1601
+ )
1602
+ console.print(f"[green]Subscription account already {auth_result.summary}.[/green]")
1603
+ return auth_result
1604
+
1605
+ console.print()
1606
+ console.print(
1607
+ "[yellow]Provider login is an immediate external side effect. "
1608
+ "Cancelling or going back later does not undo it.[/yellow]"
1609
+ )
1610
+ try:
1611
+ connect = _prompt_yes_no("Connect now? [Y/n]", default=True)
1612
+ except (_GoBack, KeyboardInterrupt, Abort, EOFError):
1613
+ console.print(
1614
+ "[yellow]Your model access settings are already saved; provider sign-in was skipped.[/yellow]"
1615
+ )
1616
+ console.print(f"[dim]Run `alysis auth login {runtime_id}` whenever you're ready.[/dim]")
1617
+ return auth_result
1618
+ if not connect:
1619
+ console.print(f"[dim]Run `alysis auth login {runtime_id}` whenever you're ready.[/dim]")
1620
+ return auth_result
1621
+
1622
+ console.print("[dim]Starting the provider's browser login…[/dim]")
1623
+ auth_result = _login_delegated_runtime(cfg, execution_result)
1624
+ if auth_result.connected:
1625
+ console.print(f"[green]Subscription account {auth_result.summary}.[/green]")
1626
+ else:
1627
+ console.print(f"[yellow]Subscription account {auth_result.summary}.[/yellow]")
1628
+ console.print(f"[dim]Finish later with `alysis auth login {runtime_id}`.[/dim]")
1629
+ return auth_result
1630
+
1631
+
1632
+ def _snapshot_file(path: Path) -> _FileSnapshot:
1633
+ try:
1634
+ if path.exists():
1635
+ return _FileSnapshot(path=path, existed=True, data=path.read_bytes())
1636
+ return _FileSnapshot(path=path, existed=False, data=None)
1637
+ except OSError as exc:
1638
+ raise ConfigError(f"Failed to snapshot {path}: {exc}") from exc
1639
+
1640
+
1641
+ def _rollback_partial_persistence(snapshots: list[_FileSnapshot]) -> list[str]:
1642
+ errors: list[str] = []
1643
+ for snapshot in snapshots:
1644
+ try:
1645
+ if snapshot.existed:
1646
+ snapshot.path.parent.mkdir(parents=True, exist_ok=True)
1647
+ snapshot.path.write_bytes(snapshot.data or b"")
1648
+ elif snapshot.path.exists():
1649
+ snapshot.path.unlink()
1650
+ except OSError as exc:
1651
+ errors.append(f"{snapshot.path}: {exc}")
1652
+ return errors
1653
+
1654
+
1655
+ def _prompt_and_check_sandbox(console: Console, cfg: AppConfig) -> _SandboxStepResult:
1656
+ console.print()
1657
+ console.print(
1658
+ Panel(
1659
+ "Alysis Code runs shell commands in a sandbox to keep your system safe.\n"
1660
+ "Checking sandbox readiness...",
1661
+ title="Sandbox",
1662
+ border_style="cyan",
1663
+ )
1664
+ )
1665
+ try:
1666
+ result = diagnose_sandbox(cfg, include_smoke=False)
1667
+ except (_GoBack, KeyboardInterrupt, Abort, EOFError):
1668
+ console.print(
1669
+ "[yellow]Sandbox skipped. You can finish this later with `alysis sandbox setup`.[/yellow]"
1670
+ )
1671
+ return _SandboxStepResult(ready=False, status="skipped")
1672
+ except Exception as exc: # noqa: BLE001 - sandbox backends fail in environment-specific ways
1673
+ console.print(
1674
+ f"[yellow]Sandbox check failed:[/yellow] {exc}. Re-run with `alysis sandbox doctor`."
1675
+ )
1676
+ return _SandboxStepResult(ready=False, status="check failed")
1677
+
1678
+ _print_sandbox_status(console, result)
1679
+ if result.ready:
1680
+ return _SandboxStepResult(ready=True, status=result.selected_backend or result.status)
1681
+
1682
+ console.print(Panel(_sandbox_problem_text(result), title="Sandbox", border_style="yellow"))
1683
+ if result.can_pull:
1684
+ try:
1685
+ should_pull = _prompt_yes_no(
1686
+ "Sandbox image not found. Pull it now? (~50MB) [Y/n]", default=True
1687
+ )
1688
+ except (_GoBack, KeyboardInterrupt, Abort, EOFError):
1689
+ console.print(
1690
+ "[yellow]Sandbox skipped. You can finish this later with `alysis sandbox setup`.[/yellow]"
1691
+ )
1692
+ return _SandboxStepResult(ready=False, status="skipped")
1693
+ if should_pull:
1694
+ try:
1695
+ return _pull_sandbox(console, cfg, result)
1696
+ except (_GoBack, KeyboardInterrupt, Abort, EOFError):
1697
+ console.print(
1698
+ "[yellow]Sandbox skipped. You can finish this later with `alysis sandbox setup`.[/yellow]"
1699
+ )
1700
+ return _SandboxStepResult(ready=False, status="skipped")
1701
+ console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
1702
+ return _SandboxStepResult(ready=False, status="not ready")
1703
+
1704
+ return _offer_sandbox_choice(console, cfg, result)
1705
+
1706
+
1707
+ def _offer_sandbox_choice(
1708
+ console: Console, cfg: AppConfig, result: SandboxDiagnostic
1709
+ ) -> _SandboxStepResult:
1710
+ """No usable backend was found: let the user install one, opt out, or defer.
1711
+
1712
+ The secure default stays strict; this only surfaces an explicit, discoverable
1713
+ opt-out so a brand-new user is not silently blocked on their first task.
1714
+ """
1715
+ del result # diagnosis already printed; we only need the decision now
1716
+ plan = detect_bubblewrap_install_plan()
1717
+ rows: list[tuple[str, str, str]] = []
1718
+ if plan is not None:
1719
+ rows.append(
1720
+ (
1721
+ "install_bwrap",
1722
+ "Install bubblewrap now (keeps sandbox on)",
1723
+ f"Runs: {plan.display}",
1724
+ )
1725
+ )
1726
+ rows.append(
1727
+ (
1728
+ "disable",
1729
+ "Run without sandbox for now (less safe)",
1730
+ "Commands run on the host shell. Re-enable anytime in /config.",
1731
+ )
1732
+ )
1733
+ rows.append(
1734
+ (
1735
+ "later",
1736
+ "Decide later",
1737
+ "Finish with `alysis sandbox setup`.",
1738
+ )
1739
+ )
1740
+ try:
1741
+ selected = _run_wizard_picker(
1742
+ console=console,
1743
+ title="Sandbox",
1744
+ subtitle=(
1745
+ "No sandbox backend (bubblewrap or Docker) was found. How do you want to proceed?"
1746
+ ),
1747
+ rows=rows,
1748
+ current_value=rows[0][0],
1749
+ cancel_hint="skip",
1750
+ )
1751
+ except (KeyboardInterrupt, Abort, EOFError):
1752
+ console.print(
1753
+ "[yellow]Sandbox skipped. You can finish this later with `alysis sandbox setup`.[/yellow]"
1754
+ )
1755
+ return _SandboxStepResult(ready=False, status="skipped")
1756
+
1757
+ if selected == "install_bwrap":
1758
+ return _install_bwrap_and_recheck(console, cfg, plan)
1759
+ if selected == "disable":
1760
+ return _disable_sandbox(console, cfg)
1761
+ console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
1762
+ return _SandboxStepResult(ready=False, status="not ready")
1763
+
1764
+
1765
+ def _disable_sandbox(console: Console, cfg: AppConfig) -> _SandboxStepResult:
1766
+ apply_sandbox_mode_to_config(cfg, "off")
1767
+ try:
1768
+ save_config(cfg)
1769
+ except (ConfigError, OSError) as exc:
1770
+ console.print(f"[yellow]Could not save the sandbox setting:[/yellow] {exc}")
1771
+ return _SandboxStepResult(ready=False, status="not ready")
1772
+ console.print(
1773
+ "[yellow]Sandbox disabled - Alysis Code will run commands directly on the host shell. "
1774
+ "Re-enable anytime with /config -> Sandbox.[/yellow]"
1775
+ )
1776
+ return _SandboxStepResult(ready=False, status="disabled")
1777
+
1778
+
1779
+ def _install_bwrap_and_recheck(
1780
+ console: Console, cfg: AppConfig, plan: BubblewrapInstallPlan | None
1781
+ ) -> _SandboxStepResult:
1782
+ display = plan.display if plan is not None else "auto"
1783
+ console.print(f"[dim]Installing bubblewrap ({display})...[/dim]")
1784
+ console.print("[dim]You may be prompted for your sudo password.[/dim]")
1785
+ install_result = install_bubblewrap(plan=plan)
1786
+ if not install_result.ok:
1787
+ console.print(
1788
+ f"[yellow]Bubblewrap install did not complete:[/yellow] {install_result.detail}"
1789
+ )
1790
+ console.print(
1791
+ "[yellow]You can finish this later with `alysis sandbox setup`, "
1792
+ "or choose to run without sandbox in /config.[/yellow]"
1793
+ )
1794
+ return _SandboxStepResult(ready=False, status="not ready")
1795
+ console.print("[green]Bubblewrap installed.[/green]")
1796
+ try:
1797
+ recheck = diagnose_sandbox(cfg, include_smoke=True)
1798
+ except Exception as exc: # noqa: BLE001 - sandbox backends fail in environment-specific ways
1799
+ console.print(
1800
+ f"[yellow]Sandbox check failed after install:[/yellow] {exc}. "
1801
+ "Re-run with `alysis sandbox doctor`."
1802
+ )
1803
+ return _SandboxStepResult(ready=False, status="check failed")
1804
+ _print_sandbox_status(console, recheck)
1805
+ if recheck.ready:
1806
+ return _SandboxStepResult(ready=True, status=recheck.selected_backend or recheck.status)
1807
+ console.print(Panel(_sandbox_problem_text(recheck), title="Sandbox", border_style="yellow"))
1808
+ console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
1809
+ return _SandboxStepResult(ready=False, status="not ready")
1810
+
1811
+
1812
+ def _pull_sandbox(
1813
+ console: Console,
1814
+ cfg: AppConfig,
1815
+ initial_result: SandboxDiagnostic,
1816
+ ) -> _SandboxStepResult:
1817
+ console.print("[dim]Pulling Alysis Code sandbox image...[/dim]")
1818
+ try:
1819
+ pull_result = pull_sandbox_images(timeout_s=900)
1820
+ except (KeyboardInterrupt, Abort, EOFError):
1821
+ raise
1822
+ except Exception as exc: # noqa: BLE001
1823
+ log_path = _write_exception_log("sandbox_pull", exc)
1824
+ console.print(f"[yellow]Sandbox image pull failed:[/yellow] {exc}")
1825
+ if log_path is not None:
1826
+ console.print(f"[dim]Details saved to: {log_path}[/dim]")
1827
+ console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
1828
+ return _SandboxStepResult(ready=False, status="pull failed")
1829
+
1830
+ if not pull_result.ok:
1831
+ log_path = _write_sandbox_pull_log(pull_result)
1832
+ console.print(
1833
+ Panel(_sandbox_problem_text(initial_result), title="Sandbox", border_style="yellow")
1834
+ )
1835
+ console.print(
1836
+ f"[yellow]Sandbox image pull failed:[/yellow] {pull_result.error or 'image pull failed'}"
1837
+ )
1838
+ if log_path is not None:
1839
+ console.print(f"[dim]Raw pull output saved to: {log_path}[/dim]")
1840
+ console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
1841
+ return _SandboxStepResult(ready=False, status="pull failed")
1842
+
1843
+ console.print("[green]Sandbox image pulled.[/green]")
1844
+ try:
1845
+ final_result = diagnose_sandbox(cfg, include_smoke=True)
1846
+ except Exception as exc: # noqa: BLE001
1847
+ console.print(
1848
+ f"[yellow]Sandbox check failed after pull:[/yellow] {exc}. "
1849
+ "Re-run with `alysis sandbox doctor`."
1850
+ )
1851
+ return _SandboxStepResult(ready=False, status="check failed")
1852
+ _print_sandbox_status(console, final_result)
1853
+ if final_result.ready:
1854
+ return _SandboxStepResult(
1855
+ ready=True, status=final_result.selected_backend or final_result.status
1856
+ )
1857
+ console.print(
1858
+ Panel(_sandbox_problem_text(final_result), title="Sandbox", border_style="yellow")
1859
+ )
1860
+ console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
1861
+ return _SandboxStepResult(ready=False, status="not ready")
1862
+
1863
+
1864
+ def _print_sandbox_status(console: Console, result: SandboxDiagnostic) -> None:
1865
+ backend = result.selected_backend or result.status
1866
+ console.print(f"Backend detected: {backend}")
1867
+ console.print(f"Image present: {result.docker_image}")
1868
+ if result.ready:
1869
+ console.print("[green]Sandbox ready[/green]")
1870
+
1871
+
1872
+ def _sandbox_problem_text(result: SandboxDiagnostic) -> str:
1873
+ return format_sandbox_problem_message(result)
1874
+
1875
+
1876
+ def _print_setup_complete(
1877
+ *,
1878
+ console: Console,
1879
+ profile_result: _ProfileStepResult,
1880
+ api_key_result: _ApiKeyStepResult,
1881
+ model_result: _ModelStepResult,
1882
+ workspace_result: _WorkspaceStepResult,
1883
+ sandbox_result: _SandboxStepResult,
1884
+ ) -> None:
1885
+ api_key_label = _api_key_summary_label(api_key_result)
1886
+ sandbox_label = _sandbox_summary_label(sandbox_result)
1887
+ table = Table(show_header=False, box=None, padding=(0, 2))
1888
+ table.add_column("label", no_wrap=True, style="bold")
1889
+ table.add_column("value")
1890
+ table.add_row("Profile:", f"{profile_result.label} (active)")
1891
+ table.add_row("API key:", api_key_label)
1892
+ table.add_row("Model:", model_result.model)
1893
+ if (
1894
+ profile_result.preset is not None
1895
+ and profile_result.preset.key in _MODEL_REASONING_SETUP_PRESET_KEYS
1896
+ ):
1897
+ table.add_row("Reasoning:", model_result.reasoning_label)
1898
+ table.add_row("Workspace:", workspace_result.workspace)
1899
+ table.add_row("Sandbox:", sandbox_label)
1900
+ console.print()
1901
+ console.print(
1902
+ Panel(
1903
+ Group(table, Text(""), Text("Type your first message to start chatting.", style="dim")),
1904
+ title="Setup complete",
1905
+ border_style="green",
1906
+ )
1907
+ )
1908
+
1909
+
1910
+ def _print_delegated_setup_complete(
1911
+ *,
1912
+ console: Console,
1913
+ execution_result: _ExecutionStepResult,
1914
+ workspace_result: _WorkspaceStepResult,
1915
+ auth_result: _DelegatedAuthResult,
1916
+ cfg: AppConfig | None = None,
1917
+ ) -> None:
1918
+ runtime_id = str(execution_result.runtime or "").strip()
1919
+ auth_hint = execution_result.auth_hint
1920
+ table = Table(show_header=False, box=None, padding=(0, 2))
1921
+ table.add_column("label", no_wrap=True, style="bold")
1922
+ table.add_column("value")
1923
+ table.add_row("Connection:", "AI subscription")
1924
+ table.add_row("Subscription:", execution_result.label)
1925
+ table.add_row("Workspace:", workspace_result.workspace)
1926
+ if cfg is not None and cfg.execution.backend == "native":
1927
+ if active_subscription_selection_ready(cfg):
1928
+ active = get_profile(cfg, str(cfg.extra_fields.get("active_profile") or ""))
1929
+ table.add_row("Model:", active.default_model if active is not None else cfg.model)
1930
+ if active is not None and active.reasoning_effort is not None:
1931
+ table.add_row("Reasoning:", active.reasoning_effort)
1932
+ else:
1933
+ table.add_row("Model setup:", "Choose model and reasoning in /config")
1934
+ table.add_row(
1935
+ "Authentication:",
1936
+ Text(auth_result.summary, style="green" if auth_result.connected else "yellow"),
1937
+ )
1938
+ next_step = (
1939
+ Text("Your AI subscription is connected.", style="dim")
1940
+ if auth_result.connected
1941
+ else Text(
1942
+ f"Next: run `alysis auth login {runtime_id}` before using this subscription.",
1943
+ style="yellow",
1944
+ )
1945
+ )
1946
+ console.print()
1947
+ console.print(
1948
+ Panel(
1949
+ Group(
1950
+ table,
1951
+ Text(""),
1952
+ next_step,
1953
+ Text(auth_hint, style="dim") if auth_hint else Text(""),
1954
+ Text(
1955
+ "Use /config → Default Model to choose the subscription model and reasoning effort.",
1956
+ style="dim",
1957
+ ),
1958
+ ),
1959
+ title="Setup complete",
1960
+ border_style="green",
1961
+ )
1962
+ )
1963
+
1964
+
1965
+ def _is_direct_subscription(runtime_id: str) -> bool:
1966
+ from ..provider_auth import provider_auth_ids
1967
+
1968
+ return str(runtime_id or "").strip() in provider_auth_ids()
1969
+
1970
+
1971
+ def _direct_subscription_profile(cfg: AppConfig, runtime_id: str) -> ProfileSpec:
1972
+ if not _is_direct_subscription(runtime_id):
1973
+ raise ConfigError(f"No native subscription profile is registered for {runtime_id!r}.")
1974
+ from ..provider_auth import create_provider_auth
1975
+
1976
+ adapter = create_provider_auth(runtime_id)
1977
+ existing = get_profile(cfg, adapter.profile_name)
1978
+ if existing is not None and existing.auth_provider == runtime_id:
1979
+ return ProfileSpec(
1980
+ name=existing.name,
1981
+ protocol=adapter.protocol,
1982
+ base_url=adapter.base_url,
1983
+ api_key_env=existing.api_key_env,
1984
+ auth_provider=runtime_id,
1985
+ extra_headers=dict(existing.extra_headers),
1986
+ default_model=existing.default_model,
1987
+ reasoning_effort=existing.reasoning_effort,
1988
+ reasoning_trace_adapter=existing.reasoning_trace_adapter,
1989
+ web_search_adapter=existing.web_search_adapter,
1990
+ web_search_model=existing.web_search_model,
1991
+ notes=existing.notes,
1992
+ cache_capability=existing.cache_capability,
1993
+ )
1994
+ return ProfileSpec(
1995
+ name=adapter.profile_name,
1996
+ protocol=adapter.protocol,
1997
+ base_url=adapter.base_url,
1998
+ auth_provider=runtime_id,
1999
+ notes=f"{adapter.display_name}. Uses Alysis Code's native agent loop.",
2000
+ )
2001
+
2002
+
2003
+ def _sync_direct_subscription_model(
2004
+ cfg: AppConfig,
2005
+ runtime_id: str,
2006
+ *,
2007
+ adapter: Any | None = None,
2008
+ ) -> None:
2009
+ """Refresh the catalog and preserve only a /config-confirmed selection."""
2010
+
2011
+ if not _is_direct_subscription(runtime_id):
2012
+ return
2013
+ from ..provider_auth import create_provider_auth
2014
+
2015
+ resolved_adapter = adapter or create_provider_auth(runtime_id)
2016
+ models = resolved_adapter.list_models(refresh=True)
2017
+ if not models:
2018
+ raise ConfigError("The connected subscription account did not advertise any models.")
2019
+ profile_name = str(getattr(resolved_adapter, "profile_name", "") or "").strip()
2020
+ if not profile_name:
2021
+ raise ConfigError(f"Subscription adapter {runtime_id!r} has no profile name.")
2022
+ profile = get_profile(cfg, profile_name)
2023
+ if profile is None:
2024
+ raise ConfigError(f"Subscription profile {profile_name!r} is missing.")
2025
+ confirmation_pending = str(
2026
+ cfg.extra_fields.get(SUBSCRIPTION_SELECTION_REQUIRED_KEY) or ""
2027
+ ).strip().lower() in {"true", runtime_id}
2028
+ selection_ready = subscription_selection_supported(profile, models) and not confirmation_pending
2029
+ cfg.extra_fields[_ONBOARDED_KEY] = selection_ready
2030
+ cfg.extra_fields.pop("subscription_reconnect_required", None)
2031
+ if not selection_ready:
2032
+ cfg.extra_fields[SUBSCRIPTION_SELECTION_REQUIRED_KEY] = runtime_id
2033
+ save_config(cfg)
2034
+
2035
+
2036
+ def _api_key_summary_label(api_key_result: _ApiKeyStepResult) -> Text:
2037
+ if not api_key_result.api_key:
2038
+ return Text("not stored", style="yellow")
2039
+
2040
+ reason = str(api_key_result.validation_message or "").strip()
2041
+ if api_key_result.validation_status == "validated":
2042
+ return Text("stored, validated", style="green")
2043
+ if api_key_result.validation_status == "inconclusive":
2044
+ suffix = f" ({reason})" if reason else ""
2045
+ return Text(f"stored, validation inconclusive{suffix}", style="yellow")
2046
+ if api_key_result.validation_status == "failed":
2047
+ suffix = f" ({reason})" if reason else ""
2048
+ return Text(
2049
+ f"stored, but provider rejected it{suffix}. Re-enter via /config.",
2050
+ style="red",
2051
+ )
2052
+ if api_key_result.validation_status == "skipped":
2053
+ return Text("stored, validation skipped", style="yellow")
2054
+ if api_key_result.validation_status == "model_not_found":
2055
+ suffix = f" ({reason})" if reason else ""
2056
+ return Text(f"stored, model validation failed{suffix}", style="red")
2057
+ return Text(
2058
+ f"stored, unknown validation status: {api_key_result.validation_status}", style="red"
2059
+ )
2060
+
2061
+
2062
+ def _sandbox_summary_label(sandbox_result: _SandboxStepResult) -> Text:
2063
+ if sandbox_result.ready:
2064
+ return Text(f"ready ({sandbox_result.status})", style="green")
2065
+ if sandbox_result.status == "disabled":
2066
+ return Text("off - host execution (less safe); re-enable via /config", style="yellow")
2067
+ if sandbox_result.status == "skipped":
2068
+ return Text("skipped - run `alysis sandbox setup` to finish", style="yellow")
2069
+ return Text(
2070
+ f"{sandbox_result.status} - run `alysis sandbox setup` to finish",
2071
+ style="yellow",
2072
+ )
2073
+
2074
+
2075
+ def _run_wizard_picker(
2076
+ *,
2077
+ console: Console,
2078
+ title: str,
2079
+ subtitle: str,
2080
+ rows: list[tuple[str, str, str]],
2081
+ current_value: str,
2082
+ cancel_hint: str,
2083
+ invalid_hint: str = "Choose one of the numbered options.",
2084
+ ) -> str | None:
2085
+ if not rows:
2086
+ return None
2087
+ footer_hint = (
2088
+ f"↑/↓ navigate Enter select 1-{min(len(rows), 9)} jump Esc {cancel_hint} Ctrl+C cancel"
2089
+ )
2090
+ try:
2091
+ from .config_menu import _run_config_picker
2092
+ except Exception as exc: # noqa: BLE001
2093
+ console.print(f"[dim]Interactive picker unavailable: {exc}. Using numeric input.[/dim]")
2094
+ return _run_wizard_picker_fallback(
2095
+ console=console,
2096
+ title=title,
2097
+ subtitle=subtitle,
2098
+ rows=rows,
2099
+ current_value=current_value,
2100
+ footer_hint=footer_hint,
2101
+ invalid_hint=invalid_hint,
2102
+ )
2103
+ try:
2104
+ return _run_config_picker(
2105
+ console=console,
2106
+ title=title,
2107
+ subtitle=subtitle,
2108
+ rows=rows,
2109
+ current_value=current_value,
2110
+ footer_hint=footer_hint,
2111
+ )
2112
+ except (KeyboardInterrupt, Abort, EOFError):
2113
+ raise
2114
+ except Exception as exc: # noqa: BLE001
2115
+ console.print(f"[dim]Interactive picker unavailable: {exc}. Using numeric input.[/dim]")
2116
+ return _run_wizard_picker_fallback(
2117
+ console=console,
2118
+ title=title,
2119
+ subtitle=subtitle,
2120
+ rows=rows,
2121
+ current_value=current_value,
2122
+ footer_hint=footer_hint,
2123
+ invalid_hint=invalid_hint,
2124
+ )
2125
+
2126
+
2127
+ def _run_wizard_picker_fallback(
2128
+ *,
2129
+ console: Console,
2130
+ title: str,
2131
+ subtitle: str,
2132
+ rows: list[tuple[str, str, str]],
2133
+ current_value: str,
2134
+ footer_hint: str,
2135
+ invalid_hint: str,
2136
+ ) -> str | None:
2137
+ current = str(current_value or "")
2138
+ while True:
2139
+ console.print()
2140
+ console.rule(f"[bold]{title}[/bold]")
2141
+ console.print(f"[dim]{subtitle}[/dim]")
2142
+ console.print()
2143
+ for index, (value, label, description) in enumerate(rows, start=1):
2144
+ suffix = " [dim](current)[/dim]" if value == current else ""
2145
+ detail = f" [dim]{description}[/dim]" if description else ""
2146
+ console.print(f" {index}) {label}{suffix}{detail}")
2147
+ console.print()
2148
+ console.print(f"[dim]{footer_hint}[/dim]")
2149
+ raw = _prompt_text("Choice", default="", show_default=False).strip()
2150
+ if not raw or _is_cancel_token(raw):
2151
+ return None
2152
+ if raw.isdigit():
2153
+ idx = int(raw) - 1
2154
+ if 0 <= idx < len(rows):
2155
+ return rows[idx][0]
2156
+ matched = _match_row_by_key_or_label(rows, raw)
2157
+ if matched is not None:
2158
+ return matched
2159
+ console.print(f"[yellow]{invalid_hint}[/yellow]")
2160
+
2161
+
2162
+ def _match_row_by_key_or_label(rows: list[tuple[str, str, str]], raw_value: str) -> str | None:
2163
+ value = str(raw_value or "").strip().casefold()
2164
+ if not value:
2165
+ return None
2166
+ for row_value, label, _description in rows:
2167
+ if value in {row_value.casefold(), label.casefold()}:
2168
+ return row_value
2169
+ return None
2170
+
2171
+
2172
+ def _setup_presets() -> list[ProfilePreset]:
2173
+ return provider_selection_presets()
2174
+
2175
+
2176
+ def _advanced_setup_presets() -> list[ProfilePreset]:
2177
+ return advanced_provider_selection_presets()
2178
+
2179
+
2180
+ def _provider_picker_rows(presets: list[ProfilePreset]) -> list[tuple[str, str, str]]:
2181
+ rows = [
2182
+ (preset.key, preset_selection_label(preset), _preset_description(preset))
2183
+ for preset in presets
2184
+ ]
2185
+ rows.append(
2186
+ (
2187
+ _ADVANCED_PROVIDER_PRESETS_VALUE,
2188
+ "Advanced / local / compatibility providers",
2189
+ "Show local endpoints (Ollama, LM Studio, vLLM), custom URLs, and legacy "
2190
+ "OpenAI-compatible first-party presets.",
2191
+ )
2192
+ )
2193
+ return rows
2194
+
2195
+
2196
+ def _preset_by_key(key: str) -> ProfilePreset | None:
2197
+ normalized = str(key or "").strip().lower()
2198
+ for preset in PROFILE_PRESETS:
2199
+ if preset.key == normalized:
2200
+ return preset
2201
+ return None
2202
+
2203
+
2204
+ def _preset_description(preset: ProfilePreset) -> str:
2205
+ prefix = preset_protocol_summary(preset)
2206
+ if preset.notes:
2207
+ return f"{prefix}. {preset.notes}"
2208
+ parsed = urlparse(preset.base_url)
2209
+ if parsed.netloc:
2210
+ return f"{prefix}. Host: {parsed.netloc}"
2211
+ return f"{prefix}. Use any OpenAI-compatible base URL"
2212
+
2213
+
2214
+ def _print_preset_warning(console: Console, preset: ProfilePreset) -> None:
2215
+ warning = str(preset.setup_warning or "").strip()
2216
+ if warning:
2217
+ console.print(f"[yellow]{warning}[/yellow]")
2218
+
2219
+
2220
+ def _print_provider_diagnostic_warnings(console: Console, cfg: AppConfig) -> None:
2221
+ try:
2222
+ issues = provider_diagnostic_warning_lines(cfg)
2223
+ except ConfigError:
2224
+ return
2225
+ for issue in issues:
2226
+ console.print(f"[yellow]Provider diagnostic: {issue}[/yellow]")
2227
+
2228
+
2229
+ def _suggest_workspace_default() -> Path:
2230
+ home = Path.home()
2231
+ for candidate in (home / "projects", home / "code"):
2232
+ if candidate.exists() and candidate.is_dir():
2233
+ return candidate
2234
+ return home
2235
+
2236
+
2237
+ def _display_path(path: Path) -> str:
2238
+ home = Path.home()
2239
+ if path == home:
2240
+ return "~"
2241
+ if path.is_relative_to(home):
2242
+ return "~" + os.fspath(path).removeprefix(os.fspath(home))
2243
+ return os.fspath(path)
2244
+
2245
+
2246
+ def _escape_capture_unavailable_reason() -> str | None:
2247
+ try:
2248
+ from ..cli import _is_non_interactive_terminal
2249
+ except Exception as exc: # noqa: BLE001 - capability checks should degrade clearly
2250
+ return f"terminal capability check failed: {exc}"
2251
+ if _is_non_interactive_terminal():
2252
+ return "non-interactive terminal"
2253
+ try:
2254
+ from prompt_toolkit import PromptSession # noqa: F401
2255
+ from prompt_toolkit.key_binding import KeyBindings # noqa: F401
2256
+ except Exception as exc: # noqa: BLE001
2257
+ return f"prompt_toolkit unavailable: {exc}"
2258
+ return None
2259
+
2260
+
2261
+ def _can_capture_escape() -> bool:
2262
+ return _escape_capture_unavailable_reason() is None
2263
+
2264
+
2265
+ def _esc_aware_text_input(
2266
+ prompt: str,
2267
+ *,
2268
+ default: str = "",
2269
+ show_default: bool = True,
2270
+ hide_input: bool = False,
2271
+ ) -> str:
2272
+ if not _can_capture_escape():
2273
+ return _prompt_text(
2274
+ prompt,
2275
+ default=default,
2276
+ show_default=show_default,
2277
+ hide_input=hide_input,
2278
+ )
2279
+ try:
2280
+ from prompt_toolkit import PromptSession
2281
+ from prompt_toolkit.key_binding import KeyBindings
2282
+ except Exception as exc: # noqa: BLE001
2283
+ _resolve_console().print(
2284
+ f"[dim]Esc/back navigation unavailable: {exc}. Using line input.[/dim]"
2285
+ )
2286
+ return _prompt_text(
2287
+ prompt,
2288
+ default=default,
2289
+ show_default=show_default,
2290
+ hide_input=hide_input,
2291
+ )
2292
+
2293
+ bindings = KeyBindings()
2294
+
2295
+ @bindings.add("escape", eager=True)
2296
+ def _go_back(event: Any) -> None:
2297
+ event.app.exit(exception=_GoBack())
2298
+
2299
+ session: PromptSession[str] = PromptSession(
2300
+ key_bindings=bindings,
2301
+ is_password=hide_input,
2302
+ )
2303
+ try:
2304
+ return str(
2305
+ session.prompt(
2306
+ f"{prompt}: ",
2307
+ default=default,
2308
+ is_password=hide_input,
2309
+ )
2310
+ )
2311
+ except _GoBack:
2312
+ raise
2313
+ except (KeyboardInterrupt, EOFError):
2314
+ raise
2315
+
2316
+
2317
+ def _confirm_cancel(
2318
+ console: Console, *, prompt: str = "Cancel setup and discard inputs? [y/N]"
2319
+ ) -> bool:
2320
+ while True:
2321
+ try:
2322
+ value = _esc_aware_text_input(prompt, default="n", show_default=False).strip().lower()
2323
+ except _GoBack:
2324
+ return False
2325
+ except (Abort, EOFError, KeyboardInterrupt):
2326
+ return True
2327
+ if not value:
2328
+ return False
2329
+ if value in {"y", "yes"}:
2330
+ return True
2331
+ if value in {"n", "no"}:
2332
+ return False
2333
+ console.print("[red]Enter y or n.[/red]")
2334
+
2335
+
2336
+ def _prompt_text(
2337
+ prompt: str,
2338
+ *,
2339
+ default: str,
2340
+ show_default: bool,
2341
+ hide_input: bool = False,
2342
+ ) -> str:
2343
+ return str(
2344
+ typer.prompt(
2345
+ prompt,
2346
+ default=default,
2347
+ show_default=show_default,
2348
+ hide_input=hide_input,
2349
+ )
2350
+ )
2351
+
2352
+
2353
+ def _prompt_yes_no(prompt: str, *, default: bool) -> bool:
2354
+ default_text = "y" if default else "n"
2355
+ while True:
2356
+ value = (
2357
+ _esc_aware_text_input(prompt, default=default_text, show_default=False).strip().lower()
2358
+ )
2359
+ if _is_cancel_token(value):
2360
+ raise _GoBack()
2361
+ if not value:
2362
+ return default
2363
+ if value in {"y", "yes"}:
2364
+ return True
2365
+ if value in {"n", "no"}:
2366
+ return False
2367
+ _resolve_console().print("[red]Enter y or n.[/red]")
2368
+
2369
+
2370
+ def _is_cancel_token(value: str) -> bool:
2371
+ normalized = str(value or "").strip().casefold()
2372
+ return normalized in {"\x1b", "esc", "escape", "cancel", "q", "quit"}
2373
+
2374
+
2375
+ def _write_sandbox_pull_log(result: SandboxPullResult) -> Path | None:
2376
+ lines = [
2377
+ f"ok={result.ok}",
2378
+ f"error={result.error or ''}",
2379
+ "",
2380
+ ]
2381
+ for item in result.results:
2382
+ lines.extend(
2383
+ [
2384
+ f"image={item.image}",
2385
+ f"ok={item.ok}",
2386
+ "output:",
2387
+ item.output,
2388
+ "",
2389
+ ]
2390
+ )
2391
+ return _write_log("sandbox_pull", "\n".join(lines))
2392
+
2393
+
2394
+ def _write_exception_log(prefix: str, exc: BaseException) -> Path | None:
2395
+ content = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
2396
+ return _write_log(prefix, content)
2397
+
2398
+
2399
+ def _write_log(prefix: str, content: str) -> Path | None:
2400
+ try:
2401
+ logs_dir = default_sessions_dir().parent / "logs"
2402
+ logs_dir.mkdir(parents=True, exist_ok=True)
2403
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
2404
+ path = logs_dir / f"{prefix}_{timestamp}.log"
2405
+ path.write_text(content, encoding="utf-8")
2406
+ return path
2407
+ except OSError as exc:
2408
+ _resolve_console().print(f"[yellow]Could not write diagnostic log:[/yellow] {exc}")
2409
+ return None