novacode-cli 1.2.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 (691) hide show
  1. novacode_cli/__init__.py +29 -0
  2. novacode_cli/__main__.py +6 -0
  3. novacode_cli/_lazy_heavy.py +99 -0
  4. novacode_cli/_version.py +3 -0
  5. novacode_cli/agent_stream.py +178 -0
  6. novacode_cli/agents/agent_file.py +250 -0
  7. novacode_cli/agents/agent_mailbox.py +120 -0
  8. novacode_cli/agents/approval_decisions.py +146 -0
  9. novacode_cli/agents/arg_repair.py +196 -0
  10. novacode_cli/agents/async_agents/__init__.py +6 -0
  11. novacode_cli/agents/async_agents/_config.py +213 -0
  12. novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
  13. novacode_cli/agents/async_agents/_specialist.py +84 -0
  14. novacode_cli/agents/async_agents/_test_commands.py +89 -0
  15. novacode_cli/agents/async_agents/_tools.py +432 -0
  16. novacode_cli/agents/async_agents/general_purpose.py +42 -0
  17. novacode_cli/agents/async_agents/langgraph.json +9 -0
  18. novacode_cli/agents/async_agents/research.py +22 -0
  19. novacode_cli/agents/async_context.py +303 -0
  20. novacode_cli/agents/async_on_demand.py +52 -0
  21. novacode_cli/agents/async_task_errors.py +75 -0
  22. novacode_cli/agents/async_workspace.py +27 -0
  23. novacode_cli/agents/attribution.py +165 -0
  24. novacode_cli/agents/browser_mode.py +119 -0
  25. novacode_cli/agents/btw_agent.py +71 -0
  26. novacode_cli/agents/caveman.py +352 -0
  27. novacode_cli/agents/caveman_support.py +370 -0
  28. novacode_cli/agents/code_mode.py +774 -0
  29. novacode_cli/agents/code_runtime.py +160 -0
  30. novacode_cli/agents/context_compaction.py +1339 -0
  31. novacode_cli/agents/core_agent.py +2710 -0
  32. novacode_cli/agents/default_subagents/__init__.py +24 -0
  33. novacode_cli/agents/default_subagents/async_subagents.py +296 -0
  34. novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
  35. novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
  36. novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
  37. novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
  38. novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
  39. novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
  40. novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
  41. novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
  42. novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
  43. novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
  44. novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
  45. novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
  46. novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
  47. novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
  48. novacode_cli/agents/default_subagents/prompt.py +88 -0
  49. novacode_cli/agents/default_subagents/subagents.py +138 -0
  50. novacode_cli/agents/dream_agent.py +93 -0
  51. novacode_cli/agents/edit_diagnostics.py +362 -0
  52. novacode_cli/agents/edit_fallback.py +62 -0
  53. novacode_cli/agents/handoff.py +166 -0
  54. novacode_cli/agents/invocation.py +152 -0
  55. novacode_cli/agents/model_budget.py +42 -0
  56. novacode_cli/agents/model_retry.py +113 -0
  57. novacode_cli/agents/model_router.py +420 -0
  58. novacode_cli/agents/openai_decisions.py +186 -0
  59. novacode_cli/agents/plan_agent/__init__.py +12 -0
  60. novacode_cli/agents/plan_agent/plan_agent.py +357 -0
  61. novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
  62. novacode_cli/agents/plan_execution.py +237 -0
  63. novacode_cli/agents/ponytail.py +66 -0
  64. novacode_cli/agents/prime.py +274 -0
  65. novacode_cli/agents/provider_caching.py +99 -0
  66. novacode_cli/agents/server_launcher.py +904 -0
  67. novacode_cli/agents/strict_tools.py +384 -0
  68. novacode_cli/agents/task_discipline.py +294 -0
  69. novacode_cli/agents/tool_call_repair.py +290 -0
  70. novacode_cli/agents/tool_offload.py +291 -0
  71. novacode_cli/agents/tool_search.py +806 -0
  72. novacode_cli/agents/tool_search_variants.py +106 -0
  73. novacode_cli/agents/tool_verdicts.py +1194 -0
  74. novacode_cli/agents/user_async_agents.py +312 -0
  75. novacode_cli/artifacts/__init__.py +10 -0
  76. novacode_cli/artifacts/registry.py +278 -0
  77. novacode_cli/artifacts/server.py +426 -0
  78. novacode_cli/assets/Nova.png +0 -0
  79. novacode_cli/assets/nova-avatar.png +0 -0
  80. novacode_cli/audio/__init__.py +301 -0
  81. novacode_cli/audio/capture.py +137 -0
  82. novacode_cli/audio/downloads.py +273 -0
  83. novacode_cli/audio/pipeline.py +391 -0
  84. novacode_cli/audio/playback.py +87 -0
  85. novacode_cli/audio/providers.py +208 -0
  86. novacode_cli/audio/speakable.py +78 -0
  87. novacode_cli/audio/stt.py +132 -0
  88. novacode_cli/audio/stt_deepgram.py +69 -0
  89. novacode_cli/audio/stt_elevenlabs.py +89 -0
  90. novacode_cli/audio/stt_parakeet.py +94 -0
  91. novacode_cli/audio/stt_whistle.py +61 -0
  92. novacode_cli/audio/summarize.py +92 -0
  93. novacode_cli/audio/tts.py +85 -0
  94. novacode_cli/audio/tts_kokoro.py +73 -0
  95. novacode_cli/audio/tts_orpheus.py +180 -0
  96. novacode_cli/audio/tts_pocket.py +131 -0
  97. novacode_cli/audio/tts_sano.py +69 -0
  98. novacode_cli/audio/vad.py +145 -0
  99. novacode_cli/backends/__init__.py +17 -0
  100. novacode_cli/backends/filesystem.py +894 -0
  101. novacode_cli/backends/native_search.py +92 -0
  102. novacode_cli/bootstrap/__init__.py +9 -0
  103. novacode_cli/bootstrap/env_snapshot.py +240 -0
  104. novacode_cli/bootstrap/steering.py +464 -0
  105. novacode_cli/bootstrap/vision_router.py +604 -0
  106. novacode_cli/brand.py +362 -0
  107. novacode_cli/cli_args.py +718 -0
  108. novacode_cli/commands/__init__.py +237 -0
  109. novacode_cli/commands/agents_commands.py +231 -0
  110. novacode_cli/commands/auth_handler.py +40 -0
  111. novacode_cli/commands/browser_use_handler.py +75 -0
  112. novacode_cli/commands/cache_handler.py +32 -0
  113. novacode_cli/commands/chat_handler.py +1357 -0
  114. novacode_cli/commands/ci_fix.py +171 -0
  115. novacode_cli/commands/claude_plan.py +331 -0
  116. novacode_cli/commands/codex_plan.py +195 -0
  117. novacode_cli/commands/commands.py +1113 -0
  118. novacode_cli/commands/create_handler.py +124 -0
  119. novacode_cli/commands/create_server.py +1887 -0
  120. novacode_cli/commands/cron_handler.py +119 -0
  121. novacode_cli/commands/dream_handler.py +203 -0
  122. novacode_cli/commands/effort_handler.py +65 -0
  123. novacode_cli/commands/evolution_handler.py +214 -0
  124. novacode_cli/commands/file_commands.py +381 -0
  125. novacode_cli/commands/handoff_handler.py +68 -0
  126. novacode_cli/commands/hooks_handler.py +269 -0
  127. novacode_cli/commands/init_handler.py +146 -0
  128. novacode_cli/commands/learning_handler.py +160 -0
  129. novacode_cli/commands/lite_mode_handler.py +76 -0
  130. novacode_cli/commands/log_commands.py +363 -0
  131. novacode_cli/commands/mcp_handler.py +29 -0
  132. novacode_cli/commands/model_handler.py +29 -0
  133. novacode_cli/commands/notifications_handler.py +158 -0
  134. novacode_cli/commands/onboard_handler.py +121 -0
  135. novacode_cli/commands/plan_handler.py +440 -0
  136. novacode_cli/commands/planner_usage.py +250 -0
  137. novacode_cli/commands/plugin_install_handler.py +38 -0
  138. novacode_cli/commands/plugins_handler.py +35 -0
  139. novacode_cli/commands/prime_handler.py +455 -0
  140. novacode_cli/commands/prompt_handler.py +149 -0
  141. novacode_cli/commands/provider_usage.py +160 -0
  142. novacode_cli/commands/ralph_events.py +106 -0
  143. novacode_cli/commands/ralph_handler.py +1293 -0
  144. novacode_cli/commands/refine_handler.py +213 -0
  145. novacode_cli/commands/research_handler.py +202 -0
  146. novacode_cli/commands/rootapp_remote.py +83 -0
  147. novacode_cli/commands/router_handler.py +36 -0
  148. novacode_cli/commands/server_commands.py +249 -0
  149. novacode_cli/commands/session_commands.py +203 -0
  150. novacode_cli/commands/share_handler.py +450 -0
  151. novacode_cli/commands/side_commands.py +366 -0
  152. novacode_cli/commands/skill_invoke.py +420 -0
  153. novacode_cli/commands/skills_commands.py +48 -0
  154. novacode_cli/commands/trace_handler.py +322 -0
  155. novacode_cli/commands/trello_handler.py +226 -0
  156. novacode_cli/commands/trello_remote.py +177 -0
  157. novacode_cli/commands/trello_server.py +1214 -0
  158. novacode_cli/commands/vision_handler.py +127 -0
  159. novacode_cli/commands/voice_handler.py +447 -0
  160. novacode_cli/commands/webhook_handler.py +134 -0
  161. novacode_cli/commands/wiki_commands.py +227 -0
  162. novacode_cli/compaction.py +753 -0
  163. novacode_cli/computation_cache.py +159 -0
  164. novacode_cli/computer/__init__.py +1 -0
  165. novacode_cli/computer/persistent.py +234 -0
  166. novacode_cli/computer/preview.py +253 -0
  167. novacode_cli/computer/runtime.py +474 -0
  168. novacode_cli/computer/windows_console.py +152 -0
  169. novacode_cli/computer/windows_qemu.py +211 -0
  170. novacode_cli/config/browser_mode.py +159 -0
  171. novacode_cli/config/catalog_models.py +95 -0
  172. novacode_cli/config/config.py +1388 -0
  173. novacode_cli/config/credentials.py +540 -0
  174. novacode_cli/config/google_oauth_auth.py +281 -0
  175. novacode_cli/config/model_capabilities.py +343 -0
  176. novacode_cli/config/model_catalog.py +275 -0
  177. novacode_cli/config/model_create.py +1325 -0
  178. novacode_cli/config/model_manager.py +695 -0
  179. novacode_cli/config/nova_config.py +1283 -0
  180. novacode_cli/config/openai_chatgpt_auth.py +384 -0
  181. novacode_cli/config/opencode_gateway.py +25 -0
  182. novacode_cli/config/plan_mode.py +188 -0
  183. novacode_cli/config/provider_auth.py +438 -0
  184. novacode_cli/config/provider_catalog.json +5127 -0
  185. novacode_cli/config/provider_registry.py +38 -0
  186. novacode_cli/config/provider_transports.py +258 -0
  187. novacode_cli/config/role_models.py +175 -0
  188. novacode_cli/context/__init__.py +50 -0
  189. novacode_cli/context/_analysis.py +799 -0
  190. novacode_cli/context/_budget.py +116 -0
  191. novacode_cli/context/_conversation.py +73 -0
  192. novacode_cli/context/_dynamic.py +264 -0
  193. novacode_cli/context/_model_config.py +380 -0
  194. novacode_cli/context/_models_dev.py +99 -0
  195. novacode_cli/context/archives.py +248 -0
  196. novacode_cli/context/estimates.py +65 -0
  197. novacode_cli/context/history.py +17 -0
  198. novacode_cli/context/knowledge_cache.py +292 -0
  199. novacode_cli/context/manager.py +101 -0
  200. novacode_cli/context/observations.py +65 -0
  201. novacode_cli/context/pressure.py +168 -0
  202. novacode_cli/context/provider_accounting.py +109 -0
  203. novacode_cli/context/snapshot.py +493 -0
  204. novacode_cli/context/strategies.py +279 -0
  205. novacode_cli/context/task_progress.py +122 -0
  206. novacode_cli/core/__init__.py +0 -0
  207. novacode_cli/core/agent_loop.py +1821 -0
  208. novacode_cli/core/autonomous_loop.py +277 -0
  209. novacode_cli/core/code_approvals.py +57 -0
  210. novacode_cli/core/input_preparation.py +276 -0
  211. novacode_cli/core/reasoning.py +67 -0
  212. novacode_cli/core/streaming.py +326 -0
  213. novacode_cli/core/subagent_tasks.py +696 -0
  214. novacode_cli/core/subagent_tracking.py +235 -0
  215. novacode_cli/core/tool_results.py +116 -0
  216. novacode_cli/core/verification_loop.py +272 -0
  217. novacode_cli/council.py +595 -0
  218. novacode_cli/council_consensus.py +115 -0
  219. novacode_cli/council_decision.py +157 -0
  220. novacode_cli/council_planner.py +1421 -0
  221. novacode_cli/council_settings.py +208 -0
  222. novacode_cli/council_web_settings.py +41 -0
  223. novacode_cli/council_workspace.py +56 -0
  224. novacode_cli/cowork/__init__.py +10 -0
  225. novacode_cli/cowork/broker_middleware.py +162 -0
  226. novacode_cli/cowork/launcher.py +102 -0
  227. novacode_cli/cowork/policy.py +284 -0
  228. novacode_cli/cowork/ui.py +826 -0
  229. novacode_cli/daemons/__init__.py +1 -0
  230. novacode_cli/daemons/registry.py +342 -0
  231. novacode_cli/desktop_notifications.py +302 -0
  232. novacode_cli/doctor.py +251 -0
  233. novacode_cli/embeddings.py +109 -0
  234. novacode_cli/entrypoint.py +56 -0
  235. novacode_cli/errors/__init__.py +19 -0
  236. novacode_cli/errors/handlers.py +648 -0
  237. novacode_cli/errors/provider_errors.py +249 -0
  238. novacode_cli/errors/taxonomy.py +80 -0
  239. novacode_cli/events.py +179 -0
  240. novacode_cli/file_ops.py +714 -0
  241. novacode_cli/git_safety.py +281 -0
  242. novacode_cli/handoff.py +383 -0
  243. novacode_cli/headless/__init__.py +18 -0
  244. novacode_cli/headless/input.py +58 -0
  245. novacode_cli/headless/output.py +224 -0
  246. novacode_cli/headless/pipe.py +532 -0
  247. novacode_cli/headless/runner.py +461 -0
  248. novacode_cli/hermes/__init__.py +39 -0
  249. novacode_cli/hermes/activation.py +297 -0
  250. novacode_cli/hermes/config.py +129 -0
  251. novacode_cli/hermes/curator.py +199 -0
  252. novacode_cli/hermes/evolution.py +374 -0
  253. novacode_cli/hermes/ledger.py +364 -0
  254. novacode_cli/hermes/memory_tiers.py +969 -0
  255. novacode_cli/hermes/middleware.py +678 -0
  256. novacode_cli/hermes/overview.py +174 -0
  257. novacode_cli/hermes/prompt_evolution.py +503 -0
  258. novacode_cli/hermes/refine_loop.py +643 -0
  259. novacode_cli/hermes/refinement_log.py +190 -0
  260. novacode_cli/hermes/review.py +674 -0
  261. novacode_cli/hermes/skill_debate.py +135 -0
  262. novacode_cli/hermes/skill_discovery.py +450 -0
  263. novacode_cli/hermes/skill_manager.py +335 -0
  264. novacode_cli/hermes/skill_proposals.py +43 -0
  265. novacode_cli/hermes/test_gaming.py +119 -0
  266. novacode_cli/hermes/tracker.py +435 -0
  267. novacode_cli/hermes/tuner.py +206 -0
  268. novacode_cli/hermes/verification_memory.py +134 -0
  269. novacode_cli/hermes/verifier.py +277 -0
  270. novacode_cli/hitl/__init__.py +22 -0
  271. novacode_cli/hitl/interrupts.py +493 -0
  272. novacode_cli/hooks.py +359 -0
  273. novacode_cli/image_utils.py +517 -0
  274. novacode_cli/init/__init__.py +7 -0
  275. novacode_cli/init/events.py +43 -0
  276. novacode_cli/input_utils.py +526 -0
  277. novacode_cli/install_commands.py +87 -0
  278. novacode_cli/integrations/__init__.py +82 -0
  279. novacode_cli/integrations/daytona.py +122 -0
  280. novacode_cli/integrations/docker.py +309 -0
  281. novacode_cli/integrations/host_path.py +111 -0
  282. novacode_cli/integrations/langsmith.py +151 -0
  283. novacode_cli/integrations/modal.py +134 -0
  284. novacode_cli/integrations/runloop.py +130 -0
  285. novacode_cli/integrations/sandbox_factory.py +1388 -0
  286. novacode_cli/integrations/sandbox_registry.py +312 -0
  287. novacode_cli/integrations/workdir_backend.py +589 -0
  288. novacode_cli/main.py +2078 -0
  289. novacode_cli/mcp/__init__.py +73 -0
  290. novacode_cli/mcp/auth.py +189 -0
  291. novacode_cli/mcp/client.py +571 -0
  292. novacode_cli/mcp/code_mode.py +5 -0
  293. novacode_cli/mcp/code_runtime.py +5 -0
  294. novacode_cli/mcp/commands.py +902 -0
  295. novacode_cli/mcp/config.py +406 -0
  296. novacode_cli/mcp/generic.py +128 -0
  297. novacode_cli/mcp/middleware.py +993 -0
  298. novacode_cli/mcp/presets.py +367 -0
  299. novacode_cli/memory/__init__.py +1 -0
  300. novacode_cli/memory/agent_memory.py +1312 -0
  301. novacode_cli/memory/builtin_lessons.py +60 -0
  302. novacode_cli/memory/dream_gates.py +192 -0
  303. novacode_cli/memory/limits.py +59 -0
  304. novacode_cli/memory/parsed_cache.py +106 -0
  305. novacode_cli/memory/recall_stats.py +97 -0
  306. novacode_cli/memory/semantic.py +116 -0
  307. novacode_cli/memory/store.py +335 -0
  308. novacode_cli/memory/wiki.py +274 -0
  309. novacode_cli/migrate.py +185 -0
  310. novacode_cli/onboarding.py +649 -0
  311. novacode_cli/onboarding_check.py +119 -0
  312. novacode_cli/path_approval.py +297 -0
  313. novacode_cli/plan_archive.py +148 -0
  314. novacode_cli/plugins/__init__.py +121 -0
  315. novacode_cli/plugins/claude_plugins.py +538 -0
  316. novacode_cli/plugins/cli.py +168 -0
  317. novacode_cli/plugins/components.py +413 -0
  318. novacode_cli/plugins/hooks.py +356 -0
  319. novacode_cli/plugins/loader.py +317 -0
  320. novacode_cli/plugins/marketplaces.py +199 -0
  321. novacode_cli/plugins/storage.py +70 -0
  322. novacode_cli/prime.py +759 -0
  323. novacode_cli/prime_factory/__init__.py +15 -0
  324. novacode_cli/prime_factory/container.py +198 -0
  325. novacode_cli/prime_factory/controller.py +565 -0
  326. novacode_cli/prime_factory/knowledge.py +261 -0
  327. novacode_cli/prime_factory/publication.py +218 -0
  328. novacode_cli/prime_factory/recovery.py +50 -0
  329. novacode_cli/prime_factory/research.py +370 -0
  330. novacode_cli/prime_factory/runner.py +608 -0
  331. novacode_cli/prime_factory/runtime.py +261 -0
  332. novacode_cli/prime_factory/state.py +201 -0
  333. novacode_cli/prime_factory/store.py +128 -0
  334. novacode_cli/prime_factory/worker.py +129 -0
  335. novacode_cli/prime_factory/workflow.py +899 -0
  336. novacode_cli/prime_factory/workspace.py +317 -0
  337. novacode_cli/prime_memory.py +290 -0
  338. novacode_cli/process_manager.py +585 -0
  339. novacode_cli/project_onboard.py +537 -0
  340. novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
  341. novacode_cli/prompts/__init__.py +168 -0
  342. novacode_cli/prompts/agent_definitions.jinja +11 -0
  343. novacode_cli/prompts/agent_generation.jinja +125 -0
  344. novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
  345. novacode_cli/prompts/claude_plan_mode.jinja +12 -0
  346. novacode_cli/prompts/coding_workflow.jinja +18 -0
  347. novacode_cli/prompts/computer_use.jinja +17 -0
  348. novacode_cli/prompts/core_agent_system.jinja +81 -0
  349. novacode_cli/prompts/delegation.jinja +15 -0
  350. novacode_cli/prompts/desktop_power.jinja +4 -0
  351. novacode_cli/prompts/file_paths.jinja +15 -0
  352. novacode_cli/prompts/file_tracker.jinja +23 -0
  353. novacode_cli/prompts/init_agents_md.jinja +56 -0
  354. novacode_cli/prompts/init_exploration.jinja +174 -0
  355. novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
  356. novacode_cli/prompts/instruction_scope.jinja +5 -0
  357. novacode_cli/prompts/learnings_extraction.jinja +45 -0
  358. novacode_cli/prompts/longterm_memory.jinja +54 -0
  359. novacode_cli/prompts/mcp.jinja +84 -0
  360. novacode_cli/prompts/memory_consolidation.jinja +40 -0
  361. novacode_cli/prompts/nova_evolution.jinja +73 -0
  362. novacode_cli/prompts/nova_review.jinja +158 -0
  363. novacode_cli/prompts/nova_skill_debate.jinja +36 -0
  364. novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
  365. novacode_cli/prompts/nova_verify.jinja +100 -0
  366. novacode_cli/prompts/onboard_dashboard.jinja +378 -0
  367. novacode_cli/prompts/plan_agent.jinja +87 -0
  368. novacode_cli/prompts/plan_scouts.jinja +5 -0
  369. novacode_cli/prompts/plan_storage.jinja +8 -0
  370. novacode_cli/prompts/planning.jinja +82 -0
  371. novacode_cli/prompts/ponytail/LICENSE +21 -0
  372. novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
  373. novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
  374. novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
  375. novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
  376. novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
  377. novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
  378. novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
  379. novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
  380. novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
  381. novacode_cli/prompts/prompt_evolution.jinja +27 -0
  382. novacode_cli/prompts/ralph_iteration.jinja +75 -0
  383. novacode_cli/prompts/refine_gate.jinja +31 -0
  384. novacode_cli/prompts/refine_plan.jinja +64 -0
  385. novacode_cli/prompts/refine_review.jinja +34 -0
  386. novacode_cli/prompts/research_delivery.jinja +9 -0
  387. novacode_cli/prompts/research_swarm.jinja +130 -0
  388. novacode_cli/prompts/response_style.jinja +5 -0
  389. novacode_cli/prompts/session_summarization.jinja +86 -0
  390. novacode_cli/prompts/skill_creation.jinja +85 -0
  391. novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
  392. novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
  393. novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
  394. novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
  395. novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
  396. novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
  397. novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
  398. novacode_cli/prompts/summarization.jinja +107 -0
  399. novacode_cli/prompts/summarization_answers.jinja +23 -0
  400. novacode_cli/prompts/summarization_questions.jinja +32 -0
  401. novacode_cli/prompts/vision_caption.jinja +21 -0
  402. novacode_cli/prompts/voice_summary.jinja +65 -0
  403. novacode_cli/prompts/wiki_file.jinja +57 -0
  404. novacode_cli/prompts/wiki_ingest.jinja +87 -0
  405. novacode_cli/recovery.py +383 -0
  406. novacode_cli/remote/__init__.py +45 -0
  407. novacode_cli/remote/approvals.py +282 -0
  408. novacode_cli/remote/async_task_clients.py +97 -0
  409. novacode_cli/remote/async_task_watcher.py +240 -0
  410. novacode_cli/remote/bridge.py +905 -0
  411. novacode_cli/remote/command_menu.py +26 -0
  412. novacode_cli/remote/command_ui.py +243 -0
  413. novacode_cli/remote/config.py +199 -0
  414. novacode_cli/remote/discord_bridge.py +530 -0
  415. novacode_cli/remote/discord_format.py +31 -0
  416. novacode_cli/remote/discord_threads.py +225 -0
  417. novacode_cli/remote/images.py +97 -0
  418. novacode_cli/remote/processor.py +601 -0
  419. novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
  420. novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
  421. novacode_cli/remote/rootapp_bot/package.json +18 -0
  422. novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
  423. novacode_cli/remote/rootapp_bridge.py +370 -0
  424. novacode_cli/remote/rootapp_setup.py +89 -0
  425. novacode_cli/remote/routing.py +103 -0
  426. novacode_cli/remote/scheduler.py +302 -0
  427. novacode_cli/remote/session.py +164 -0
  428. novacode_cli/remote/status.py +411 -0
  429. novacode_cli/remote/streaming.py +188 -0
  430. novacode_cli/remote/telegram_bridge.py +1118 -0
  431. novacode_cli/remote/telegram_format.py +215 -0
  432. novacode_cli/remote/telegram_hub.py +256 -0
  433. novacode_cli/remote/voice_notes.py +120 -0
  434. novacode_cli/remote/webhook_adapters.py +167 -0
  435. novacode_cli/remote/webhook_server.py +154 -0
  436. novacode_cli/rewind.py +150 -0
  437. novacode_cli/rooms/__init__.py +1 -0
  438. novacode_cli/rooms/bridge.py +86 -0
  439. novacode_cli/rooms/cli.py +231 -0
  440. novacode_cli/rooms/nova.py +227 -0
  441. novacode_cli/rooms/runtime.py +299 -0
  442. novacode_cli/rooms/store.py +538 -0
  443. novacode_cli/rooms/workspace.py +94 -0
  444. novacode_cli/security/__init__.py +29 -0
  445. novacode_cli/security/approval_context.py +45 -0
  446. novacode_cli/security/delegated_approval.py +68 -0
  447. novacode_cli/security/local_http.py +85 -0
  448. novacode_cli/security/middleware.py +87 -0
  449. novacode_cli/security/paths.py +35 -0
  450. novacode_cli/security/policy.py +535 -0
  451. novacode_cli/security/policy_writer.py +90 -0
  452. novacode_cli/security/prompt_injection.py +203 -0
  453. novacode_cli/security/remember.py +81 -0
  454. novacode_cli/security/rule_synthesis.py +116 -0
  455. novacode_cli/security/secret_files.py +61 -0
  456. novacode_cli/security/session_allow.py +114 -0
  457. novacode_cli/security/unicode_security.py +516 -0
  458. novacode_cli/security/validator.py +173 -0
  459. novacode_cli/server/__init__.py +9 -0
  460. novacode_cli/server/app.py +632 -0
  461. novacode_cli/server/event_adapter.py +189 -0
  462. novacode_cli/server/session_manager.py +90 -0
  463. novacode_cli/server_main.py +64 -0
  464. novacode_cli/server_runner/dev_server.py +530 -0
  465. novacode_cli/server_runner/test_runner.py +470 -0
  466. novacode_cli/session/__init__.py +1 -0
  467. novacode_cli/session/adapters/__init__.py +240 -0
  468. novacode_cli/session/adapters/claude_code.py +102 -0
  469. novacode_cli/session/adapters/codex.py +77 -0
  470. novacode_cli/session/adapters/harness_native.py +75 -0
  471. novacode_cli/session/checkpoint_coordinator.py +448 -0
  472. novacode_cli/session/context_restore.py +25 -0
  473. novacode_cli/session/history_export.py +228 -0
  474. novacode_cli/session/history_tree.py +174 -0
  475. novacode_cli/session/imported_context.py +267 -0
  476. novacode_cli/session/runtime_identity.py +32 -0
  477. novacode_cli/session/session_persistence.py +1168 -0
  478. novacode_cli/session/session_prompt_builder.py +279 -0
  479. novacode_cli/session/session_restore.py +351 -0
  480. novacode_cli/session/session_summarization.py +20 -0
  481. novacode_cli/session/transcript_journal.py +153 -0
  482. novacode_cli/sessions/__init__.py +11 -0
  483. novacode_cli/sessions/gateway.py +84 -0
  484. novacode_cli/sessions/launch.py +51 -0
  485. novacode_cli/sessions/lease.py +213 -0
  486. novacode_cli/sessions/messaging.py +255 -0
  487. novacode_cli/sessions/protocol.py +216 -0
  488. novacode_cli/sessions/resume.py +31 -0
  489. novacode_cli/sessions/supervisor.py +498 -0
  490. novacode_cli/sessions/tasks.py +73 -0
  491. novacode_cli/sessions/tui_messaging.py +413 -0
  492. novacode_cli/sessions/worker.py +652 -0
  493. novacode_cli/sessions/worktree.py +262 -0
  494. novacode_cli/shell/__init__.py +27 -0
  495. novacode_cli/shell/jobs.py +475 -0
  496. novacode_cli/shell/middleware.py +1568 -0
  497. novacode_cli/shell/os_sandbox.py +205 -0
  498. novacode_cli/shell/output.py +181 -0
  499. novacode_cli/shell/patterns.py +275 -0
  500. novacode_cli/shell/processes.py +87 -0
  501. novacode_cli/shell/utils.py +236 -0
  502. novacode_cli/skills/__init__.py +26 -0
  503. novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
  504. novacode_cli/skills/curation_middleware.py +69 -0
  505. novacode_cli/skills/executable.py +161 -0
  506. novacode_cli/skills/libraries.py +102 -0
  507. novacode_cli/skills/load.py +248 -0
  508. novacode_cli/skills/prune.py +215 -0
  509. novacode_cli/skills/refreshing_middleware.py +896 -0
  510. novacode_cli/skills/retrieval.py +248 -0
  511. novacode_cli/skills/runtime.py +118 -0
  512. novacode_cli/skills/schema.py +254 -0
  513. novacode_cli/skills/skill_creation.py +1549 -0
  514. novacode_cli/skills/skill_lock.py +75 -0
  515. novacode_cli/skills/skill_system_prompt.py +16 -0
  516. novacode_cli/skills/skills_prefs.py +126 -0
  517. novacode_cli/skills/tool_resolver.py +31 -0
  518. novacode_cli/skills/upstream.py +50 -0
  519. novacode_cli/skills/versioning.py +291 -0
  520. novacode_cli/states/Session.py +847 -0
  521. novacode_cli/states/__init__.py +31 -0
  522. novacode_cli/states/slices/__init__.py +17 -0
  523. novacode_cli/states/slices/agent_runtime.py +249 -0
  524. novacode_cli/states/slices/background_tasks.py +21 -0
  525. novacode_cli/states/slices/notifications.py +74 -0
  526. novacode_cli/states/slices/remote_bridge.py +56 -0
  527. novacode_cli/states/slices/ui_settings.py +49 -0
  528. novacode_cli/states/slices/wiki.py +25 -0
  529. novacode_cli/teammates/__init__.py +1 -0
  530. novacode_cli/teammates/controller.py +557 -0
  531. novacode_cli/teammates/graph.py +104 -0
  532. novacode_cli/teammates/registry.py +175 -0
  533. novacode_cli/teammates/service.py +254 -0
  534. novacode_cli/teammates/worker.py +19 -0
  535. novacode_cli/teams/__init__.py +1 -0
  536. novacode_cli/teams/adapters/__init__.py +1 -0
  537. novacode_cli/teams/adapters/container.py +316 -0
  538. novacode_cli/teams/adapters/inprocess.py +206 -0
  539. novacode_cli/teams/adapters/process.py +364 -0
  540. novacode_cli/teams/bootstrap.py +246 -0
  541. novacode_cli/teams/container_git.py +67 -0
  542. novacode_cli/teams/context.py +34 -0
  543. novacode_cli/teams/coordinator.py +815 -0
  544. novacode_cli/teams/gateway.py +200 -0
  545. novacode_cli/teams/host.py +64 -0
  546. novacode_cli/teams/legacy.py +52 -0
  547. novacode_cli/teams/middleware.py +176 -0
  548. novacode_cli/teams/models.py +104 -0
  549. novacode_cli/teams/output.py +47 -0
  550. novacode_cli/teams/protocol.py +50 -0
  551. novacode_cli/teams/recovery.py +80 -0
  552. novacode_cli/teams/scheduler.py +44 -0
  553. novacode_cli/teams/store.py +631 -0
  554. novacode_cli/teams/worker.py +251 -0
  555. novacode_cli/teams/workspace.py +100 -0
  556. novacode_cli/token_utils.py +256 -0
  557. novacode_cli/tools/__init__.py +104 -0
  558. novacode_cli/tools/_shared.py +97 -0
  559. novacode_cli/tools/_types.py +24 -0
  560. novacode_cli/tools/artifact_tools.py +137 -0
  561. novacode_cli/tools/code_search_tools.py +330 -0
  562. novacode_cli/tools/computer_tools.py +77 -0
  563. novacode_cli/tools/cron_tools.py +65 -0
  564. novacode_cli/tools/daemon_tool.py +290 -0
  565. novacode_cli/tools/desktop_tools.py +161 -0
  566. novacode_cli/tools/fetch_tools.py +530 -0
  567. novacode_cli/tools/http_tools.py +279 -0
  568. novacode_cli/tools/inspection_tools.py +107 -0
  569. novacode_cli/tools/job_tools.py +143 -0
  570. novacode_cli/tools/knowledge_tools.py +427 -0
  571. novacode_cli/tools/memory_tools.py +461 -0
  572. novacode_cli/tools/memory_wiki_tools.py +40 -0
  573. novacode_cli/tools/notebook_tools.py +238 -0
  574. novacode_cli/tools/oracle_tool.py +83 -0
  575. novacode_cli/tools/plan_archive_tools.py +80 -0
  576. novacode_cli/tools/plan_mode_tools.py +315 -0
  577. novacode_cli/tools/python_kernel_loop.py +165 -0
  578. novacode_cli/tools/python_kernel_tool.py +222 -0
  579. novacode_cli/tools/reflection_tools.py +34 -0
  580. novacode_cli/tools/scraper_tools.py +377 -0
  581. novacode_cli/tools/skill_tools.py +275 -0
  582. novacode_cli/tools/speak_tool.py +51 -0
  583. novacode_cli/tools/store_memory_tools.py +227 -0
  584. novacode_cli/tools/ui_tools.py +62 -0
  585. novacode_cli/tools/waiter_tools.py +408 -0
  586. novacode_cli/tools/web_tools.py +349 -0
  587. novacode_cli/tools/wiki_tools.py +152 -0
  588. novacode_cli/tools/worktree_tools.py +219 -0
  589. novacode_cli/tracking/__init__.py +10 -0
  590. novacode_cli/tracking/file_tracker.py +826 -0
  591. novacode_cli/tracking/local_metrics.py +170 -0
  592. novacode_cli/tracking/loop_guard.py +507 -0
  593. novacode_cli/tracking/request_metrics.py +60 -0
  594. novacode_cli/tracking/tracing.py +692 -0
  595. novacode_cli/tracking/usage_tree.py +677 -0
  596. novacode_cli/tracking/workspace_anchoring.py +231 -0
  597. novacode_cli/tui/__init__.py +11 -0
  598. novacode_cli/tui/animation_rate.py +9 -0
  599. novacode_cli/tui/animations.py +332 -0
  600. novacode_cli/tui/app.py +19315 -0
  601. novacode_cli/tui/auth_display.py +87 -0
  602. novacode_cli/tui/auth_screens.py +826 -0
  603. novacode_cli/tui/avatar.py +294 -0
  604. novacode_cli/tui/avatar_frames.py +21 -0
  605. novacode_cli/tui/background_tasks.py +35 -0
  606. novacode_cli/tui/browser_settings_screen.py +70 -0
  607. novacode_cli/tui/caveman_screen.py +185 -0
  608. novacode_cli/tui/council_settings_screen.py +182 -0
  609. novacode_cli/tui/council_view.py +535 -0
  610. novacode_cli/tui/decision_settings.py +268 -0
  611. novacode_cli/tui/diff_preview.py +199 -0
  612. novacode_cli/tui/gc_tuning.py +63 -0
  613. novacode_cli/tui/handoff_screen.py +206 -0
  614. novacode_cli/tui/harness.py +244 -0
  615. novacode_cli/tui/history_tree_screen.py +179 -0
  616. novacode_cli/tui/home.py +328 -0
  617. novacode_cli/tui/home_scene.py +94 -0
  618. novacode_cli/tui/icons.py +81 -0
  619. novacode_cli/tui/interface_styles.tcss +327 -0
  620. novacode_cli/tui/matrix3.py +108 -0
  621. novacode_cli/tui/mcp_tools_screen.py +131 -0
  622. novacode_cli/tui/motion.py +178 -0
  623. novacode_cli/tui/output_body.py +136 -0
  624. novacode_cli/tui/output_buffer.py +59 -0
  625. novacode_cli/tui/palette.py +334 -0
  626. novacode_cli/tui/pickers.py +237 -0
  627. novacode_cli/tui/prime_panel.py +506 -0
  628. novacode_cli/tui/provider_signin.py +213 -0
  629. novacode_cli/tui/pty_shell.py +159 -0
  630. novacode_cli/tui/reference_screens.py +444 -0
  631. novacode_cli/tui/resume_picker.py +346 -0
  632. novacode_cli/tui/rooms.py +597 -0
  633. novacode_cli/tui/screens.py +7088 -0
  634. novacode_cli/tui/session_import.py +333 -0
  635. novacode_cli/tui/session_pane.py +242 -0
  636. novacode_cli/tui/session_tab.py +42 -0
  637. novacode_cli/tui/session_transcript.py +118 -0
  638. novacode_cli/tui/shell_output.py +7 -0
  639. novacode_cli/tui/stall_watch.py +174 -0
  640. novacode_cli/tui/statusline.py +40 -0
  641. novacode_cli/tui/statusline_screen.py +91 -0
  642. novacode_cli/tui/teammate_updates.py +162 -0
  643. novacode_cli/tui/teammates.py +548 -0
  644. novacode_cli/tui/terminal.py +289 -0
  645. novacode_cli/tui/terminal_view.py +235 -0
  646. novacode_cli/tui/themes.py +118 -0
  647. novacode_cli/tui/update_notice.py +84 -0
  648. novacode_cli/tui/voice_downloads.py +113 -0
  649. novacode_cli/tui/widgets.py +2038 -0
  650. novacode_cli/tui/windows_input.py +38 -0
  651. novacode_cli/tui/workspace_approval.py +165 -0
  652. novacode_cli/ui/council_web.py +113 -0
  653. novacode_cli/ui/council_workspace.py +72 -0
  654. novacode_cli/ui/cowork_web.py +41 -0
  655. novacode_cli/ui/diff_highlight.py +160 -0
  656. novacode_cli/ui/execution.py +1396 -0
  657. novacode_cli/ui/exit_summary.py +65 -0
  658. novacode_cli/ui/hitl_approval.py +196 -0
  659. novacode_cli/ui/input_preparation.py +2 -0
  660. novacode_cli/ui/interrupt_handlers.py +215 -0
  661. novacode_cli/ui/question_prompt.py +151 -0
  662. novacode_cli/ui/status_phrases.py +157 -0
  663. novacode_cli/ui/stream_preview.py +66 -0
  664. novacode_cli/ui/streaming.py +2 -0
  665. novacode_cli/ui/subagent_tracking.py +2 -0
  666. novacode_cli/ui/ui_elements.py +1585 -0
  667. novacode_cli/ui/web_workspace.py +244 -0
  668. novacode_cli/ui_events.py +299 -0
  669. novacode_cli/update_ui.py +243 -0
  670. novacode_cli/updates.py +489 -0
  671. novacode_cli/utils/backend_patches.py +399 -0
  672. novacode_cli/utils/backend_paths.py +467 -0
  673. novacode_cli/utils/lazy_providers.py +51 -0
  674. novacode_cli/utils/model_info.py +223 -0
  675. novacode_cli/utils/pdf_extraction.py +183 -0
  676. novacode_cli/utils/startup.py +29 -0
  677. novacode_cli/utils/tasks.py +42 -0
  678. novacode_cli/utils/windows.py +9 -0
  679. novacode_cli/vixie/hook_adapter.py +127 -0
  680. novacode_cli/vixie/server.py +375 -0
  681. novacode_cli/wiki/__init__.py +15 -0
  682. novacode_cli/wiki/ask.py +56 -0
  683. novacode_cli/wiki/file.py +71 -0
  684. novacode_cli/wiki/ingest.py +184 -0
  685. novacode_cli/wiki/manager.py +261 -0
  686. novacode_cli-1.2.0.dist-info/METADATA +271 -0
  687. novacode_cli-1.2.0.dist-info/RECORD +691 -0
  688. novacode_cli-1.2.0.dist-info/WHEEL +5 -0
  689. novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
  690. novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
  691. novacode_cli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,826 @@
1
+ """Screens for managing stored provider and service credentials (``/auth``).
2
+
3
+ `AuthManagerScreen` lists every credential Nova can store, with a badge showing
4
+ where each one stands, and routes the user into `AuthPromptScreen`. The prompt
5
+ is the only place a credential is written, and the only place one is deleted
6
+ (after a confirmation) — the manager never writes, so there is one path to
7
+ audit.
8
+
9
+ Security notes:
10
+
11
+ - The key field is rendered with ``password=True`` so the value is never echoed
12
+ to the terminal.
13
+ - The key value is never logged, never included in a notification, and never
14
+ interpolated into an error message. Only the credential's *name* (its
15
+ environment variable) is ever shown.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import os
23
+ from typing import TYPE_CHECKING, ClassVar
24
+ from urllib.parse import urlsplit
25
+
26
+ from rich.text import Text
27
+ from textual import work
28
+ from textual.containers import Horizontal, Vertical
29
+ from textual.screen import ModalScreen
30
+ from textual.widgets import Button, Input, OptionList, Static, Tab, Tabs
31
+ from textual.widgets.option_list import Option
32
+
33
+ from novacode_cli.config.credentials import (
34
+ CredentialMeta,
35
+ CredentialStore,
36
+ delete_credential,
37
+ has_stored_credential,
38
+ set_credential,
39
+ )
40
+ from novacode_cli.config.provider_auth import (
41
+ ProviderAuthSource,
42
+ ProviderAuthState,
43
+ ProviderAuthStatus,
44
+ credential_env_var,
45
+ credential_names,
46
+ credential_section,
47
+ get_all_auth_statuses,
48
+ is_service,
49
+ provider_display_name,
50
+ )
51
+ from novacode_cli.tui.animations import animate_modal_screen
52
+ from novacode_cli.tui.auth_display import format_auth_badge
53
+ from novacode_cli.tui.screens import ConfirmModal
54
+
55
+ if TYPE_CHECKING:
56
+ from textual.app import ComposeResult
57
+ from textual.binding import BindingType
58
+
59
+
60
+ #: What a *service* credential unlocks, for the rows that need to say it. Only a
61
+ #: service that gates a tool earns an entry: the voice credentials already name
62
+ #: their own axis in the display name ("Deepgram (speech to text)"), so reusing
63
+ #: Tavily's "gates web search" note for them was simply wrong.
64
+ _SERVICE_NOTES: dict[str, str] = {
65
+ "tavily": "gates web search",
66
+ "exa": "web search; connect its preset in /mcp",
67
+ "firecrawl": "web crawling; connect its preset in /mcp",
68
+ "parallel": "web research; connect its preset in /mcp",
69
+ "tinyfish": "browser automation; connect its preset in /mcp",
70
+ "jev": "decision-based tool pruning",
71
+ "systemone": "custom endpoint credential",
72
+ }
73
+
74
+
75
+ def _endpoint_providers() -> frozenset[str]:
76
+ """Return the providers that accept a custom endpoint.
77
+
78
+ Imported lazily so this module does not pull `model_manager` (and through it
79
+ the LangChain model classes) just to answer a gating question.
80
+ """
81
+ from novacode_cli.config.model_manager import ENDPOINT_PROVIDERS
82
+
83
+ return ENDPOINT_PROVIDERS
84
+
85
+
86
+ def _is_http_url(value: str) -> bool:
87
+ """Whether *value* looks like an http(s) URL.
88
+
89
+ Args:
90
+ value: Candidate endpoint URL.
91
+
92
+ Returns:
93
+ True when the scheme is http or https and a host is present.
94
+ """
95
+ try:
96
+ parts = urlsplit(value)
97
+ except ValueError:
98
+ return False
99
+ return parts.scheme in {"http", "https"} and bool(parts.netloc)
100
+
101
+
102
+ class AuthManagerScreen(ModalScreen[None]):
103
+ """List stored credentials and route the user into the key prompt.
104
+
105
+ Always dismisses with ``None``; the caller reads :attr:`saved` and
106
+ :attr:`deleted` afterwards to report what changed. State lives in the
107
+ credential store, so the list is re-read rather than patched after any
108
+ change.
109
+
110
+ Credential reads are filesystem/keychain work and run in a worker thread:
111
+ a status pass touches the OS credential store once per provider, which is
112
+ not something to do inside `compose`.
113
+ """
114
+
115
+ BINDINGS: ClassVar[list[BindingType]] = [
116
+ ("escape", "close", "Close"),
117
+ ("ctrl+d", "delete", "Delete the stored key"),
118
+ ]
119
+
120
+ def __init__(self, *, focus: str | None = None) -> None:
121
+ """Initialize the manager.
122
+
123
+ Args:
124
+ focus: Credential name whose row should be highlighted once the
125
+ list is built — used when reopening so the cursor lands on the
126
+ row the user just worked on instead of resetting to the top.
127
+ """
128
+ super().__init__()
129
+ self._focus = focus
130
+ self._section = credential_section(focus) if focus else "llms"
131
+ self._filter = ""
132
+ self._names: list[str] = []
133
+ self._statuses: dict[str, ProviderAuthStatus] = {}
134
+ self._metas: dict[str, CredentialMeta] = {}
135
+ #: Credential names saved or deleted while this screen was open, in the
136
+ #: order they happened. The caller reports them; the screen does not.
137
+ self.saved: list[str] = []
138
+ self.deleted: list[str] = []
139
+ self.model_settings_changed: list[str] = []
140
+ self.warnings: list[str] = []
141
+
142
+ def compose(self) -> ComposeResult:
143
+ """Compose the manager list and its buttons."""
144
+ with Vertical(id="modal-box"):
145
+ yield Static(Text("Manage credentials", style="bold"), id="modal-title")
146
+ yield Static(
147
+ Text(
148
+ "Stored in your OS credential store, so they survive a restart "
149
+ "and apply to every Nova session.\n"
150
+ "Providers that need no key (Ollama) are not listed.",
151
+ style="dim",
152
+ ),
153
+ id="auth-desc",
154
+ )
155
+ yield Tabs(
156
+ Tab("LLM Providers", id="llms"),
157
+ Tab("Tools", id="tools"),
158
+ Tab("Decisions", id="decisions"),
159
+ Tab("Voice", id="voice"),
160
+ active=self._section, id="auth-sections",
161
+ )
162
+ yield Input(placeholder="Search providers or credentials…", id="auth-filter")
163
+ yield OptionList(id="auth-list")
164
+ yield Static("", id="auth-hint")
165
+ with Horizontal(id="modal-buttons"):
166
+ yield Button("Add / Replace", id="edit", variant="primary")
167
+ yield Button("Delete", id="delete", variant="error")
168
+ yield Button("Close", id="close")
169
+
170
+ def on_mount(self) -> None:
171
+ """Animate in and load the credential list."""
172
+ animate_modal_screen(self)
173
+ self.reload()
174
+
175
+ @work(thread=True, group="auth_reload", exclusive=True)
176
+ def reload(self) -> None:
177
+ """Re-read credentials and repaint, off the UI thread."""
178
+ try:
179
+ statuses = get_all_auth_statuses(include_providers_without_keys=True)
180
+ except Exception: # noqa: BLE001 — a failed pass must still render a list
181
+ statuses = {}
182
+ # Suppressed, not handled: the screen is dismissed before the background
183
+ # pass finishes whenever the user closes the manager quickly, and there
184
+ # is nothing left to repaint by then.
185
+ with contextlib.suppress(Exception):
186
+ self.app.call_from_thread(self._paint, statuses)
187
+
188
+ def _paint(self, statuses: dict[str, ProviderAuthStatus]) -> None:
189
+ """Rebuild the option list from *statuses*.
190
+
191
+ Named `_paint`, not `_render`: `Widget._render` is part of Textual's own
192
+ render pipeline (it returns a Visual) and shadowing it made the screen
193
+ raise on every frame.
194
+
195
+ Args:
196
+ statuses: Readiness per provider/service id.
197
+ """
198
+ self._statuses = statuses
199
+ names = [
200
+ name for name in (*credential_names(), "google-vertex")
201
+ if name in statuses and credential_section(name) == self._section
202
+ and self._filter.casefold() in (
203
+ f"{name} {provider_display_name(name)} {credential_env_var(name)}".casefold()
204
+ )
205
+ ]
206
+ self._names = names
207
+
208
+ self._metas = CredentialStore().list_meta(
209
+ env_var for env_var in (credential_env_var(name) for name in names) if env_var
210
+ )
211
+
212
+ option_list = self.query_one("#auth-list", OptionList)
213
+ option_list.clear_options()
214
+ for name in names:
215
+ option_list.add_option(Option(self._row_label(name, statuses[name]), id=name))
216
+
217
+ if not names:
218
+ message = (
219
+ "No credentials match your search." if self._filter
220
+ else "No credentials are available in this section."
221
+ )
222
+ option_list.add_option(Option(message, id="__none__"))
223
+
224
+ if self._focus and self._focus in names:
225
+ option_list.highlighted = names.index(self._focus)
226
+ option_list.focus()
227
+ self._update_hint()
228
+
229
+ def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
230
+ """Keep tools, decisions, voice, and model credentials in their own sections."""
231
+ if event.tabs.id == "auth-sections" and event.tab.id and event.tab.id != self._section:
232
+ self._section = event.tab.id
233
+ self._focus = None
234
+ self._paint(self._statuses)
235
+
236
+ def on_input_changed(self, event: Input.Changed) -> None:
237
+ """Filter the selected section without changing credentials or starting work."""
238
+ if event.input.id == "auth-filter" and event.value != self._filter:
239
+ self._filter = event.value
240
+ self._paint(self._statuses)
241
+ event.input.focus()
242
+
243
+ def _row_label(self, name: str, status: ProviderAuthStatus) -> Text:
244
+ """Build one row: display name, badge, and a short status detail.
245
+
246
+ Args:
247
+ name: Provider or service id.
248
+ status: Its readiness status.
249
+
250
+ Returns:
251
+ The composed row text.
252
+ """
253
+ display = provider_display_name(name)
254
+ text = Text()
255
+ text.append(display, style="bold")
256
+ text.append(" ")
257
+ text.append_text(format_auth_badge(status))
258
+ detail = self._row_detail(name, status)
259
+ if detail:
260
+ text.append(f" {detail}", style="dim")
261
+ return text
262
+
263
+ def _row_detail(self, name: str, status: ProviderAuthStatus) -> str:
264
+ """Return the trailing description shown on a row.
265
+
266
+ Args:
267
+ name: Provider or service id.
268
+ status: Its readiness status.
269
+
270
+ Returns:
271
+ A short detail string, or an empty string when the badge says enough.
272
+ """
273
+ if status.state is ProviderAuthState.CONFIGURED:
274
+ env_var = credential_env_var(name)
275
+ meta = self._metas.get(env_var) if env_var else None
276
+ parts = []
277
+ if meta is not None and meta.base_url:
278
+ parts.append(meta.base_url)
279
+ if is_service(name):
280
+ note = _SERVICE_NOTES.get(name)
281
+ if note:
282
+ parts.append(note)
283
+ if status.detail in {"ChatGPT plan sign-in", "Google OAuth sign-in"}:
284
+ parts.append(status.detail)
285
+ return " · ".join(parts)
286
+ return status.detail or ""
287
+
288
+ def _highlighted(self) -> str | None:
289
+ """Return the highlighted credential name, if it is a real row."""
290
+ option_list = self.query_one("#auth-list", OptionList)
291
+ index = option_list.highlighted
292
+ if index is None or not (0 <= index < len(self._names)):
293
+ return None
294
+ return self._names[index]
295
+
296
+ def _update_hint(self) -> None:
297
+ """Refresh the footer for the highlighted row."""
298
+ name = self._highlighted()
299
+ hint = self.query_one("#auth-hint", Static)
300
+ managed = name is None or credential_env_var(name) is None
301
+ self.query_one("#edit", Button).disabled = managed
302
+ self.query_one("#delete", Button).disabled = managed
303
+ if name is None:
304
+ hint.update(Text("Esc close", style="dim"))
305
+ return
306
+ if credential_env_var(name) is None:
307
+ hint.update(Text(
308
+ "Configure Google Cloud Application Default Credentials outside Nova."
309
+ " · Esc close",
310
+ style="dim",
311
+ ))
312
+ return
313
+ action = "replace" if self._stored(name) else "add"
314
+ deletable = " · Ctrl+D delete" if self._stored(name) else ""
315
+ hint.update(Text(f"Enter {action} the key{deletable} · Esc close", style="dim"))
316
+
317
+ def _stored(self, name: str) -> bool:
318
+ """Whether a credential for *name* came from the credential store.
319
+
320
+ Args:
321
+ name: Provider or service id.
322
+
323
+ Returns:
324
+ True when `/auth` can replace or delete it.
325
+ """
326
+ status = self._statuses.get(name)
327
+ return status is not None and status.source is ProviderAuthSource.STORED
328
+
329
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
330
+ """Open the prompt for the selected credential."""
331
+ if event.option_list.id == "auth-list":
332
+ self.open_prompt()
333
+
334
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
335
+ """Update the footer when the highlight moves."""
336
+ if event.option_list.id == "auth-list":
337
+ self._update_hint()
338
+
339
+ def on_button_pressed(self, event: Button.Pressed) -> None:
340
+ """Route the manager's buttons."""
341
+ if event.button.id == "close":
342
+ self.dismiss(None)
343
+ elif event.button.id == "edit":
344
+ self.open_prompt()
345
+ elif event.button.id == "delete":
346
+ self.remove_selected()
347
+
348
+ @work(group="auth_action", exclusive=True)
349
+ async def open_prompt(self) -> None:
350
+ """Push the key prompt for the highlighted credential and reload."""
351
+ name = self._highlighted()
352
+ if name is None:
353
+ return
354
+ env_var = credential_env_var(name)
355
+ if env_var is None:
356
+ return
357
+ if name in {"google", "anthropic", "opencode", "opencode_zen", "commandcode"}:
358
+ from novacode_cli.tui.provider_signin import (
359
+ GoogleSignInScreen,
360
+ ProviderSignInChoice,
361
+ open_anthropic_console,
362
+ open_commandcode_studio,
363
+ open_opencode_console,
364
+ )
365
+ choice = await self.app.push_screen_wait(ProviderSignInChoice(name))
366
+ if choice == "zdr":
367
+ from novacode_cli.config.nova_config import NovaConfig, _config_bool
368
+
369
+ config = NovaConfig()
370
+ on = not _config_bool(config.get("commandcode_zdr", False))
371
+ config.set("commandcode_zdr", on)
372
+ self.model_settings_changed.append(name)
373
+ self.app.notify(
374
+ f"Command Code zero data retention {'on' if on else 'off'}. "
375
+ "Close /auth to refresh the model."
376
+ )
377
+ return
378
+ if name == "google":
379
+ from novacode_cli.config import google_oauth_auth as google
380
+ if choice == "signin":
381
+ if await self.app.push_screen_wait(GoogleSignInScreen()):
382
+ self.saved.append(name)
383
+ self._focus = name
384
+ self.reload()
385
+ self.app.notify("Gemini Google sign-in connected.")
386
+ return
387
+ if choice == "use":
388
+ google.set_auth_mode("oauth")
389
+ self.saved.append(name)
390
+ self.reload()
391
+ return
392
+ if choice == "remove":
393
+ confirmed = await self.app.push_screen_wait(ConfirmModal(
394
+ "Disconnect Google sign-in?",
395
+ Text("Removes Nova's stored Google tokens. Your API key is retained."),
396
+ ))
397
+ if confirmed and await asyncio.to_thread(google.delete_credentials):
398
+ google.set_auth_mode("auto")
399
+ self.deleted.append(name)
400
+ self.reload()
401
+ return
402
+ if choice == "api_key" and (
403
+ CredentialStore().get_key(env_var) or os.environ.get(env_var)
404
+ or os.environ.get("GEMINI_API_KEY")
405
+ ):
406
+ google.set_auth_mode("api_key")
407
+ self.saved.append(name)
408
+ self.reload()
409
+ return
410
+ elif choice == "console":
411
+ if name in {"opencode", "opencode_zen"}:
412
+ await open_opencode_console()
413
+ elif name == "commandcode":
414
+ await open_commandcode_studio()
415
+ else:
416
+ await open_anthropic_console()
417
+ if choice not in {"api_key", "replace_key", "console"}:
418
+ return
419
+ if name == "openai":
420
+ choice = await self.app.push_screen_wait(OpenAIAuthChoiceScreen())
421
+ if choice == "chatgpt":
422
+ try:
423
+ from novacode_cli.config.openai_chatgpt_auth import sign_in
424
+
425
+ await asyncio.to_thread(sign_in)
426
+ from novacode_cli.config.openai_chatgpt_auth import set_auth_mode
427
+
428
+ set_auth_mode("chatgpt")
429
+ except Exception as exc: # noqa: BLE001 — keep token details out of UI
430
+ self.app.notify(str(exc), severity="error", markup=False)
431
+ return
432
+ self.saved.append(name)
433
+ self._focus = name
434
+ self.reload()
435
+ self.app.notify("OpenAI ChatGPT sign-in connected.", severity="information")
436
+ return
437
+ if choice == "use_chatgpt":
438
+ from novacode_cli.config.openai_chatgpt_auth import set_auth_mode
439
+
440
+ set_auth_mode("chatgpt")
441
+ self.saved.append(name)
442
+ self._focus = name
443
+ self.reload()
444
+ self.app.notify("OpenAI ChatGPT sign-in selected.", severity="information")
445
+ return
446
+ if choice == "remove_chatgpt":
447
+ from novacode_cli.config.openai_chatgpt_auth import (
448
+ delete_credentials,
449
+ load_credentials,
450
+ )
451
+
452
+ record = load_credentials()
453
+ if record:
454
+ email = record.get("email") or "this ChatGPT account"
455
+ confirmed = await self.app.push_screen_wait(
456
+ ConfirmModal(
457
+ "Remove the ChatGPT sign-in?",
458
+ Text(
459
+ f"This removes Nova's stored access for {email} "
460
+ "from the OS credential store."
461
+ ),
462
+ )
463
+ )
464
+ if confirmed and delete_credentials():
465
+ from novacode_cli.config.openai_chatgpt_auth import (
466
+ selected_auth_mode,
467
+ set_auth_mode,
468
+ )
469
+
470
+ if selected_auth_mode() == "chatgpt":
471
+ set_auth_mode("auto")
472
+ self.deleted.append(name)
473
+ self.reload()
474
+ return
475
+ if choice == "api_key":
476
+ from novacode_cli.config.openai_chatgpt_auth import set_auth_mode
477
+
478
+ if CredentialStore().get_key(env_var) or os.environ.get(env_var):
479
+ set_auth_mode("api_key")
480
+ self.saved.append(name)
481
+ self._focus = name
482
+ self.reload()
483
+ self.app.notify("OpenAI API-key auth selected.", severity="information")
484
+ return
485
+ elif choice != "replace_key":
486
+ return
487
+ prompt = AuthPromptScreen(name, existing=self._metas.get(env_var))
488
+ result = await self.app.push_screen_wait(prompt)
489
+ if prompt.warnings:
490
+ self.warnings.extend(prompt.warnings)
491
+ for warning in prompt.warnings:
492
+ # markup=False: the text interpolates an env var name and a path
493
+ # step that may contain characters Textual's markup reads as tags.
494
+ self.app.notify(warning, severity="warning", markup=False)
495
+ if result:
496
+ if name == "google":
497
+ from novacode_cli.config.google_oauth_auth import set_auth_mode
498
+ set_auth_mode("api_key")
499
+ if name == "openai":
500
+ from novacode_cli.config.openai_chatgpt_auth import set_auth_mode
501
+
502
+ set_auth_mode("api_key")
503
+ self.saved.append(name)
504
+ self._focus = name
505
+ self.reload()
506
+
507
+ @work(group="auth_action", exclusive=True)
508
+ async def remove_selected(self) -> None:
509
+ """Confirm, then delete the highlighted credential."""
510
+ name = self._highlighted()
511
+ if name is None:
512
+ return
513
+ env_var = credential_env_var(name)
514
+ if env_var is None:
515
+ return
516
+ if name == "openai" and not has_stored_credential(env_var):
517
+ from novacode_cli.config.openai_chatgpt_auth import (
518
+ delete_credentials,
519
+ load_credentials,
520
+ )
521
+
522
+ record = load_credentials()
523
+ if record:
524
+ email = record.get("email") or "this ChatGPT account"
525
+ confirmed = await self.app.push_screen_wait(
526
+ ConfirmModal(
527
+ "Remove the ChatGPT sign-in?",
528
+ Text(
529
+ f"This removes Nova's stored access for {email} "
530
+ "from the OS credential store."
531
+ ),
532
+ )
533
+ )
534
+ if confirmed and delete_credentials():
535
+ from novacode_cli.config.openai_chatgpt_auth import (
536
+ selected_auth_mode,
537
+ set_auth_mode,
538
+ )
539
+
540
+ if selected_auth_mode() == "chatgpt":
541
+ set_auth_mode("auto")
542
+ self.deleted.append(name)
543
+ self._focus = name
544
+ self.reload()
545
+ return
546
+ if not self._stored(name) or not has_stored_credential(env_var):
547
+ # Deleting an environment-supplied key is not something `/auth` can
548
+ # do, and pretending otherwise would leave the user thinking it is
549
+ # gone. Say so instead.
550
+ self.app.notify(
551
+ f"{self._statuses[name].env_var or env_var} is not stored here "
552
+ "— it comes from your environment. "
553
+ f"Unset it in your shell or .env instead.",
554
+ severity="warning",
555
+ markup=False,
556
+ )
557
+ return
558
+ confirmed = await self.app.push_screen_wait(
559
+ ConfirmModal(
560
+ f"Delete the stored key for {provider_display_name(name)}?",
561
+ Text(f"This removes {env_var} from your credential store. It cannot be undone."),
562
+ )
563
+ )
564
+ if not confirmed:
565
+ return
566
+ if delete_credential(env_var):
567
+ self.deleted.append(name)
568
+ else:
569
+ self.app.notify(
570
+ f"Could not delete the credential for {env_var}.",
571
+ severity="error",
572
+ markup=False,
573
+ )
574
+ self._focus = name
575
+ self.reload()
576
+
577
+ def action_close(self) -> None:
578
+ """Dismiss the manager."""
579
+ self.dismiss(None)
580
+
581
+ def action_delete(self) -> None:
582
+ """Delete the highlighted credential, after confirmation."""
583
+ self.remove_selected()
584
+
585
+
586
+ class OpenAIAuthChoiceScreen(ModalScreen[str | None]):
587
+ """Choose an API key or Sign in with ChatGPT for OpenAI."""
588
+
589
+ def compose(self) -> ComposeResult:
590
+ from novacode_cli.config.openai_chatgpt_auth import (
591
+ load_credentials,
592
+ selected_auth_mode,
593
+ )
594
+
595
+ record = load_credentials()
596
+ mode = selected_auth_mode()
597
+ mode_label = {"chatgpt": "ChatGPT", "api_key": "API key"}.get(mode, "automatic")
598
+ details = [f"Active auth: {mode_label}"]
599
+ if record:
600
+ details.insert(0, f"Connected: {record.get('email') or 'ChatGPT account'}")
601
+ detail = "\n" + "\n".join(details)
602
+ with Vertical(id="modal-box"):
603
+ yield Static(Text("Connect OpenAI", style="bold"), id="modal-title")
604
+ yield Static(
605
+ Text(
606
+ "Select which OpenAI credential Nova should use. API keys use "
607
+ "OpenAI API billing; ChatGPT sign-in authorizes eligible Responses API usage."
608
+ "\nChatGPT sign-in has preview limits: some hosted tools are unavailable."
609
+ + detail,
610
+ style="dim",
611
+ ),
612
+ id="auth-desc",
613
+ )
614
+ with Horizontal(id="modal-buttons"):
615
+ yield Button("Use API key", id="api-key", variant="primary")
616
+ yield Button("Add / replace API key", id="replace-key")
617
+ yield Button("Continue with ChatGPT", id="chatgpt")
618
+ if record:
619
+ yield Button("Use saved ChatGPT account", id="use-chatgpt")
620
+ yield Button("Remove ChatGPT sign-in", id="remove-chatgpt", variant="error")
621
+ yield Button("Cancel", id="cancel")
622
+
623
+ def on_button_pressed(self, event: Button.Pressed) -> None:
624
+ choices = {
625
+ "api-key": "api_key",
626
+ "replace-key": "replace_key",
627
+ "chatgpt": "chatgpt",
628
+ "use-chatgpt": "use_chatgpt",
629
+ "remove-chatgpt": "remove_chatgpt",
630
+ }
631
+ self.dismiss(choices.get(event.button.id))
632
+
633
+
634
+ class AuthPromptScreen(ModalScreen[str | None]):
635
+ """Accept (or delete) the credential for one provider or service.
636
+
637
+ Dismisses with the credential name when it was saved, or ``None`` when the
638
+ user cancelled. Warnings from the write are left on :attr:`warnings` for the
639
+ caller to surface: a keychain write that failed partially is the one signal
640
+ the user must not miss, and a toast raised from inside a dismissing screen
641
+ competes with its own teardown.
642
+ """
643
+
644
+ BINDINGS: ClassVar[list[BindingType]] = [
645
+ ("escape", "cancel", "Cancel"),
646
+ ("f2", "toggle_advanced", "Advanced"),
647
+ ("ctrl+d", "delete", "Delete key"),
648
+ ]
649
+
650
+ def __init__(self, name: str, *, existing: CredentialMeta | None = None) -> None:
651
+ """Initialize the prompt.
652
+
653
+ Args:
654
+ name: Provider or service id whose credential is being set.
655
+ existing: Metadata already recorded for this credential, used to
656
+ prefill the endpoint so a rotation does not silently drop it.
657
+ """
658
+ super().__init__()
659
+ self._name = name
660
+ self._existing = existing
661
+ # Azure has no default endpoint, so its field starts open.
662
+ self._advanced = name == "azure"
663
+ self._warnings: list[str] = []
664
+
665
+ @property
666
+ def warnings(self) -> list[str]:
667
+ """Warnings produced by a save that nonetheless stored the key."""
668
+ return list(self._warnings)
669
+
670
+ @property
671
+ def _env_var(self) -> str | None:
672
+ """The environment variable this credential is filed under."""
673
+ return credential_env_var(self._name)
674
+
675
+ @property
676
+ def _supports_endpoint(self) -> bool:
677
+ """Whether this credential's provider accepts a custom endpoint."""
678
+ return self._name in _endpoint_providers()
679
+
680
+ def compose(self) -> ComposeResult:
681
+ """Compose the key prompt, with the advanced panel hidden."""
682
+ env_var = self._env_var or self._name
683
+ with Vertical(id="modal-box"):
684
+ yield Static(
685
+ Text(f"{provider_display_name(self._name)} API key", style="bold"),
686
+ id="modal-title",
687
+ )
688
+ yield Static(
689
+ Text(
690
+ f"Paste the key below. It is stored in your OS credential store as "
691
+ f"{env_var} and applied to this session immediately."
692
+ + (
693
+ "\nConfigure decision models in /model → Decisions."
694
+ if self._name in {"jev", "systemone"}
695
+ else ""
696
+ ),
697
+ style="dim",
698
+ ),
699
+ id="auth-desc",
700
+ )
701
+ yield Input(placeholder="API key (input is hidden)", password=True, id="auth-key")
702
+ yield Static(
703
+ Text(
704
+ "Enter save · F2 advanced · Ctrl+D delete stored key · Esc cancel",
705
+ style="dim",
706
+ ),
707
+ id="auth-hint",
708
+ )
709
+ yield Static("", id="auth-error")
710
+ yield Static(
711
+ Text(
712
+ "Endpoint URL (your Azure resource)"
713
+ if self._name == "azure"
714
+ else "Endpoint URL (blank = provider default)",
715
+ style="bold",
716
+ ),
717
+ id="auth-endpoint-label",
718
+ )
719
+ yield Input(
720
+ placeholder=(
721
+ "https://<resource>.openai.azure.com" if self._name == "azure" else "https://…"
722
+ ),
723
+ id="auth-endpoint",
724
+ )
725
+
726
+ def on_mount(self) -> None:
727
+ """Hide the advanced panel and focus the key field."""
728
+ animate_modal_screen(self)
729
+ self._apply_advanced_visibility()
730
+ if self._advanced and self._existing is not None and self._existing.base_url:
731
+ self.query_one("#auth-endpoint", Input).value = self._existing.base_url
732
+ self.query_one("#auth-key", Input).focus()
733
+
734
+ def _apply_advanced_visibility(self) -> None:
735
+ """Show the endpoint field only when advanced is open and it applies."""
736
+ show = self._advanced and self._supports_endpoint
737
+ self.query_one("#auth-endpoint-label", Static).display = show
738
+ self.query_one("#auth-endpoint", Input).display = show
739
+
740
+ def action_toggle_advanced(self) -> None:
741
+ """Show or hide the endpoint field."""
742
+ self._advanced = not self._advanced
743
+ self._apply_advanced_visibility()
744
+ if self._advanced and self._supports_endpoint:
745
+ endpoint = self.query_one("#auth-endpoint", Input)
746
+ if not endpoint.value and self._existing is not None and self._existing.base_url:
747
+ # Prefill the stored endpoint so it is visible and editable
748
+ # rather than silently still in effect.
749
+ endpoint.value = self._existing.base_url
750
+ endpoint.focus()
751
+ else:
752
+ self.query_one("#auth-key", Input).focus()
753
+
754
+ def on_input_submitted(self, event: Input.Submitted) -> None:
755
+ """Save on Enter from either field."""
756
+ event.stop()
757
+ self._save()
758
+
759
+ def on_button_pressed(self, event: Button.Pressed) -> None:
760
+ """Route the prompt's buttons."""
761
+ if event.button.id == "cancel":
762
+ self.dismiss(None)
763
+ else:
764
+ self._save()
765
+
766
+ def _show_error(self, message: str) -> None:
767
+ """Render an error under the key field."""
768
+ self.query_one("#auth-error", Static).update(Text(message, style="red"))
769
+
770
+ def _save(self) -> None:
771
+ """Validate and persist the credential."""
772
+ env_var = self._env_var
773
+ if env_var is None:
774
+ self._show_error("This credential cannot be stored.")
775
+ return
776
+
777
+ key = self.query_one("#auth-key", Input).value.strip()
778
+ if not key:
779
+ self._show_error("API key cannot be empty.")
780
+ return
781
+
782
+ base_url = ""
783
+ if self._supports_endpoint:
784
+ base_url = self.query_one("#auth-endpoint", Input).value.strip()
785
+ existing_url = (self._existing.base_url if self._existing else "") or ""
786
+ # Validate only a *changed* value. The field is prefilled with the
787
+ # stored endpoint, so a legacy non-http(s) value must not block
788
+ # rotating just the key.
789
+ if base_url and base_url != existing_url and not _is_http_url(base_url):
790
+ self._show_error("Endpoint URL must be an http(s) URL.")
791
+ return
792
+
793
+ outcome = set_credential(env_var, key, base_url=base_url or None)
794
+ if not outcome.ok:
795
+ # The store reported the write failed; the message names the env var
796
+ # only, never the value.
797
+ self._warnings.extend(outcome.warnings)
798
+ self._show_error(outcome.warnings[0] if outcome.warnings else "Could not save.")
799
+ return
800
+
801
+ self._warnings.extend(outcome.warnings)
802
+ self.dismiss(self._name)
803
+
804
+ @work(exclusive=True)
805
+ async def action_delete(self) -> None:
806
+ """Delete the stored credential after a confirmation."""
807
+ env_var = self._env_var
808
+ if env_var is None:
809
+ return
810
+ if not has_stored_credential(env_var):
811
+ self._show_error("No stored key to delete for this credential.")
812
+ return
813
+ confirmed = await self.app.push_screen_wait(
814
+ ConfirmModal(
815
+ f"Delete the stored key for {provider_display_name(self._name)}?",
816
+ Text(f"This removes {env_var} from your credential store. It cannot be undone."),
817
+ )
818
+ )
819
+ if not confirmed:
820
+ return
821
+ delete_credential(env_var)
822
+ self.dismiss(self._name)
823
+
824
+ def action_cancel(self) -> None:
825
+ """Discard the prompt."""
826
+ self.dismiss(None)