aria-code 0.167.0__py3-none-win_amd64.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 (601) hide show
  1. aria_bots/__init__.py +7 -0
  2. aria_bots/channels/README.md +97 -0
  3. aria_bots/channels/__init__.py +1 -0
  4. aria_bots/channels/approvals.py +263 -0
  5. aria_bots/channels/conversation.py +280 -0
  6. aria_bots/channels/digest.py +186 -0
  7. aria_bots/channels/registry.py +95 -0
  8. aria_bots/engine.py +144 -0
  9. aria_bots/feishu_bot.py +1385 -0
  10. aria_bots/logistics/__init__.py +1 -0
  11. aria_bots/logistics/carriers.py +253 -0
  12. aria_bots/logistics/freight.py +154 -0
  13. aria_bots/logistics/inventory.py +242 -0
  14. aria_bots/logistics/mcp_server.py +156 -0
  15. aria_bots/logistics/records.py +119 -0
  16. aria_bots/logistics/tenancy.py +152 -0
  17. aria_bots/market.py +91 -0
  18. aria_bots/relay_client.py +326 -0
  19. aria_bots/telegram_bot.py +299 -0
  20. aria_code/__init__.py +111 -0
  21. aria_code/_bin/aria-native.exe +0 -0
  22. aria_code/_bin/aria-runtime.exe +0 -0
  23. aria_code/_version.py +20 -0
  24. aria_code/account_memory.py +296 -0
  25. aria_code/account_session.py +166 -0
  26. aria_code/adk_apps/__init__.py +1 -0
  27. aria_code/adk_apps/aria_code_review/__init__.py +1 -0
  28. aria_code/adk_apps/aria_code_review/agent.py +32 -0
  29. aria_code/adk_apps/aria_research/__init__.py +1 -0
  30. aria_code/adk_apps/aria_research/agent.py +31 -0
  31. aria_code/agents/__init__.py +49 -0
  32. aria_code/agents/base.py +329 -0
  33. aria_code/agents/code_review.py +200 -0
  34. aria_code/agents/deep/__init__.py +37 -0
  35. aria_code/agents/deep/calibration_loop.py +144 -0
  36. aria_code/agents/deep/critic.py +125 -0
  37. aria_code/agents/deep/deepen.py +193 -0
  38. aria_code/agents/deep/models.py +149 -0
  39. aria_code/agents/deep/pipeline.py +164 -0
  40. aria_code/agents/deep/quant_fusion.py +192 -0
  41. aria_code/agents/deep/themes.py +95 -0
  42. aria_code/agents/deep/tiers.py +106 -0
  43. aria_code/agents/engineering/__init__.py +14 -0
  44. aria_code/agents/engineering/coder.py +222 -0
  45. aria_code/agents/engineering/tester.py +145 -0
  46. aria_code/agents/financial/__init__.py +21 -0
  47. aria_code/agents/financial/cashflow_burn.py +119 -0
  48. aria_code/agents/financial/catalyst.py +279 -0
  49. aria_code/agents/financial/corporate_finance.py +175 -0
  50. aria_code/agents/financial/debate.py +224 -0
  51. aria_code/agents/financial/earnings.py +315 -0
  52. aria_code/agents/financial/fundamental.py +178 -0
  53. aria_code/agents/financial/macro.py +111 -0
  54. aria_code/agents/financial/news.py +231 -0
  55. aria_code/agents/financial/risk.py +146 -0
  56. aria_code/agents/financial/sector.py +279 -0
  57. aria_code/agents/financial/strategist.py +121 -0
  58. aria_code/agents/financial/stripe_revenue.py +127 -0
  59. aria_code/agents/financial/synthesis.py +274 -0
  60. aria_code/agents/financial/technical.py +277 -0
  61. aria_code/agents/orchestrator.py +44 -0
  62. aria_code/agents/portfolio_agent.py +392 -0
  63. aria_code/agents/realty/__init__.py +62 -0
  64. aria_code/agents/realty/asset_diagnosis.py +152 -0
  65. aria_code/agents/realty/business_match.py +166 -0
  66. aria_code/agents/realty/cashflow_verify.py +209 -0
  67. aria_code/agents/realty/contract_rules.py +210 -0
  68. aria_code/agents/realty/energy_anomaly.py +189 -0
  69. aria_code/agents/realty/exit_settlement.py +208 -0
  70. aria_code/agents/realty/fulfillment_risk.py +206 -0
  71. aria_code/agents/realty/ops_optimize.py +160 -0
  72. aria_code/agents/realty/revenue_share.py +215 -0
  73. aria_code/agents/registry.py +168 -0
  74. aria_code/agents/signal_scheme.py +111 -0
  75. aria_code/agents/sports/__init__.py +0 -0
  76. aria_code/agents/sports/football_agent.py +197 -0
  77. aria_code/agents/supervisor.py +113 -0
  78. aria_code/agents/team.py +500 -0
  79. aria_code/agents/video/__init__.py +16 -0
  80. aria_code/agents/video/director.py +376 -0
  81. aria_code/agents/video/qa_agent.py +143 -0
  82. aria_code/agents/video/script_doctor.py +112 -0
  83. aria_code/agents/warehouse/__init__.py +25 -0
  84. aria_code/agents/warehouse/contracts.py +26 -0
  85. aria_code/agents/warehouse/dashboard.py +155 -0
  86. aria_code/agents/warehouse/fulfillment_leadtime.py +95 -0
  87. aria_code/agents/warehouse/inbound_exceptions.py +25 -0
  88. aria_code/agents/warehouse/inventory_health.py +24 -0
  89. aria_code/agents/warehouse/local_run.py +71 -0
  90. aria_code/agents/warehouse/local_snapshot.py +132 -0
  91. aria_code/agents/warehouse/logistics_cost.py +84 -0
  92. aria_code/agents/warehouse/logistics_sync.py +21 -0
  93. aria_code/agents/warehouse/navigation.schema.json +110 -0
  94. aria_code/agents/warehouse/task_agent.py +172 -0
  95. aria_code/agents/warehouse/workflow.py +31 -0
  96. aria_code/aliyun_data_client.py +4 -0
  97. aria_code/apps/README.md +12 -0
  98. aria_code/apps/__init__.py +2 -0
  99. aria_code/apps/channels/README.md +9 -0
  100. aria_code/apps/channels/__init__.py +17 -0
  101. aria_code/apps/channels/approvals.py +11 -0
  102. aria_code/apps/channels/conversation.py +11 -0
  103. aria_code/apps/channels/digest.py +11 -0
  104. aria_code/apps/channels/intake.py +92 -0
  105. aria_code/apps/channels/registry.py +11 -0
  106. aria_code/apps/channels/tradingview.py +94 -0
  107. aria_code/apps/cli/README.md +13 -0
  108. aria_code/apps/cli/__init__.py +2 -0
  109. aria_code/apps/cli/_optional.py +120 -0
  110. aria_code/apps/cli/app_server.py +656 -0
  111. aria_code/apps/cli/auto_update.py +125 -0
  112. aria_code/apps/cli/bootstrap.py +206 -0
  113. aria_code/apps/cli/broker_render.py +206 -0
  114. aria_code/apps/cli/chat_turn.py +1327 -0
  115. aria_code/apps/cli/cloud_models.py +231 -0
  116. aria_code/apps/cli/codegen_paths.py +29 -0
  117. aria_code/apps/cli/commands/__init__.py +34 -0
  118. aria_code/apps/cli/commands/_business_render.py +78 -0
  119. aria_code/apps/cli/commands/_ui.py +71 -0
  120. aria_code/apps/cli/commands/analysis_cmds.py +378 -0
  121. aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
  122. aria_code/apps/cli/commands/auth_cmds.py +215 -0
  123. aria_code/apps/cli/commands/backtest_cmds.py +2518 -0
  124. aria_code/apps/cli/commands/broker_cmds.py +1236 -0
  125. aria_code/apps/cli/commands/business_workflow_cmds.py +379 -0
  126. aria_code/apps/cli/commands/canvas_cmds.py +77 -0
  127. aria_code/apps/cli/commands/catalog.py +189 -0
  128. aria_code/apps/cli/commands/core_cmds.py +2748 -0
  129. aria_code/apps/cli/commands/data_cmds.py +521 -0
  130. aria_code/apps/cli/commands/diagnostic_cmds.py +285 -0
  131. aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
  132. aria_code/apps/cli/commands/engineering_cmds.py +399 -0
  133. aria_code/apps/cli/commands/file_cmds.py +312 -0
  134. aria_code/apps/cli/commands/finance_service_cmds.py +60 -0
  135. aria_code/apps/cli/commands/fx_commodity_cmds.py +123 -0
  136. aria_code/apps/cli/commands/market.py +544 -0
  137. aria_code/apps/cli/commands/market_cmds.py +1419 -0
  138. aria_code/apps/cli/commands/market_context.py +432 -0
  139. aria_code/apps/cli/commands/market_render.py +7 -0
  140. aria_code/apps/cli/commands/model_cmds.py +2374 -0
  141. aria_code/apps/cli/commands/ops_cmds.py +721 -0
  142. aria_code/apps/cli/commands/orchestrator_cmds.py +181 -0
  143. aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
  144. aria_code/apps/cli/commands/portfolio_cmds.py +1316 -0
  145. aria_code/apps/cli/commands/report.py +476 -0
  146. aria_code/apps/cli/commands/scaffold_templates.py +617 -0
  147. aria_code/apps/cli/commands/session_cmds.py +236 -0
  148. aria_code/apps/cli/commands/session_ux_cmds.py +359 -0
  149. aria_code/apps/cli/commands/team.py +723 -0
  150. aria_code/apps/cli/commands/team_render.py +8 -0
  151. aria_code/apps/cli/commands/ui_cmds.py +401 -0
  152. aria_code/apps/cli/commands/warehouse_cmds.py +297 -0
  153. aria_code/apps/cli/commands/workflow_cmds.py +629 -0
  154. aria_code/apps/cli/commands/workspace_cmds.py +1543 -0
  155. aria_code/apps/cli/config_paths.py +73 -0
  156. aria_code/apps/cli/config_store.py +158 -0
  157. aria_code/apps/cli/constants.py +88 -0
  158. aria_code/apps/cli/context.py +34 -0
  159. aria_code/apps/cli/conversation_policy.py +53 -0
  160. aria_code/apps/cli/deterministic.py +200 -0
  161. aria_code/apps/cli/direct.py +48 -0
  162. aria_code/apps/cli/earnings_view.py +172 -0
  163. aria_code/apps/cli/endpoint_migration.py +64 -0
  164. aria_code/apps/cli/exec_events.py +255 -0
  165. aria_code/apps/cli/file_references.py +155 -0
  166. aria_code/apps/cli/finance_service_catalog.py +111 -0
  167. aria_code/apps/cli/football_reports.py +231 -0
  168. aria_code/apps/cli/github_app_auth.py +135 -0
  169. aria_code/apps/cli/google_login.py +239 -0
  170. aria_code/apps/cli/handlers/__init__.py +11 -0
  171. aria_code/apps/cli/handlers/broker_handlers.py +122 -0
  172. aria_code/apps/cli/handlers/chart_handlers.py +1314 -0
  173. aria_code/apps/cli/handlers/market_handlers.py +2570 -0
  174. aria_code/apps/cli/handlers/realty_handlers.py +114 -0
  175. aria_code/apps/cli/handlers/sports_handlers.py +22 -0
  176. aria_code/apps/cli/handlers/strategy_advice.py +82 -0
  177. aria_code/apps/cli/headless.py +352 -0
  178. aria_code/apps/cli/helpers.py +326 -0
  179. aria_code/apps/cli/hooks.py +180 -0
  180. aria_code/apps/cli/i18n.py +310 -0
  181. aria_code/apps/cli/intent.py +136 -0
  182. aria_code/apps/cli/intent_router.py +305 -0
  183. aria_code/apps/cli/intent_shadow.py +72 -0
  184. aria_code/apps/cli/intent_signals.py +347 -0
  185. aria_code/apps/cli/lifecycle_hooks.py +48 -0
  186. aria_code/apps/cli/local_tool_schemas.py +550 -0
  187. aria_code/apps/cli/main.py +39 -0
  188. aria_code/apps/cli/market_hours.py +100 -0
  189. aria_code/apps/cli/market_metadata.py +135 -0
  190. aria_code/apps/cli/market_universe.py +487 -0
  191. aria_code/apps/cli/message_processing.py +310 -0
  192. aria_code/apps/cli/mixin_binding.py +32 -0
  193. aria_code/apps/cli/model_catalog.py +652 -0
  194. aria_code/apps/cli/model_probe.py +169 -0
  195. aria_code/apps/cli/model_retirement.py +65 -0
  196. aria_code/apps/cli/native_branding.py +12 -0
  197. aria_code/apps/cli/native_bridge.py +118 -0
  198. aria_code/apps/cli/native_entry.py +58 -0
  199. aria_code/apps/cli/pdf_report.py +977 -0
  200. aria_code/apps/cli/plan_mode.py +139 -0
  201. aria_code/apps/cli/plotly_html.py +15 -0
  202. aria_code/apps/cli/prediction_feedback.py +202 -0
  203. aria_code/apps/cli/preflight.py +502 -0
  204. aria_code/apps/cli/project_aria.py +60 -0
  205. aria_code/apps/cli/prompt_assembly.py +78 -0
  206. aria_code/apps/cli/prompts/__init__.py +0 -0
  207. aria_code/apps/cli/prompts/coding.py +71 -0
  208. aria_code/apps/cli/prompts/select.py +193 -0
  209. aria_code/apps/cli/prompts/system_prompts.py +562 -0
  210. aria_code/apps/cli/prompts/ui.py +436 -0
  211. aria_code/apps/cli/provider_endpoints.py +237 -0
  212. aria_code/apps/cli/provider_keys.py +73 -0
  213. aria_code/apps/cli/providers/__init__.py +1 -0
  214. aria_code/apps/cli/providers/base.py +775 -0
  215. aria_code/apps/cli/providers/chat_routing.py +139 -0
  216. aria_code/apps/cli/providers/collaboration.py +98 -0
  217. aria_code/apps/cli/providers/llm/__init__.py +1 -0
  218. aria_code/apps/cli/providers/llm/offline_responses.py +68 -0
  219. aria_code/apps/cli/providers/llm/ollama_stream.py +1574 -0
  220. aria_code/apps/cli/providers/llm/response_cache.py +60 -0
  221. aria_code/apps/cli/providers/llm/sse_stream.py +298 -0
  222. aria_code/apps/cli/providers/runtime_bridge.py +586 -0
  223. aria_code/apps/cli/providers/vertexai_stream.py +592 -0
  224. aria_code/apps/cli/release_download.py +159 -0
  225. aria_code/apps/cli/review_runner.py +111 -0
  226. aria_code/apps/cli/runtime_consumer.py +769 -0
  227. aria_code/apps/cli/sandbox.py +32 -0
  228. aria_code/apps/cli/session_export.py +120 -0
  229. aria_code/apps/cli/session_jsonl.py +208 -0
  230. aria_code/apps/cli/session_refresh.py +123 -0
  231. aria_code/apps/cli/session_store.py +125 -0
  232. aria_code/apps/cli/skills_catalog.py +273 -0
  233. aria_code/apps/cli/task_isolation.py +211 -0
  234. aria_code/apps/cli/terminal_chrome.py +412 -0
  235. aria_code/apps/cli/todo_tracker.py +190 -0
  236. aria_code/apps/cli/tool_executor.py +932 -0
  237. aria_code/apps/cli/tool_registry.py +34 -0
  238. aria_code/apps/cli/tool_scope.py +113 -0
  239. aria_code/apps/cli/tools/__init__.py +40 -0
  240. aria_code/apps/cli/tools/context.py +46 -0
  241. aria_code/apps/cli/tools/file_tools.py +181 -0
  242. aria_code/apps/cli/tools/market_tools.py +626 -0
  243. aria_code/apps/cli/tools/notebook_tools.py +111 -0
  244. aria_code/apps/cli/tools/system_tools.py +656 -0
  245. aria_code/apps/cli/tools/write_tools.py +961 -0
  246. aria_code/apps/cli/tradingview_bridge.py +529 -0
  247. aria_code/apps/cli/transactions.py +269 -0
  248. aria_code/apps/cli/turn_planning.py +137 -0
  249. aria_code/apps/cli/update_check.py +257 -0
  250. aria_code/apps/cli/updater.py +373 -0
  251. aria_code/apps/cli/utils/__init__.py +0 -0
  252. aria_code/apps/cli/utils/market_detect.py +1663 -0
  253. aria_code/apps/cli/web_login.py +136 -0
  254. aria_code/apps/cli/workflow_runner.py +127 -0
  255. aria_code/apps/cli/workspace_route.py +121 -0
  256. aria_code/apps/daemon/README.md +14 -0
  257. aria_code/apps/vscode/README.md +115 -0
  258. aria_code/apps/vscode/package.json +70 -0
  259. aria_code/aria_cli.py +5244 -0
  260. aria_code/aria_daemon.py +1372 -0
  261. aria_code/aria_feishu_bot.py +16 -0
  262. aria_code/aria_mcp_server.py +61 -0
  263. aria_code/aria_relay_client.py +4 -0
  264. aria_code/aria_telegram_bot.py +11 -0
  265. aria_code/ariarc.py +385 -0
  266. aria_code/artifacts.py +581 -0
  267. aria_code/backtest_engine.py +4 -0
  268. aria_code/backtest_report.py +4 -0
  269. aria_code/brokers/__init__.py +78 -0
  270. aria_code/brokers/automation.py +193 -0
  271. aria_code/brokers/base.py +231 -0
  272. aria_code/brokers/capabilities.py +264 -0
  273. aria_code/brokers/cn/__init__.py +10 -0
  274. aria_code/brokers/cn/easytrader_broker.py +193 -0
  275. aria_code/brokers/cn/futu_broker.py +194 -0
  276. aria_code/brokers/cn/longbridge_broker.py +190 -0
  277. aria_code/brokers/cn/tiger_broker.py +196 -0
  278. aria_code/brokers/cn/xtquant_broker.py +225 -0
  279. aria_code/brokers/config.py +414 -0
  280. aria_code/brokers/intl/__init__.py +5 -0
  281. aria_code/brokers/intl/alpaca_broker.py +183 -0
  282. aria_code/brokers/intl/ibkr_broker.py +215 -0
  283. aria_code/brokers/intl/trading212_broker.py +183 -0
  284. aria_code/brokers/intl/webull_broker.py +156 -0
  285. aria_code/brokers/paper_broker.py +266 -0
  286. aria_code/brokers/planning.py +296 -0
  287. aria_code/brokers/registry.py +213 -0
  288. aria_code/brokers/trading.py +267 -0
  289. aria_code/canva_client.py +4 -0
  290. aria_code/change_store.py +127 -0
  291. aria_code/clients/__init__.py +0 -0
  292. aria_code/clients/aliyun_data_client.py +660 -0
  293. aria_code/clients/aria_relay_client.py +16 -0
  294. aria_code/clients/canva_client.py +284 -0
  295. aria_code/clients/figma_client.py +100 -0
  296. aria_code/clients/football_data_client.py +1671 -0
  297. aria_code/clients/kling_video_client.py +182 -0
  298. aria_code/clients/market_data_client.py +1985 -0
  299. aria_code/clients/openai_image_client.py +242 -0
  300. aria_code/clients/runway_video_client.py +157 -0
  301. aria_code/clients/warehouse_erp_client.py +105 -0
  302. aria_code/cloud_memory.py +190 -0
  303. aria_code/command_safety.py +19 -0
  304. aria_code/computer_use_server.py +276 -0
  305. aria_code/computer_use_tools.py +4 -0
  306. aria_code/dashboard_generator.py +579 -0
  307. aria_code/data_analysis_tools.py +4 -0
  308. aria_code/data_cleaner.py +483 -0
  309. aria_code/data_service.py +513 -0
  310. aria_code/datasources/__init__.py +23 -0
  311. aria_code/datasources/base.py +166 -0
  312. aria_code/datasources/router.py +253 -0
  313. aria_code/datasources/sec_statements.py +217 -0
  314. aria_code/datasources/sources/__init__.py +15 -0
  315. aria_code/datasources/sources/akshare_source.py +269 -0
  316. aria_code/datasources/sources/alpha_vantage_source.py +203 -0
  317. aria_code/datasources/sources/edgar_source.py +219 -0
  318. aria_code/datasources/sources/finnhub_source.py +198 -0
  319. aria_code/datasources/sources/fred_source.py +223 -0
  320. aria_code/datasources/sources/tushare_source.py +143 -0
  321. aria_code/datasources/sources/web_scraper_source.py +278 -0
  322. aria_code/datasources/sources/world_bank_source.py +206 -0
  323. aria_code/datasources/sources/yfinance_source.py +152 -0
  324. aria_code/doctor.py +860 -0
  325. aria_code/domain/__init__.py +0 -0
  326. aria_code/domain/_finance_core/__init__.py +5 -0
  327. aria_code/domain/_finance_core/backtest_engine.py +325 -0
  328. aria_code/domain/_finance_core/performance_stats.py +101 -0
  329. aria_code/domain/backtest_engine.py +75 -0
  330. aria_code/domain/backtest_report.py +488 -0
  331. aria_code/domain/finance_formulas.py +389 -0
  332. aria_code/domain/performance_stats.py +6 -0
  333. aria_code/domain/portfolio_ledger.py +307 -0
  334. aria_code/domain/strategy_vault.py +421 -0
  335. aria_code/evals/__init__.py +37 -0
  336. aria_code/evals/behavior.py +113 -0
  337. aria_code/evals/gap.py +93 -0
  338. aria_code/evals/harness.py +949 -0
  339. aria_code/evals/review_probe.py +132 -0
  340. aria_code/evals/runner.py +425 -0
  341. aria_code/evals/scorecard.py +322 -0
  342. aria_code/evals/summary.py +125 -0
  343. aria_code/external_agent_runner.py +115 -0
  344. aria_code/figma_client.py +4 -0
  345. aria_code/file_analysis_tools.py +4 -0
  346. aria_code/finance_formulas.py +4 -0
  347. aria_code/football_data_client.py +4 -0
  348. aria_code/image_gen_tools.py +4 -0
  349. aria_code/intent_classifier.py +459 -0
  350. aria_code/kling_video_client.py +4 -0
  351. aria_code/licensing.py +151 -0
  352. aria_code/local_finance_tools.py +4 -0
  353. aria_code/local_image_provider.py +4 -0
  354. aria_code/local_llm_provider.py +4 -0
  355. aria_code/macro_tools.py +4 -0
  356. aria_code/markdown_pdf.py +289 -0
  357. aria_code/market_data_client.py +4 -0
  358. aria_code/mcp_client.py +744 -0
  359. aria_code/memory_manager.py +273 -0
  360. aria_code/model_capability.py +512 -0
  361. aria_code/notification_tools.py +4 -0
  362. aria_code/openai_image_client.py +4 -0
  363. aria_code/packages/__init__.py +26 -0
  364. aria_code/packages/adk_bridge/__init__.py +11 -0
  365. aria_code/packages/adk_bridge/code_review_tools.py +49 -0
  366. aria_code/packages/adk_bridge/market_tools.py +125 -0
  367. aria_code/packages/aria_agents/__init__.py +5 -0
  368. aria_code/packages/aria_agents/manifest.py +78 -0
  369. aria_code/packages/aria_core/__init__.py +34 -0
  370. aria_code/packages/aria_core/architecture.py +269 -0
  371. aria_code/packages/aria_core/export.py +147 -0
  372. aria_code/packages/aria_core/manifest.py +65 -0
  373. aria_code/packages/aria_core/paths.py +57 -0
  374. aria_code/packages/aria_core/secure_file.py +68 -0
  375. aria_code/packages/aria_infra/__init__.py +15 -0
  376. aria_code/packages/aria_infra/arthera.py +58 -0
  377. aria_code/packages/aria_infra/doctor.py +246 -0
  378. aria_code/packages/aria_infra/product.py +40 -0
  379. aria_code/packages/aria_mcp/__init__.py +25 -0
  380. aria_code/packages/aria_mcp/bridge.py +232 -0
  381. aria_code/packages/aria_mcp/circuit.py +82 -0
  382. aria_code/packages/aria_mcp/config.py +162 -0
  383. aria_code/packages/aria_mcp/installer.py +95 -0
  384. aria_code/packages/aria_mcp/server.py +1705 -0
  385. aria_code/packages/aria_mcp/tools.py +81 -0
  386. aria_code/packages/aria_sdk/__init__.py +19 -0
  387. aria_code/packages/aria_sdk/client.py +393 -0
  388. aria_code/packages/aria_sdk/providers.py +70 -0
  389. aria_code/packages/aria_sdk/streaming.py +75 -0
  390. aria_code/packages/aria_sdk/types.py +86 -0
  391. aria_code/packages/aria_services/__init__.py +92 -0
  392. aria_code/packages/aria_services/cache.py +60 -0
  393. aria_code/packages/aria_services/context.py +356 -0
  394. aria_code/packages/aria_services/context_checkpoints.py +157 -0
  395. aria_code/packages/aria_services/data.py +11 -0
  396. aria_code/packages/aria_services/provider_health.py +304 -0
  397. aria_code/packages/aria_services/references.py +247 -0
  398. aria_code/packages/aria_services/registry.py +249 -0
  399. aria_code/packages/aria_services/research_protocol.py +137 -0
  400. aria_code/packages/aria_services/research_quality.py +186 -0
  401. aria_code/packages/aria_services/settings.py +160 -0
  402. aria_code/packages/aria_services/usage.py +151 -0
  403. aria_code/packages/aria_skills/__init__.py +46 -0
  404. aria_code/packages/aria_skills/catalog.py +149 -0
  405. aria_code/packages/aria_skills/loader.py +455 -0
  406. aria_code/packages/aria_skills/registry.py +84 -0
  407. aria_code/packages/aria_tools/__init__.py +5 -0
  408. aria_code/packages/aria_tools/financial/__init__.py +5 -0
  409. aria_code/packages/aria_tools/financial/_paths.py +92 -0
  410. aria_code/packages/aria_tools/registry.py +129 -0
  411. aria_code/packages/quant_engine/__init__.py +33 -0
  412. aria_code/packages/quant_engine/_namespace.py +24 -0
  413. aria_code/packages/quant_engine/backtest/__init__.py +10 -0
  414. aria_code/packages/quant_engine/backtest/core.py +53 -0
  415. aria_code/packages/quant_engine/backtest/engine.py +102 -0
  416. aria_code/packages/quant_engine/portfolio/__init__.py +9 -0
  417. aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
  418. aria_code/packages/quant_engine/sports/__init__.py +76 -0
  419. aria_code/packages/quant_engine/sports/calibrator.py +354 -0
  420. aria_code/packages/quant_engine/sports/dixon_coles.py +234 -0
  421. aria_code/packages/quant_engine/sports/elo.py +300 -0
  422. aria_code/packages/quant_engine/sports/form.py +188 -0
  423. aria_code/packages/quant_engine/sports/h2h.py +195 -0
  424. aria_code/packages/quant_engine/sports/predictor.py +312 -0
  425. aria_code/packages/quant_engine/sports/tracker.py +665 -0
  426. aria_code/packages/quant_engine/stochastic/__init__.py +31 -0
  427. aria_code/packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  428. aria_code/packages/quant_engine/stochastic/ito_calculus.py +477 -0
  429. aria_code/packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  430. aria_code/packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  431. aria_code/packages/quant_engine/stochastic/options_pricing.py +576 -0
  432. aria_code/packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  433. aria_code/packs/__init__.py +85 -0
  434. aria_code/packs/base.py +168 -0
  435. aria_code/packs/finance/__init__.py +250 -0
  436. aria_code/packs/healthcare/__init__.py +40 -0
  437. aria_code/packs/logistics/__init__.py +171 -0
  438. aria_code/packs/payments/__init__.py +139 -0
  439. aria_code/packs/realty/__init__.py +190 -0
  440. aria_code/packs/registry.py +202 -0
  441. aria_code/packs/rules.py +90 -0
  442. aria_code/plan_utils.py +194 -0
  443. aria_code/plugin_loader.py +328 -0
  444. aria_code/portfolio_ledger.py +4 -0
  445. aria_code/preview_server.py +449 -0
  446. aria_code/privacy/__init__.py +5 -0
  447. aria_code/privacy/feedback.py +123 -0
  448. aria_code/project_review.py +278 -0
  449. aria_code/project_review_api.py +235 -0
  450. aria_code/project_review_server.py +18 -0
  451. aria_code/project_tools.py +4 -0
  452. aria_code/providers/__init__.py +30 -0
  453. aria_code/providers/google_video_provider.py +256 -0
  454. aria_code/providers/llm/__init__.py +19 -0
  455. aria_code/providers/llm/anthropic.py +184 -0
  456. aria_code/providers/llm/arthera_gateway.py +90 -0
  457. aria_code/providers/llm/autoconfig.py +213 -0
  458. aria_code/providers/llm/base.py +139 -0
  459. aria_code/providers/llm/custom.py +158 -0
  460. aria_code/providers/llm/ollama.py +137 -0
  461. aria_code/providers/llm/openai_compat.py +350 -0
  462. aria_code/providers/llm/registry.py +457 -0
  463. aria_code/providers/local_image_provider.py +202 -0
  464. aria_code/providers/local_llm_provider.py +549 -0
  465. aria_code/providers/ollama_models.py +79 -0
  466. aria_code/providers/openai_params.py +32 -0
  467. aria_code/providers/tool_messages.py +100 -0
  468. aria_code/realty_data_tools.py +4 -0
  469. aria_code/report_exporters.py +150 -0
  470. aria_code/report_generator.py +1764 -0
  471. aria_code/review_service.py +497 -0
  472. aria_code/runtime/__init__.py +176 -0
  473. aria_code/runtime/acceptance.py +632 -0
  474. aria_code/runtime/agent_loop.py +1851 -0
  475. aria_code/runtime/agent_profiles.py +75 -0
  476. aria_code/runtime/approval.py +71 -0
  477. aria_code/runtime/baseline.py +219 -0
  478. aria_code/runtime/budget.py +232 -0
  479. aria_code/runtime/checkpoints.py +481 -0
  480. aria_code/runtime/consult.py +65 -0
  481. aria_code/runtime/contract.py +282 -0
  482. aria_code/runtime/delivery.py +432 -0
  483. aria_code/runtime/events.py +113 -0
  484. aria_code/runtime/gateway.py +167 -0
  485. aria_code/runtime/lsp.py +484 -0
  486. aria_code/runtime/native_index.py +153 -0
  487. aria_code/runtime/orchestrator.py +55 -0
  488. aria_code/runtime/processes.py +343 -0
  489. aria_code/runtime/project_graph.py +614 -0
  490. aria_code/runtime/repo_map.py +712 -0
  491. aria_code/runtime/research_bundle.py +105 -0
  492. aria_code/runtime/review.py +269 -0
  493. aria_code/runtime/run_state.py +127 -0
  494. aria_code/runtime/run_store.py +522 -0
  495. aria_code/runtime/scoped_runs.py +113 -0
  496. aria_code/runtime/self_healing.py +422 -0
  497. aria_code/runtime/semantic_diff.py +171 -0
  498. aria_code/runtime/subagent.py +721 -0
  499. aria_code/runtime/symbol_edit.py +117 -0
  500. aria_code/runtime/task_brief.py +46 -0
  501. aria_code/runtime/task_context.py +145 -0
  502. aria_code/runtime/task_ledger.py +108 -0
  503. aria_code/runtime/task_orchestrator.py +90 -0
  504. aria_code/runtime/task_outcome.py +33 -0
  505. aria_code/runtime/task_verification.py +266 -0
  506. aria_code/runtime/task_worktree.py +448 -0
  507. aria_code/runtime/tool_executor.py +329 -0
  508. aria_code/runtime/tool_policy.py +107 -0
  509. aria_code/runtime/transactions.py +216 -0
  510. aria_code/runtime/windows_job.py +115 -0
  511. aria_code/runtime/workflows.py +222 -0
  512. aria_code/runtime/worktrees.py +215 -0
  513. aria_code/runway_video_client.py +4 -0
  514. aria_code/safety/__init__.py +27 -0
  515. aria_code/safety/client_isolation.py +178 -0
  516. aria_code/safety/permissions.py +339 -0
  517. aria_code/safety/risk.py +699 -0
  518. aria_code/safety/sandbox.py +148 -0
  519. aria_code/safety/service.py +102 -0
  520. aria_code/service_endpoints.py +14 -0
  521. aria_code/setup_wizard.py +684 -0
  522. aria_code/spreadsheet_tools.py +4 -0
  523. aria_code/strategy_vault.py +4 -0
  524. aria_code/tools/__init__.py +0 -0
  525. aria_code/tools/artifact_tools.py +252 -0
  526. aria_code/tools/broker_tools.py +92 -0
  527. aria_code/tools/build_quant_engine.py +97 -0
  528. aria_code/tools/code_audit_tools.py +192 -0
  529. aria_code/tools/computer_use_tools.py +509 -0
  530. aria_code/tools/data_analysis_tools.py +831 -0
  531. aria_code/tools/enterprise_finance_tools.py +202 -0
  532. aria_code/tools/extended_tools.py +240 -0
  533. aria_code/tools/file_analysis_tools.py +880 -0
  534. aria_code/tools/image_gen_tools.py +114 -0
  535. aria_code/tools/local_finance_tools.py +3488 -0
  536. aria_code/tools/logistics_carriers.py +11 -0
  537. aria_code/tools/logistics_inventory.py +11 -0
  538. aria_code/tools/logistics_io.py +11 -0
  539. aria_code/tools/logistics_tenancy.py +11 -0
  540. aria_code/tools/logistics_tools.py +11 -0
  541. aria_code/tools/macro_tools.py +412 -0
  542. aria_code/tools/notification_tools.py +249 -0
  543. aria_code/tools/project_tools.py +525 -0
  544. aria_code/tools/realty_data_tools.py +662 -0
  545. aria_code/tools/spreadsheet_tools.py +399 -0
  546. aria_code/tools/stripe_tools.py +115 -0
  547. aria_code/tools/video_tools.py +500 -0
  548. aria_code/training/__init__.py +1 -0
  549. aria_code/training/compare.py +117 -0
  550. aria_code/training/prelude_commands.py +111 -0
  551. aria_code/training/prelude_eval.py +390 -0
  552. aria_code/training/prelude_real.py +183 -0
  553. aria_code/training/prelude_train.py +248 -0
  554. aria_code/training/sft_dataset.py +230 -0
  555. aria_code/training/trajectory_dataset.py +263 -0
  556. aria_code/ui/__init__.py +105 -0
  557. aria_code/ui/banner.py +404 -0
  558. aria_code/ui/completer.py +571 -0
  559. aria_code/ui/console.py +285 -0
  560. aria_code/ui/input_box.py +505 -0
  561. aria_code/ui/mentions.py +206 -0
  562. aria_code/ui/picker.py +248 -0
  563. aria_code/ui/render/__init__.py +11 -0
  564. aria_code/ui/render/actions.py +297 -0
  565. aria_code/ui/render/finance.py +1732 -0
  566. aria_code/ui/render/market.py +253 -0
  567. aria_code/ui/render/output.py +954 -0
  568. aria_code/ui/render/responsive.py +75 -0
  569. aria_code/ui/render/team.py +367 -0
  570. aria_code/ui/robot.py +239 -0
  571. aria_code/ui/startup_dashboard.py +110 -0
  572. aria_code/video_analysis.py +147 -0
  573. aria_code/video_editor.py +518 -0
  574. aria_code/video_workflow/__init__.py +8 -0
  575. aria_code/video_workflow/__main__.py +61 -0
  576. aria_code/video_workflow/engines/__init__.py +18 -0
  577. aria_code/video_workflow/engines/base.py +69 -0
  578. aria_code/video_workflow/engines/custom_model.py +204 -0
  579. aria_code/video_workflow/engines/google_veo.py +94 -0
  580. aria_code/video_workflow/engines/local_engine.py +71 -0
  581. aria_code/video_workflow/engines/registry.py +98 -0
  582. aria_code/video_workflow/google_models.py +278 -0
  583. aria_code/video_workflow/music/__init__.py +5 -0
  584. aria_code/video_workflow/music/foley_service.py +132 -0
  585. aria_code/video_workflow/music/service.py +212 -0
  586. aria_code/video_workflow/pipeline.py +620 -0
  587. aria_code/video_workflow/plan.py +202 -0
  588. aria_code/video_workflow/session.py +297 -0
  589. aria_code/workspace/__init__.py +6 -0
  590. aria_code/workspace/files.py +219 -0
  591. aria_code/workspace/verify.py +167 -0
  592. aria_code/workspace_changes.py +199 -0
  593. aria_code/workspace_service.py +196 -0
  594. aria_code/workspace_tools.py +150 -0
  595. aria_code-0.167.0.dist-info/METADATA +330 -0
  596. aria_code-0.167.0.dist-info/RECORD +601 -0
  597. aria_code-0.167.0.dist-info/WHEEL +5 -0
  598. aria_code-0.167.0.dist-info/entry_points.txt +5 -0
  599. aria_code-0.167.0.dist-info/licenses/LICENSE +201 -0
  600. aria_code-0.167.0.dist-info/licenses/NOTICE +49 -0
  601. aria_code-0.167.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,2374 @@
1
+ """
2
+ ModelCommandsMixin — Model/config commands: model, apikey, providers, cloud, config, tools, skills.
3
+
4
+ Extracted from aria_cli.py. Methods' __globals__ are rebound to aria_cli's namespace
5
+ by _rebind_mixin_globals() called at module load time.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from aria_code.apps.cli.i18n import ui_text
10
+ from aria_code.packages.aria_core.paths import aria_home
11
+
12
+
13
+ import logging
14
+ import json
15
+ import asyncio
16
+ import datetime
17
+ import time
18
+ import shlex
19
+ import sys
20
+ import os
21
+ from typing import Dict, Any, Optional
22
+
23
+ # Import path → the name you actually type after `pip install`. Namespace
24
+ # packages make these differ, and guessing from the module name produces
25
+ # advice that fails: `google.genai` is shipped as `google-genai`, so the
26
+ # obvious `pip install google` installs an unrelated stub.
27
+ _PIP_NAMES = {
28
+ "google.genai": "google-genai",
29
+ "google.generativeai": "google-generativeai",
30
+ "google.cloud": "google-cloud",
31
+ "anthropic": "anthropic",
32
+ "openai": "openai",
33
+ "mistralai": "mistralai",
34
+ "cohere": "cohere",
35
+ "yfinance": "yfinance",
36
+ "akshare": "akshare",
37
+ "ccxt": "ccxt",
38
+ }
39
+
40
+
41
+ def _pip_name(module: str) -> str:
42
+ """Best installable name for *module*, longest known prefix first."""
43
+ if module in _PIP_NAMES:
44
+ return _PIP_NAMES[module]
45
+ for known, package in sorted(_PIP_NAMES.items(), key=lambda kv: -len(kv[0])):
46
+ if module.startswith(known + "."):
47
+ return package
48
+ # Unknown: the top-level name is the best available guess, and it is right
49
+ # for the ordinary single-package case.
50
+ return module.split(".")[0].replace("_", "-")
51
+
52
+
53
+ def _actionable(provider: str, error: str) -> str:
54
+ """Turn a probe failure into something the user can act on.
55
+
56
+ The picker used to show ``ollama_err[:40]``, which for the most common
57
+ failure produced ``[ollama: <urlopen error [Errno 61] Connection ref]`` —
58
+ a raw Python exception, truncated mid-word, naming neither the cause nor
59
+ the remedy. A connection refused on the local runtime port has exactly one
60
+ meaning and one fix, so say them.
61
+ """
62
+ text = str(error or "")
63
+ low = text.lower()
64
+ if "errno 61" in low or "connection refused" in low:
65
+ return "未运行 · 先执行 ollama serve" if provider == "ollama" else f"{provider} 未运行"
66
+ if "timed out" in low or "timeout" in low:
67
+ return "连接超时 · 检查服务地址与网络"
68
+ if "name or service not known" in low or "nodename nor servname" in low:
69
+ return "地址无法解析 · 检查 ollama_url 配置"
70
+ if "no module named" in low:
71
+ module = text.split("'")[1] if "'" in text else text.strip()
72
+ return f"缺少 {module} · pip install {_pip_name(module)}"
73
+ collapsed = " ".join(text.split())
74
+ return collapsed if len(collapsed) <= 48 else collapsed[:47] + "…"
75
+
76
+
77
+ def detect_ollama_models_rich(*args, **kwargs):
78
+ from aria_code.apps.cli.helpers import detect_ollama_models_rich as fn
79
+ return fn(*args, **kwargs)
80
+ def _sync_write_policy(*args, **kwargs):
81
+ from aria_code.aria_cli import _sync_write_policy as fn
82
+ return fn(*args, **kwargs)
83
+ def _load_providers_json(*args, **kwargs):
84
+ from aria_code.aria_cli import _load_providers_json as fn
85
+ return fn(*args, **kwargs)
86
+ def _get__PROVIDER_KEY_MAP():
87
+ from aria_code.aria_cli import _PROVIDER_KEY_MAP as val
88
+ return val
89
+ def _get__LLM_SIGNUP_URLS():
90
+ from aria_code.aria_cli import _LLM_SIGNUP_URLS as val
91
+ return val
92
+ def _get__PROVIDER_BASE_URLS():
93
+ from aria_code.aria_cli import _PROVIDER_BASE_URLS as val
94
+ return val
95
+ def _get_ARIA_TOOLS():
96
+ # Owned by apps/cli/tool_registry.py; aria_cli fills it in place.
97
+ from ..tool_registry import ARIA_TOOLS as val
98
+ return val
99
+ def _get_THINKING_MODES():
100
+ from aria_code.aria_cli import THINKING_MODES as val
101
+ return val
102
+ def _get__PROVIDER_GUIDE():
103
+ from aria_code.aria_cli import _PROVIDER_GUIDE as val
104
+ return val
105
+ def _get__DATA_KEY_MAP():
106
+ from aria_code.aria_cli import _DATA_KEY_MAP as val
107
+ return val
108
+ def _run_picker_in_thread(*args, **kwargs):
109
+ from aria_code.aria_cli import _run_picker_in_thread as fn
110
+ return fn(*args, **kwargs)
111
+ import pathlib
112
+ def _get_MODEL_ALIASES():
113
+ from aria_code.aria_cli import MODEL_ALIASES as val
114
+ return val
115
+ def _save_providers_json(*args, **kwargs):
116
+ from aria_code.aria_cli import _save_providers_json as fn
117
+ return fn(*args, **kwargs)
118
+ def _test_api_key(*args, **kwargs):
119
+ from aria_code.aria_cli import _test_api_key as fn
120
+ return fn(*args, **kwargs)
121
+ def _get_provider_key(*args, **kwargs):
122
+ from aria_code.apps.cli.provider_keys import get_provider_key as fn
123
+ return fn(*args, **kwargs)
124
+ def _get__DATA_SIGNUP_URLS():
125
+ from aria_code.aria_cli import _DATA_SIGNUP_URLS as val
126
+ return val
127
+ def _arrow_select(*args, **kwargs):
128
+ # Re-exported by aria_cli; taken from its own module here.
129
+ from aria_code.ui.picker import arrow_select as fn
130
+ return fn(*args, **kwargs)
131
+ def _get_SKILLS():
132
+ from aria_code.apps.cli.skills_catalog import SKILLS as val
133
+ return val
134
+ def _get_LOCAL_TOOLS():
135
+ # Owned by apps/cli/tool_registry.py; aria_cli fills it in place.
136
+ from ..tool_registry import LOCAL_TOOLS as val
137
+ return val
138
+ def get_model_capability(*args, **kwargs):
139
+ from aria_code.apps.cli._optional import get_model_capability as fn
140
+ return fn(*args, **kwargs)
141
+ def _save_data_key(*args, **kwargs):
142
+ from aria_code.aria_cli import _save_data_key as fn
143
+ return fn(*args, **kwargs)
144
+ def _get_MODELS():
145
+ from aria_code.apps.cli.model_catalog import MODELS as val
146
+ return val
147
+ def load_config(*args, **kwargs):
148
+ from aria_code.aria_cli import load_config as fn
149
+ return fn(*args, **kwargs)
150
+ def _null_ctx(*args, **kwargs):
151
+ from ._ui import null_ctx as fn
152
+ return fn(*args, **kwargs)
153
+ def _get__PROVIDER_DESC():
154
+ from aria_code.aria_cli import _PROVIDER_DESC as val
155
+ return val
156
+ def _get_PROVIDERS_FILE():
157
+ from aria_code.aria_cli import PROVIDERS_FILE as val
158
+ return val
159
+ def _get__HAS_MODEL_CAP():
160
+ from aria_code.apps.cli._optional import HAS_MODEL_CAP as val
161
+ return val
162
+ def _load_data_keys(*args, **kwargs):
163
+ from aria_code.apps.cli.provider_keys import load_data_keys as fn
164
+ return fn(*args, **kwargs)
165
+
166
+ import json
167
+ import asyncio
168
+ import datetime
169
+ import time
170
+ import shlex
171
+ import sys
172
+ import os
173
+ from typing import Dict, Any, Optional
174
+
175
+ # A real logger. This was a `def logger(*args, **kwargs)` forwarding to
176
+ # aria_cli, so every `logger.debug(...)` raised AttributeError — and all but
177
+ # one of them sit in an except block, where the failure replaced whatever
178
+ # error was being reported.
179
+ logger = logging.getLogger(__name__)
180
+
181
+
182
+ def _installed_catalogue(rich_models):
183
+ """Keep cloud routes separate; local catalogue entries need a real install."""
184
+ from aria_code.apps.cli.model_catalog import is_provider_qualified
185
+ installed = {m["name"] for m in rich_models}
186
+ return {k: m for k, m in _get_MODELS().items()
187
+ if (is_provider_qualified(m["id"]) and not m["id"].startswith("ollama/"))
188
+ or m["id"] in installed}
189
+
190
+
191
+ class ModelCommandsMixin:
192
+ """Mixin: Model/config commands: model, apikey, providers, cloud, config, tools, skills."""
193
+
194
+ def _listed_model_ids(self) -> list:
195
+ """The ids behind the numbers /models prints, in the order it prints them.
196
+
197
+ The number is the most prominent thing on each row, so it has to mean
198
+ something; the printer and this must therefore read the same order
199
+ from the same place. Community models are keyed by name, which is
200
+ their id.
201
+ """
202
+ if hasattr(self, "_last_listed_models"):
203
+ return list(self._last_listed_models)
204
+ ids = [m["id"] for m in _get_MODELS().values() if "/" in m["id"]]
205
+ for name in getattr(self, "_last_listed_community", []) or []:
206
+ if name not in ids:
207
+ ids.append(name)
208
+ return ids
209
+
210
+ async def cmd_model(self, args: str):
211
+ name = args.strip()
212
+
213
+ # /models prints a numbered list, so a number is the obvious thing to
214
+ # type back. It used to be taken as a model name and matched nothing,
215
+ # which left the numbers on screen decorative.
216
+ if name.isdigit():
217
+ listed = self._listed_model_ids()
218
+ index = int(name)
219
+ if 1 <= index <= len(listed):
220
+ name = listed[index - 1]
221
+ else:
222
+ msg = ui_text(self, f"没有第 {index} 个模型;用 /models 查看当前列表。",
223
+ f"There is no model {index}; run /models for the current list.")
224
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
225
+ return
226
+
227
+ # A model Google has retired would fail on the next request; switch
228
+ # to its replacement and say so (model_retirement.py).
229
+ from aria_code.apps.cli.model_retirement import retired_replacement
230
+
231
+ _replacement = retired_replacement(name)
232
+ if _replacement:
233
+ msg = ui_text(self, f"ℹ {name} 已被 Google 停用,改用 {_replacement}。",
234
+ f"ℹ {name} has been retired by Google; using {_replacement}.")
235
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
236
+ name = _replacement
237
+
238
+ # ── "provider/model" format (Open Interpreter style) ─────────────────
239
+ # Examples: /model deepseek/deepseek-chat /model ollama/qwen2.5:7b
240
+ # /model openai/gpt-4.5 /model openai/o3 /model openai/o4-mini
241
+ if "/" in name and not name.startswith("http"):
242
+ _prov, _mod = name.split("/", 1)
243
+ from aria_code.apps.cli.providers.chat_routing import normalize_provider_name
244
+
245
+ _prov = normalize_provider_name(_prov)
246
+ _mod = _mod.strip()
247
+ _local_backends = {"ollama", "lmstudio", "vllm", "llamacpp", "jan", "custom"}
248
+ if _prov not in _local_backends:
249
+ # Cloud provider — check API key. Google models also run on
250
+ # Vertex AI with Google Cloud credentials and no key; this
251
+ # refused every `/model google/…` without a Gemini API key,
252
+ # so a team running on Google Cloud could not pick Gemini 3
253
+ # or Gemma at all.
254
+ from aria_code.apps.cli.providers.base import google_cloud_available
255
+ _key = _get_provider_key(_prov)
256
+ _vertex_ok = _prov in ("google", "gemini", "vertexai") and google_cloud_available()
257
+ if not _key and not _vertex_ok:
258
+ if _prov in ("google", "gemini", "vertexai"):
259
+ msg = ui_text(self,
260
+ f"⚠ 需要 Google Cloud 凭据或 API key:运行 gcloud auth application-default login,"
261
+ f"或 /apikey set {_prov} <key>",
262
+ f"⚠ Needs Google Cloud credentials or an API key: run "
263
+ f"gcloud auth application-default login, or /apikey set {_prov} <key>")
264
+ else:
265
+ msg = (f"⚠ {_prov} API key 未配置。"
266
+ f"运行: /apikey set {_prov} <key>")
267
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
268
+ return
269
+ if _prov == "ollama":
270
+ try:
271
+ from aria_code.providers.ollama_models import ollama_base_url, resolve_model_async
272
+ _mod = await resolve_model_async(ollama_base_url(self.terminal.config), _mod)
273
+ except ValueError as exc:
274
+ self.context.console.print(str(exc))
275
+ return
276
+ self.terminal.config["local_provider"] = _prov
277
+ self.terminal.config["model"] = _mod
278
+ self.terminal.config["local_mode"] = _prov in _local_backends
279
+ # /model is an explicit provider selection. A legacy
280
+ # backend_chat=true value must not silently override it and route
281
+ # the next turn through Aria SSE instead of the selected runtime.
282
+ self.terminal.config["backend_chat"] = False
283
+ self.context.save_config(self.terminal.config)
284
+ msg = f"✓ 已切换到 {_prov}/{_mod}"
285
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
286
+ return
287
+
288
+ # Direct selection by number: /model 1 /model 2 … (Codex style)
289
+ if name.isdigit():
290
+ idx = int(name) - 1
291
+ keys = list(_get_MODELS().keys())
292
+ if 0 <= idx < len(keys):
293
+ self._set_model(keys[idx])
294
+ else:
295
+ self.context.console.print(f"[dim]No model #{name}[/dim]" if self.context.has_rich else f"No model #{name}")
296
+ return
297
+
298
+ # Direct selection by key (case-insensitive): /model qwen7b
299
+ if name.lower() in _get_MODELS():
300
+ self._set_model(name.lower())
301
+ return
302
+
303
+ # Direct selection by alias: /model st / s / p / coder
304
+ if name.lower() in _get_MODEL_ALIASES():
305
+ self._set_model(_get_MODEL_ALIASES()[name.lower()])
306
+ return
307
+
308
+ # Direct selection by full Ollama model ID: /model qwen2.5-coder:1.5b
309
+ if name and ":" in name:
310
+ self._set_model_by_id(name, provider="ollama")
311
+ return
312
+
313
+ # ── Interactive picker (Codex style: numbered list + descriptions) ────
314
+ from aria_code.providers.ollama_models import ollama_base_url
315
+ ollama_url = ollama_base_url(self.terminal.config)
316
+ current_provider = str(
317
+ self.terminal.config.get("local_provider") or "ollama"
318
+ ).lower()
319
+ current_id = self.terminal.config.get("model", "qwen2.5:7b")
320
+ try:
321
+ from aria_code.apps.cli.i18n import t as _i18nt
322
+ _lang = self.terminal.config.get("ui_lang", "en") or "en"
323
+ _i18n = lambda k: _i18nt(k, lang=_lang)
324
+ except Exception:
325
+ _lang = "en"
326
+ _i18n = lambda k: k
327
+
328
+ _local_backends = {"ollama", "lmstudio", "vllm", "llamacpp", "jan", "custom"}
329
+ if current_provider == "ollama":
330
+ rich_models, ollama_err = detect_ollama_models_rich(ollama_url)
331
+ elif current_provider in _local_backends:
332
+ try:
333
+ from local_llm_provider import LocalLLMProvider
334
+
335
+ _runtime = LocalLLMProvider.from_config(self.terminal.config)
336
+ _runtime_models = _runtime.list_models()
337
+ rich_models = [
338
+ {
339
+ "name": model_name,
340
+ "size_label": "",
341
+ "family": "",
342
+ "quant": "",
343
+ "execution": "local",
344
+ "remote_model": "",
345
+ "remote_host": "",
346
+ "context_window": 0,
347
+ "capabilities": [],
348
+ }
349
+ for model_name in _runtime_models
350
+ ]
351
+ ollama_err = None if _runtime.is_available() else f"{current_provider} unavailable"
352
+ except Exception as exc:
353
+ rich_models, ollama_err = [], str(exc)
354
+ else:
355
+ # Cloud catalog APIs vary widely and can be expensive. Keep the
356
+ # selected model visible and report credential readiness; users can
357
+ # switch directly with /model provider/model.
358
+ if _get_provider_key(current_provider):
359
+ rich_models = [{
360
+ "name": current_id,
361
+ "size_label": "",
362
+ "family": "",
363
+ "quant": "",
364
+ "execution": "remote",
365
+ "remote_model": current_id,
366
+ "remote_host": current_provider,
367
+ "context_window": 0,
368
+ "capabilities": [],
369
+ }]
370
+ ollama_err = None
371
+ else:
372
+ rich_models = []
373
+ ollama_err = f"API key missing · /apikey set {current_provider} <key>"
374
+ if current_provider != "ollama":
375
+ # Local catalogue entries are shown only when installed, whatever
376
+ # the current provider, so a cloud user can still switch to one.
377
+ try:
378
+ local_models, _ = detect_ollama_models_rich(ollama_url)
379
+ except Exception:
380
+ local_models = []
381
+ seen = {m["name"] for m in rich_models}
382
+ rich_models = rich_models + [m for m in local_models or [] if m.get("name") not in seen]
383
+ display_models = _installed_catalogue(rich_models)
384
+ installed_names = {m["name"] for m in rich_models}
385
+ aria_ids = {m["id"] for m in display_models.values()}
386
+ runtime_by_id = {m["name"]: m for m in rich_models}
387
+
388
+ # ── Build picker title (one line, shown inside arrow_select header) ──
389
+ _sel_model = _i18n("select_model")
390
+ _installed = _i18n("installed")
391
+ if ollama_err:
392
+ _picker_title = f"{_sel_model} [{current_provider}: {_actionable(current_provider, ollama_err)}]"
393
+ else:
394
+ n_local = sum(1 for m in rich_models if m.get("execution") != "remote")
395
+ n_remote = len(rich_models) - n_local
396
+ if current_provider == "ollama":
397
+ _picker_title = (
398
+ f"{_sel_model} Ollama {len(rich_models)} ready"
399
+ f" · {n_local} local · {n_remote} cloud"
400
+ )
401
+ else:
402
+ _picker_title = (
403
+ f"{_sel_model} {current_provider} {len(rich_models)} ready"
404
+ " · /model <provider>/<model>"
405
+ )
406
+
407
+ def _status_tag(mid: str, badge: str) -> str:
408
+ """Return runtime status, not a hard-coded catalogue badge."""
409
+ runtime = runtime_by_id.get(mid)
410
+ if runtime and runtime.get("execution") == "remote":
411
+ return "☁"
412
+ return "●" if runtime else "○"
413
+
414
+ # Get terminal width for safe label truncation
415
+ try:
416
+ _term_cols = os.get_terminal_size().columns
417
+ except Exception:
418
+ _term_cols = 80
419
+ if getattr(self.terminal, "_native_select", None) is not None:
420
+ _term_cols = 110 # the host's menu is about this wide; not a TTY to measure
421
+
422
+ def _cjk_width(s: str) -> int:
423
+ """Display-column width (CJK = 2 cols each)."""
424
+ w = 0
425
+ for ch in s:
426
+ cp = ord(ch)
427
+ w += 2 if (0x2E80 <= cp <= 0xA4CF or 0xAC00 <= cp <= 0xD7AF or
428
+ 0xFF01 <= cp <= 0xFF60 or 0x3000 <= cp <= 0x303F) else 1
429
+ return w
430
+
431
+ def _cjk_truncate(s: str, max_cols: int) -> str:
432
+ """Truncate s so its display width ≤ max_cols, adding … if cut."""
433
+ w, out = 0, ""
434
+ for ch in s:
435
+ cw = 2 if (0x2E80 <= ord(ch) <= 0xA4CF or
436
+ 0xAC00 <= ord(ch) <= 0xD7AF or
437
+ 0xFF01 <= ord(ch) <= 0xFF60 or
438
+ 0x3000 <= ord(ch) <= 0x303F) else 1
439
+ if w + cw > max_cols:
440
+ return out + "…"
441
+ out += ch
442
+ w += cw
443
+ return out
444
+
445
+ def _short_desc(m: dict) -> str:
446
+ """Single-line description with right-aligned meta tags."""
447
+ desc = m.get("description", "")
448
+ badge = m.get("badge", "")
449
+ extras = []
450
+ runtime = runtime_by_id.get(m["id"], {})
451
+ runtime_ctx = int(runtime.get("context_window") or 0)
452
+ runtime_caps = set(runtime.get("capabilities") or [])
453
+ if runtime_ctx:
454
+ extras.append(f"ctx={runtime_ctx//1024}K")
455
+ if "tools" in runtime_caps: extras.append("tools")
456
+ if "thinking" in runtime_caps: extras.append("think")
457
+ elif _get__HAS_MODEL_CAP():
458
+ cap = get_model_capability(m["id"])
459
+ extras.append(f"ctx={cap.context_window//1024}K")
460
+ if cap.tool_calls: extras.append("tools")
461
+ if cap.thinking: extras.append("think")
462
+ else:
463
+ extras.append(f"ctx={m.get('num_ctx', 8192)//1024}K")
464
+ if runtime.get("execution") == "remote":
465
+ extras.insert(0, "Cloud")
466
+ elif m["id"] in installed_names:
467
+ extras.insert(0, "Local")
468
+ elif badge in ("Fast", "Code", "Think"):
469
+ extras.insert(0, badge)
470
+ meta = " " + " · ".join(extras) if extras else ""
471
+ # prefix " N. ☁ ModelName " ≈ 24 cols; give description 60% of remaining
472
+ _prefix_cols = 24
473
+ _avail = max(30, _term_cols - _prefix_cols - len(meta) - 2)
474
+ _desc_budget = max(20, _avail * 3 // 4) # 75% of available → description
475
+ desc = _cjk_truncate(desc, _desc_budget)
476
+ return f"{desc}{meta}"
477
+
478
+ # Build option list (Codex: numbered, no separators within Aria section)
479
+ options: list = [] # (label_str, desc_str) for _arrow_select
480
+ all_ids: list = []
481
+
482
+ # ── Native frontend: pick in the host's menu, not from a printed list ─
483
+ # Arrow keys and Enter, the way Claude and ChatGPT do it. The list used
484
+ # to be printed and the user had to type a number back.
485
+ native_select = getattr(self.terminal, "_native_select", None)
486
+ if native_select is not None and not name:
487
+ await self._pick_model_in_host(
488
+ native_select, picker_title=_picker_title, current_id=current_id,
489
+ current_provider=current_provider, rich_models=rich_models,
490
+ aria_ids=aria_ids, status_tag=_status_tag, short_desc=_short_desc,
491
+ lang=_lang,
492
+ )
493
+ return
494
+
495
+ # ── Print numbered list only in non-interactive (-p) mode ────────────
496
+ # In interactive TTY mode the arrow picker below already shows all items.
497
+ # Printing twice causes the visual duplication seen in the session log.
498
+ _is_tty = sys.stdin.isatty()
499
+ idx_counter = 1
500
+ if not _is_tty:
501
+ # Non-interactive (-p mode): show static numbered list then return.
502
+ # The arrow picker cannot run without a TTY.
503
+ community_list = [cm for cm in rich_models if cm["name"] not in aria_ids]
504
+ # What /model <number> will count through, recorded as it is printed.
505
+ self._last_listed_community = [cm["name"] for cm in community_list]
506
+ self._last_listed_models = [m["id"] for m in display_models.values()] + self._last_listed_community
507
+ for key, m in display_models.items():
508
+ mid = m["id"]
509
+ is_cur = mid == current_id
510
+ status = _status_tag(mid, m.get("badge", ""))
511
+ cur_tag = " (current)" if is_cur else ""
512
+ desc = _short_desc(m)
513
+ line = f" {idx_counter}. {status} {m['name']:<14s} {desc}{cur_tag}"
514
+ self.context.console.print(line) if self.context.has_rich else print(line)
515
+ idx_counter += 1
516
+ if community_list:
517
+ self.context.console.print() if self.context.has_rich else print()
518
+ lbl = " Community (Ollama)"
519
+ self.context.console.print(f"[dim]{lbl}[/dim]") if self.context.has_rich else print(lbl)
520
+ for cm in community_list:
521
+ mid = cm["name"]
522
+ is_cur = mid == current_id
523
+ cur_tag = " (current)" if is_cur else ""
524
+ line = f" {idx_counter}. ● {mid}{cur_tag}"
525
+ self.context.console.print(line) if self.context.has_rich else print(line)
526
+ idx_counter += 1
527
+ self.context.console.print() if self.context.has_rich else print()
528
+ hint = "Use /model <number> from this list, or /model <id>. E.g. /model 1 or /model deepseek/deepseek-chat"
529
+ self.context.console.print(f" [dim]{hint}[/dim]") if self.context.has_rich else print(f" {hint}")
530
+ return
531
+
532
+ # ── Build compact options for _arrow_select ────────────────────────
533
+ # In TTY mode: include short description (static list is suppressed above).
534
+ # In non-TTY: descriptions already shown in static list, keep labels short.
535
+ num = 1
536
+ picker_models = (
537
+ list(display_models.values())
538
+ if current_provider == "ollama"
539
+ else [
540
+ {
541
+ "id": item["name"], "name": item["name"],
542
+ "description": (
543
+ "可用模型" if str(_lang).lower().startswith("zh")
544
+ else "available model"
545
+ ),
546
+ "num_ctx": item.get("context_window") or 8192,
547
+ }
548
+ for item in rich_models
549
+ ]
550
+ )
551
+ for m in picker_models:
552
+ mid = m["id"]
553
+ status = _status_tag(mid, m.get("badge", ""))
554
+ is_cur = " ✓" if mid == current_id else ""
555
+ if _is_tty:
556
+ desc_part = f" {_short_desc(m)}"
557
+ else:
558
+ desc_part = ""
559
+ label = f" {num}. {status} {m['name']}{is_cur}{desc_part}"
560
+ options.append((label, ""))
561
+ all_ids.append(mid)
562
+ num += 1
563
+
564
+ community = (
565
+ [cm for cm in rich_models if cm["name"] not in aria_ids]
566
+ if current_provider == "ollama" else []
567
+ )
568
+ if community:
569
+ _comm_label = _i18n("community_models")
570
+ options.append((f" ── {_comm_label} ──", ""))
571
+ all_ids.append(None)
572
+ for cm in community:
573
+ mid = cm["name"]
574
+ is_cur = " ◀" if mid == current_id else ""
575
+ options.append((f" {num}. ● {mid}{is_cur}", ""))
576
+ all_ids.append(mid)
577
+ num += 1
578
+
579
+ if ollama_err and not rich_models:
580
+ _unreach = _i18n("ollama_unreachable")
581
+ options.append((f" ── {_unreach} ──────────", ""))
582
+ all_ids.append(None)
583
+
584
+ if not any(model_id is not None for model_id in all_ids):
585
+ message = (
586
+ f"{current_provider}: "
587
+ f"{_actionable(current_provider, ollama_err) if ollama_err else 'no models available'}"
588
+ )
589
+ self.context.console.print(f"[yellow]{message}[/yellow]") if self.context.has_rich else print(message)
590
+ return
591
+
592
+ # ── Run thread-based arrow picker (short labels = no line wrap) ────
593
+ current_idx = next((i for i, mid in enumerate(all_ids) if mid == current_id), 0)
594
+
595
+ while True:
596
+ _controls_hint = (
597
+ "↑↓ Enter Esc/q 取消"
598
+ if str(_lang).lower().startswith("zh")
599
+ else "↑↓ Enter Esc/q Cancel"
600
+ )
601
+ choice = await _run_picker_in_thread(
602
+ options, current_idx,
603
+ _picker_title,
604
+ max_visible=min(9, len(options)),
605
+ controls_hint=_controls_hint,
606
+ )
607
+ if choice < 0:
608
+ _msg = _i18n("cancelled")
609
+ self.context.console.print(f"[dim]{_msg}[/dim]" if self.context.has_rich else _msg)
610
+ return
611
+ if all_ids[choice] is None:
612
+ current_idx = min(choice + 1, len(options) - 1)
613
+ continue
614
+ break
615
+
616
+ self._set_model_by_id(all_ids[choice], provider=current_provider)
617
+
618
+ async def _pick_model_in_host(self, native_select, *, picker_title, current_id, current_provider,
619
+ rich_models, aria_ids, status_tag, short_desc, lang):
620
+ """Offer every model in the frontend's menu and apply the choice.
621
+
622
+ One row per model: the name (with a mark for the current one and for
623
+ whether it is installed) and, beneath it, what it is for and its
624
+ limits. Aria's catalogue comes first, then community Ollama models.
625
+ """
626
+ zh = str(lang).lower().startswith("zh")
627
+ rows: list = [] # (model_id, label, help)
628
+ groups: list = []
629
+ catalogue = "Aria 精选" if zh else "Aria catalogue"
630
+ for m in _installed_catalogue(rich_models).values():
631
+ mid = m["id"]
632
+ tags = short_desc(m)
633
+ current = (" ✓ 当前" if zh else " ✓ current") if mid == current_id else ""
634
+ # The id leads the detail: several catalogue entries share a display name
635
+ # ("Qwen 2.5"), and the id is what tells them apart.
636
+ rows.append((mid, f"{status_tag(mid, m.get('badge', ''))} {m['name']}{current}", f"{mid} · {tags}"))
637
+ groups.append(catalogue)
638
+ if current_provider == "ollama":
639
+ community = "社区模型 (Ollama)" if zh else "Community models (Ollama)"
640
+ for cm in rich_models:
641
+ if cm["name"] in aria_ids:
642
+ continue
643
+ current = (" ✓ 当前" if zh else " ✓ current") if cm["name"] == current_id else ""
644
+ size = " · ".join(x for x in (cm.get("size_label"), cm.get("family"), cm.get("quant")) if x)
645
+ rows.append((cm["name"], f"● {cm['name']}{current}", size or community))
646
+ groups.append(community)
647
+ # Every provider that has a key lists what it serves today. A hand-kept
648
+ # catalogue goes stale; the provider's own list does not.
649
+ await self._add_cloud_models(rows, groups, current_id, zh)
650
+ if not rows:
651
+ return
652
+ start = next((i for i, row in enumerate(rows) if row[0] == current_id), 0)
653
+ options = [(label, help_text) for _mid, label, help_text in rows]
654
+ # select() blocks on the user's answer, so keep it off the event loop.
655
+ import asyncio as _asyncio
656
+
657
+ try:
658
+ choice = await _asyncio.to_thread(native_select, options, start, picker_title, picker=True, groups=groups)
659
+ except (KeyboardInterrupt, ValueError):
660
+ choice = -1
661
+ if not isinstance(choice, int) or not 0 <= choice < len(rows):
662
+ msg = "已取消,模型未改变。" if zh else "Cancelled; the model is unchanged."
663
+ self.context.console.print(f"[dim]{msg}[/dim]" if self.context.has_rich else msg)
664
+ return
665
+ chosen = rows[choice][0]
666
+ if current_provider == "ollama" and chosen in {m["name"] for m in rich_models}:
667
+ self._set_model_by_id(chosen, provider="ollama")
668
+ elif "/" in chosen:
669
+ # provider/model: the route that checks credentials and sets the provider.
670
+ await self.cmd_model(chosen)
671
+ else:
672
+ self._set_model_by_id(chosen)
673
+
674
+ async def _add_cloud_models(self, rows: list, groups: list, current_id: str, zh: bool) -> None:
675
+ """Append a section per provider with a key: what its model list endpoint returns now."""
676
+ import asyncio as _asyncio
677
+ from aria_code.apps.cli import cloud_models
678
+
679
+ def key_for(provider: str) -> str:
680
+ key = _get_provider_key(provider)
681
+ if not key and provider == "google":
682
+ key = os.environ.get("GEMINI_API_KEY", "")
683
+ return key
684
+
685
+ try:
686
+ found = await _asyncio.to_thread(cloud_models.collect, key_for)
687
+ except Exception:
688
+ return
689
+ listed = {row[0] for row in rows}
690
+ for provider, models in found.items():
691
+ heading = cloud_models.PROVIDERS[provider][0]
692
+ fresh = [x for x in models if f"{provider}/{x['id']}" not in listed]
693
+ if not fresh:
694
+ continue
695
+ group = f"{heading} · {len(fresh)}" + (" 个可用" if zh else " available")
696
+ for item in fresh:
697
+ full = f"{provider}/{item['id']}"
698
+ is_current = current_id in (full, item["id"])
699
+ tag = (" ✓ 当前" if zh else " ✓ current") if is_current else ""
700
+ detail = " · ".join(x for x in (full, item["detail"]) if x)
701
+ rows.append((full, f"☁ {item['name']}{tag}", detail))
702
+ groups.append(group)
703
+
704
+ def _set_model(self, key: str):
705
+ """Set model by _get_MODELS() key."""
706
+ m = _get_MODELS()[key]
707
+ self._set_model_by_id(m["id"])
708
+
709
+ def _set_model_by_id(self, model_id: str, provider: str | None = None):
710
+ """Set model by Ollama model ID (works for both built-in and community models)."""
711
+ matched = next((m for m in _get_MODELS().values() if m["id"] == model_id), None)
712
+ target_provider = provider or ("ollama" if matched is not None else self.terminal.config.get("local_provider", "ollama"))
713
+ if target_provider == "ollama":
714
+ try:
715
+ from aria_code.providers.ollama_models import ollama_base_url, resolve_model_sync
716
+ model_id = resolve_model_sync(ollama_base_url(self.terminal.config), model_id)
717
+ except ValueError as exc:
718
+ self.context.console.print(str(exc))
719
+ return
720
+ self.terminal.config["model"] = model_id
721
+ self.terminal.config["local_provider"] = target_provider
722
+ self.terminal.config["local_mode"] = (
723
+ self.terminal.config["local_provider"]
724
+ in {"ollama", "lmstudio", "vllm", "llamacpp", "jan", "custom"}
725
+ )
726
+ self.terminal.config["backend_chat"] = False
727
+ self.terminal._actual_model = None # reset: new config model, no known fallback yet
728
+ self.context.save_config(self.terminal.config)
729
+ # Pretty label
730
+ for m in _get_MODELS().values():
731
+ if m["id"] == model_id:
732
+ runtime = "Ollama Cloud" if m.get("badge") == "Cloud" else "Ollama"
733
+ if self.context.has_rich:
734
+ self.context.console.print(f"[bold]Model:[/bold] [bold]{m['name']} {m['version']}[/bold] "
735
+ f"[dim]· {runtime}[/dim]")
736
+ else:
737
+ print(f"Model: {m['name']} {m['version']} ({m['tag']})")
738
+ return
739
+ # Community / unknown model
740
+ if self.context.has_rich:
741
+ self.context.console.print(f"[bold]Model:[/bold] [bold]{model_id}[/bold] [dim](local)[/dim]")
742
+ else:
743
+ print(f"Model: {model_id} (local)")
744
+
745
+ def cmd_thinking(self, args: str):
746
+ mode = args.strip().lower()
747
+
748
+ # Direct set: /thinking on
749
+ if mode in ("on", "thinking"):
750
+ self.terminal.config["thinking_mode"] = "thinking"
751
+ elif mode in ("off", "instant"):
752
+ self.terminal.config["thinking_mode"] = "instant"
753
+ elif mode == "auto":
754
+ self.terminal.config["thinking_mode"] = "auto"
755
+ elif mode:
756
+ # Unknown mode, show picker
757
+ pass
758
+ else:
759
+ # Interactive picker
760
+ current = self.terminal.config.get("thinking_mode", "auto")
761
+ mode_keys = list(_get_THINKING_MODES().keys())
762
+ current_idx = mode_keys.index(current) if current in mode_keys else 0
763
+ options = [(info["label"], info["description"]) for info in _get_THINKING_MODES().values()]
764
+ choice = _arrow_select(options, selected=current_idx, title="Thinking Mode")
765
+ if 0 <= choice < len(mode_keys):
766
+ self.terminal.config["thinking_mode"] = mode_keys[choice]
767
+ else:
768
+ if self.context.has_rich:
769
+ self.context.console.print("[dim]No change[/dim]")
770
+ else:
771
+ print("No change")
772
+ return
773
+
774
+ self.context.save_config(self.terminal.config)
775
+ result = self.terminal.config["thinking_mode"]
776
+ info = _get_THINKING_MODES().get(result, {})
777
+ if self.context.has_rich:
778
+ self.context.console.print(f"[green]Thinking: {info.get('label', result)}[/green] [dim]{info.get('description', '')}[/dim]")
779
+ else:
780
+ print(f"Thinking: {result}")
781
+
782
+ def cmd_skills(self, args: str):
783
+ """List all available skills grouped by category."""
784
+ import shlex
785
+
786
+ raw_parts = shlex.split(args.strip()) if args.strip() else []
787
+ if raw_parts and raw_parts[0].lower() == "add":
788
+ if len(raw_parts) < 2:
789
+ message = "Usage: /skills add owner/repository [--ref tag-or-sha]"
790
+ self.context.console.print(f"[yellow]{message}[/yellow]") if self.context.has_rich else print(message)
791
+ return
792
+ ref = ""
793
+ if "--ref" in raw_parts:
794
+ ref_index = raw_parts.index("--ref")
795
+ if ref_index + 1 >= len(raw_parts):
796
+ message = "--ref requires a tag, branch, or commit"
797
+ self.context.console.print(f"[red]{message}[/red]") if self.context.has_rich else print(message)
798
+ return
799
+ ref = raw_parts[ref_index + 1]
800
+ try:
801
+ from aria_code.packages.aria_skills import install_catalog
802
+
803
+ installed = install_catalog(raw_parts[1], ref=ref)
804
+ message = (
805
+ f"Installed {installed.source.full_name} at {installed.revision[:12]} "
806
+ f"with {len(installed.skills)} verified skills"
807
+ )
808
+ self.context.console.print(f"[green]{message}[/green]") if self.context.has_rich else print(message)
809
+ except Exception as exc:
810
+ message = f"Skill catalog install failed: {exc}"
811
+ self.context.console.print(f"[red]{message}[/red]") if self.context.has_rich else print(message)
812
+ return
813
+
814
+ categories = {}
815
+ for s in _get_SKILLS():
816
+ cat = s["category"]
817
+ if cat not in categories:
818
+ categories[cat] = []
819
+ categories[cat].append(s)
820
+
821
+ cat_labels = {
822
+ "research": "Research",
823
+ "analysis": "Analysis",
824
+ "strategy": "Strategy",
825
+ "risk": "Risk Management",
826
+ "quant": "Quantitative",
827
+ "crypto": "Crypto",
828
+ "tools": "Tools",
829
+ "code": "Code Generation",
830
+ }
831
+ try:
832
+ from aria_code.packages.aria_skills import (
833
+ discover_external_skills,
834
+ recent_skill_activation_traces,
835
+ )
836
+
837
+ external_skills = discover_external_skills()
838
+ activation_traces = recent_skill_activation_traces()
839
+ except Exception:
840
+ external_skills = []
841
+ activation_traces = []
842
+
843
+ subcommand = args.strip().lower()
844
+ if subcommand in {"trace", "traces"}:
845
+ if self.context.has_rich:
846
+ self.context.console.print("\n [bold]Skill Activation Trace[/bold]")
847
+ if not activation_traces:
848
+ self.context.console.print(" [dim]No skill activations recorded in this session.[/dim]\n")
849
+ for trace in activation_traces:
850
+ state = "[green]active[/green]" if trace.activated else "[red]blocked[/red]"
851
+ self.context.console.print(
852
+ f" {state} [bold]{trace.qualified_name}[/bold] "
853
+ f"[dim]{trace.reason} · score {trace.score:g} · {trace.integrity}[/dim]"
854
+ )
855
+ self.context.console.print()
856
+ else:
857
+ print("\nSkill Activation Trace")
858
+ for trace in activation_traces:
859
+ state = "active" if trace.activated else "blocked"
860
+ print(
861
+ f" {state} {trace.qualified_name} {trace.reason} "
862
+ f"score={trace.score:g} integrity={trace.integrity}"
863
+ )
864
+ return
865
+
866
+ if subcommand in {"doctor", "status"}:
867
+ if self.context.has_rich:
868
+ self.context.console.print("\n [bold]Portable Skill Doctor[/bold]")
869
+ for skill in external_skills:
870
+ style = "green" if skill.integrity == "verified" else "yellow"
871
+ self.context.console.print(
872
+ f" [{style}]{skill.integrity:8s}[/{style}] "
873
+ f"[bold]{skill.qualified_name}[/bold] "
874
+ f"[dim]v{skill.plugin_version or '-'} · "
875
+ f"scripts={skill.policy.script_execution}[/dim]"
876
+ )
877
+ self.context.console.print()
878
+ else:
879
+ print("\nPortable Skill Doctor")
880
+ for skill in external_skills:
881
+ print(
882
+ f" {skill.integrity:8s} {skill.qualified_name} "
883
+ f"v{skill.plugin_version or '-'} scripts={skill.policy.script_execution}"
884
+ )
885
+ return
886
+
887
+ if self.context.has_rich:
888
+ self.context.console.print()
889
+ for cat, skills in categories.items():
890
+ label = cat_labels.get(cat, cat.title())
891
+ self.context.console.print(f" [bold]{label}[/bold]")
892
+ for s in skills:
893
+ args_hint = f" [dim]{s.get('args', '')}[/dim]" if s.get("args") else ""
894
+ self.context.console.print(f" [bold]{s['command']:20s}[/bold][dim]{s['description']}[/dim]{args_hint}")
895
+ self.context.console.print()
896
+
897
+ if external_skills:
898
+ self.context.console.print(" [bold]Portable Workflows[/bold]")
899
+ for skill in external_skills:
900
+ self.context.console.print(
901
+ f" [bold]${skill.qualified_name}[/bold] "
902
+ f"[dim]v{skill.plugin_version or '-'} · {skill.integrity}[/dim]\n"
903
+ f" [dim]{skill.description}[/dim]"
904
+ )
905
+ self.context.console.print()
906
+
907
+ self.context.console.print("[dim] Type a skill command to execute, e.g. /deep-analysis AAPL[/dim]\n")
908
+ else:
909
+ print("\nSkills:")
910
+ for cat, skills in categories.items():
911
+ label = cat_labels.get(cat, cat.title())
912
+ print(f"\n [{label}]")
913
+ for s in skills:
914
+ print(f" {s['command']:20s} {s['description']}")
915
+ if external_skills:
916
+ print("\n [Portable Workflows]")
917
+ for skill in external_skills:
918
+ print(
919
+ f" ${skill.qualified_name} v{skill.plugin_version or '-'} "
920
+ f"{skill.integrity} {skill.description}"
921
+ )
922
+
923
+ async def _execute_skill(self, skill: dict, args: str):
924
+ """Execute a skill by expanding its prompt template and sending to AI."""
925
+ parts = args.strip().upper().split() if args.strip() else []
926
+ cmd = skill["command"]
927
+
928
+ # Skill invocation header — matches the ⏺ tool-call rhythm
929
+ _skill_name = skill.get("name") or cmd.lstrip("/")
930
+ _arg_hint = f" [dim]{args.strip()}[/dim]" if args.strip() else ""
931
+ if self.context.has_rich:
932
+ self.context.console.print(f"\n [#C08050]⏺[/#C08050] [bold]技能 · {_skill_name}[/bold]{_arg_hint}")
933
+ else:
934
+ print(f"\n ⏺ 技能 · {_skill_name} {args.strip()}")
935
+
936
+ # Build the prompt from template
937
+ template = skill["prompt"]
938
+
939
+ if cmd == "/deep-analysis":
940
+ symbol = parts[0] if parts else "AAPL"
941
+ prompt = template.format(symbol=symbol)
942
+
943
+ elif cmd == "/trade-idea":
944
+ context = f" in {' '.join(parts)}" if parts else " in the US market"
945
+ prompt = template.format(context=context)
946
+
947
+ elif cmd == "/risk-report":
948
+ if parts:
949
+ symbols = ", ".join(parts)
950
+ else:
951
+ symbols = ", ".join(self.terminal.config.get("watchlist", ["AAPL", "MSFT", "GOOGL"]))
952
+ prompt = template.format(symbols=symbols)
953
+
954
+ elif cmd == "/factor-screen":
955
+ factor = " ".join(parts).lower() if parts else "momentum"
956
+ prompt = template.format(factor=factor)
957
+
958
+ elif cmd == "/backtest-report":
959
+ strategy = parts[0].lower() if len(parts) > 0 else "momentum"
960
+ symbol = parts[1] if len(parts) > 1 else "SPY"
961
+ start = parts[2] if len(parts) > 2 else "2023-01-01"
962
+ end = parts[3] if len(parts) > 3 else "2025-01-01"
963
+ prompt = template.format(strategy=strategy, symbol=symbol, start=start, end=end)
964
+
965
+ elif cmd == "/morning-brief":
966
+ extra = f"\nFocus on: {' '.join(parts)}" if parts else ""
967
+ prompt = template.format(extra=extra)
968
+
969
+ elif cmd == "/macro-outlook":
970
+ context = f" for {' '.join(parts)}" if parts else " for the US and global economy"
971
+ prompt = template.format(context=context)
972
+
973
+ elif cmd == "/crypto-scan":
974
+ extra = f"\nFocus on: {' '.join(parts)}" if parts else ""
975
+ prompt = template.format(extra=extra)
976
+
977
+ elif cmd == "/watchlist-scan":
978
+ symbols = ", ".join(self.terminal.config.get("watchlist", ["AAPL", "MSFT", "GOOGL"]))
979
+ prompt = template.format(symbols=symbols)
980
+
981
+ elif cmd == "/sector-rotation":
982
+ prompt = template
983
+
984
+ elif cmd == "/gen-strategy":
985
+ strategy = parts[0].lower() if len(parts) > 0 else "momentum"
986
+ symbol = parts[1] if len(parts) > 1 else "SPY"
987
+ prompt = template.format(strategy=strategy, symbol=symbol)
988
+
989
+ elif cmd == "/gen-analysis":
990
+ topic = " ".join(parts[:2]).lower() if parts else "technical analysis"
991
+ symbols = ", ".join(parts[2:]) if len(parts) > 2 else "SPY"
992
+ prompt = template.format(topic=topic, symbols=symbols)
993
+
994
+ elif cmd == "/gen-bot":
995
+ exchange = parts[0].lower() if len(parts) > 0 else "binance"
996
+ strategy = " ".join(parts[1:]).lower() if len(parts) > 1 else "grid trading"
997
+ prompt = template.format(exchange=exchange, strategy=strategy)
998
+
999
+ else:
1000
+ prompt = template
1001
+
1002
+ # Show skill activation
1003
+ if self.context.has_rich:
1004
+ tools = ", ".join(skill.get("tools_hint", [])[:3])
1005
+ self.context.console.print(f"[bold]Skill:[/bold] [bold]{skill['name']}[/bold] [dim]tools: {tools}[/dim]")
1006
+ else:
1007
+ print(f"Skill: {skill['name']}")
1008
+
1009
+ await self.terminal.send_message(prompt)
1010
+
1011
+ def cmd_tools(self, args: str):
1012
+ if self.context.has_rich:
1013
+ self.context.console.print()
1014
+ self.context.console.print(" [bold]Local Tools[/bold] [dim](Code Agent)[/dim]")
1015
+ for i, (name, (_, desc)) in enumerate(_get_LOCAL_TOOLS().items(), 1):
1016
+ self.context.console.print(f" [bold]{name:28s}[/bold][dim]{desc}[/dim]")
1017
+ self.context.console.print()
1018
+
1019
+ self.context.console.print(f" [bold]Remote Tools[/bold] [dim]({len(_get_ARIA_TOOLS())})[/dim]")
1020
+ for i, (name, desc) in enumerate(_get_ARIA_TOOLS(), 1):
1021
+ self.context.console.print(f" [bold]{name:28s}[/bold][dim]{desc}[/dim]")
1022
+ self.context.console.print()
1023
+ else:
1024
+ print("\nLocal Tools (Code Agent):")
1025
+ for i, (name, (_, desc)) in enumerate(_get_LOCAL_TOOLS().items(), 1):
1026
+ print(f" {i:2d}. {name:30s} {desc}")
1027
+ print("\nRemote Aria Tools (22):")
1028
+ for i, (name, desc) in enumerate(_get_ARIA_TOOLS(), 1):
1029
+ print(f" {i:2d}. {name:30s} {desc}")
1030
+
1031
+ async def cmd_collab(self, args: str):
1032
+ """Consult ChatGPT/OpenAI and Claude/Anthropic through their APIs.
1033
+
1034
+ Consumer-app subscriptions deliberately are not treated as credentials.
1035
+ This keeps external model use explicit, attributable, and opt-in.
1036
+ """
1037
+ from aria_code.apps.cli.providers.collaboration import (
1038
+ collaboration_readiness, consult, resolve_collaborator,
1039
+ )
1040
+
1041
+ parts = args.strip().split(maxsplit=1)
1042
+ action = parts[0].lower() if parts else "status"
1043
+ rest = parts[1].strip() if len(parts) > 1 else ""
1044
+ config = self.terminal.config
1045
+
1046
+ if action in ("status", "help"):
1047
+ rows = collaboration_readiness(config, _get_provider_key)
1048
+ if self.context.has_rich:
1049
+ self.context.console.print()
1050
+ self.context.console.print(" [bold]多模型协作(API)[/bold]")
1051
+ self.context.console.print(" [dim]ChatGPT/Claude 桌面订阅不会被读取;请配置各自 API key。[/dim]")
1052
+ self.context.console.print()
1053
+ for row in rows:
1054
+ icon = "[green]●[/green]" if row["configured"] else "[dim]○[/dim]"
1055
+ state = "[green]已就绪[/green]" if row["configured"] else "[dim]未配置[/dim]"
1056
+ self.context.console.print(
1057
+ f" {icon} [bold]{row['label']:18s}[/bold] {state}"
1058
+ f" [dim]{row['model']}[/dim]"
1059
+ )
1060
+ self.context.console.print()
1061
+ self.context.console.print(" [dim]/apikey set openai <key> 配置 ChatGPT/OpenAI API[/dim]")
1062
+ self.context.console.print(" [dim]/apikey set anthropic <key> 配置 Claude API[/dim]")
1063
+ self.context.console.print(" [dim]/collab use chatgpt [model] 将主对话切换为 OpenAI[/dim]")
1064
+ self.context.console.print(" [dim]/collab use claude [model] 将主对话切换为 Claude[/dim]")
1065
+ self.context.console.print(" [dim]/collab model <名称> <模型> 设置协作咨询使用的模型[/dim]")
1066
+ self.context.console.print(" [dim]/collab ask <问题> 并行获取两份独立意见(会消耗 API 配额)[/dim]")
1067
+ self.context.console.print()
1068
+ else:
1069
+ print("Multi-model collaboration (API; desktop subscriptions are not API keys):")
1070
+ for row in rows:
1071
+ state = "ready" if row["configured"] else "not configured"
1072
+ print(f" {'✓' if row['configured'] else '○'} {row['label']}: {state} ({row['model']})")
1073
+ print(" /collab use chatgpt [model] | /collab use claude [model]")
1074
+ print(" /collab model <name> <model>")
1075
+ print(" /collab ask <prompt>")
1076
+ return
1077
+
1078
+ if action == "model":
1079
+ tokens = rest.split(maxsplit=1)
1080
+ target = resolve_collaborator(tokens[0] if tokens else "", config)
1081
+ model = tokens[1].strip() if len(tokens) > 1 else ""
1082
+ if target is None or not model:
1083
+ msg = "Usage: /collab model chatgpt <model> | /collab model claude <model>"
1084
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1085
+ return
1086
+ config[f"collab_{target.alias}_model"] = model
1087
+ self.context.save_config(config)
1088
+ msg = f"✓ {target.label} 协作模型已设为 {model}"
1089
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
1090
+ return
1091
+
1092
+ if action == "use":
1093
+ tokens = rest.split(maxsplit=1)
1094
+ name = tokens[0] if tokens else ""
1095
+ target = resolve_collaborator(name, config)
1096
+ if target is None:
1097
+ msg = "Usage: /collab use chatgpt [model] | /collab use claude [model]"
1098
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1099
+ return
1100
+ if not _get_provider_key(target.provider):
1101
+ msg = f"⚠ {target.label} API key 未配置:/apikey set {target.provider} <key>"
1102
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1103
+ return
1104
+ model = tokens[1].strip() if len(tokens) > 1 else target.default_model
1105
+ await self.cmd_model(f"{target.provider}/{model}")
1106
+ return
1107
+
1108
+ if action == "ask":
1109
+ if not rest:
1110
+ msg = "Usage: /collab ask <问题>"
1111
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1112
+ return
1113
+ targets = [
1114
+ target for name in ("chatgpt", "claude")
1115
+ if (target := resolve_collaborator(name, config)) is not None
1116
+ and _get_provider_key(target.provider)
1117
+ ]
1118
+ if not targets:
1119
+ msg = "没有已配置的协作模型。先运行 /collab status。"
1120
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1121
+ return
1122
+ notice = f"正在向 {len(targets)} 个模型征询独立意见…"
1123
+ self.context.console.print(f"[dim]{notice}[/dim]") if self.context.has_rich else print(notice)
1124
+ from aria_code.providers.llm.base import Message
1125
+ from aria_code.providers.llm.registry import get_provider
1126
+
1127
+ results = await consult(
1128
+ rest, targets, get_provider=get_provider, message_factory=Message,
1129
+ timeout=float(config.get("collab_timeout", 60.0)),
1130
+ )
1131
+ for result in results:
1132
+ label = str(result["label"])
1133
+ model = str(result["model"])
1134
+ if result["success"]:
1135
+ if self.context.has_rich:
1136
+ from rich.panel import Panel
1137
+ self.context.console.print(Panel(
1138
+ str(result["response"]), title=f"{label} · {model}",
1139
+ border_style="cyan", padding=(0, 1),
1140
+ ))
1141
+ else:
1142
+ print(f"\n[{label} · {model}]\n{result['response']}")
1143
+ else:
1144
+ msg = f"{label} 未完成:{result['error'] or 'empty response'}"
1145
+ self.context.console.print(f"[yellow]⚠ {msg}[/yellow]") if self.context.has_rich else print(f"⚠ {msg}")
1146
+ return
1147
+
1148
+ msg = "Usage: /collab [status|model|use|ask]"
1149
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1150
+
1151
+ async def cmd_apikey(self, args: str):
1152
+ """Manage Cloud API keys.
1153
+
1154
+ Usage:
1155
+ /apikey — 交互式向导:选择 provider → 输入 key → 测试连接
1156
+ /apikey set <p> <k> — 直接保存
1157
+ /apikey list — 列出所有已配置 key
1158
+ /apikey remove <p> — 删除 key
1159
+ /apikey test <p> — 测试连接
1160
+ """
1161
+ parts = args.strip().split()
1162
+ sub = parts[0].lower() if parts else ""
1163
+
1164
+ # ── 无参数 or "add" → 交互式向导 ─────────────────────────────────────
1165
+ if not sub or sub in ("add", "wizard"):
1166
+ await self._cmd_apikey_wizard()
1167
+ return
1168
+
1169
+ pjson = _load_providers_json() # dict of {provider: {api_key, base_url, ...}}
1170
+
1171
+ if sub == "set-url":
1172
+ # /apikey set-url <provider> <base_url>
1173
+ # 允许自定义端点(中转代理、国内镜像等),示例:
1174
+ # /apikey set-url openai https://my-proxy.com
1175
+ # /apikey set-url siliconflow https://api.siliconflow.cn
1176
+ if len(parts) < 3:
1177
+ msg = "Usage: /apikey set-url <provider> <base_url>"
1178
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
1179
+ return
1180
+ provider = parts[1].lower()
1181
+ url = parts[2].rstrip("/")
1182
+ entry = pjson.get(provider, {})
1183
+ entry["base_url"] = url
1184
+ pjson[provider] = entry
1185
+ _save_providers_json(pjson)
1186
+ msg = f"✓ {provider.capitalize()} base_url 已更新: {url}"
1187
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
1188
+ return
1189
+
1190
+ if sub == "set":
1191
+ if len(parts) < 3:
1192
+ msg = ("Usage: /apikey set <provider> <key> (e.g. /apikey set deepseek sk-...)\n"
1193
+ " /apikey set-url <provider> <base_url> (自定义代理端点)")
1194
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
1195
+ return
1196
+ provider = parts[1].lower()
1197
+ key = parts[2]
1198
+ _all_known = set(_get__PROVIDER_KEY_MAP()) | set(_get__DATA_KEY_MAP()) | set(_get__PROVIDER_BASE_URLS())
1199
+ if provider not in _all_known:
1200
+ known_llm = ", ".join(sorted(_get__PROVIDER_KEY_MAP().keys()))
1201
+ known_data = ", ".join(sorted(_get__DATA_KEY_MAP().keys()))
1202
+ msg = (f"Unknown provider '{provider}'.\n"
1203
+ f" LLM providers: {known_llm}\n"
1204
+ f" Data services: {known_data}")
1205
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1206
+ return
1207
+
1208
+ # ── Data service key ──────────────────────────────────────────────
1209
+ if provider in _get__DATA_KEY_MAP():
1210
+ _save_data_key(provider, key)
1211
+ env_var = _get__DATA_KEY_MAP()[provider]
1212
+ os.environ[env_var] = key # take effect immediately
1213
+ masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
1214
+ signup = _get__DATA_SIGNUP_URLS().get(provider, "")
1215
+ msg = f"✓ {provider.capitalize()} 数据服务 key 已保存 ({masked})"
1216
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
1217
+ return
1218
+
1219
+ # ── LLM provider key (original logic) ────────────────────────────
1220
+ # Persist to providers.json
1221
+ entry = pjson.get(provider, {})
1222
+ entry["api_key"] = key
1223
+ if provider in _get__PROVIDER_BASE_URLS():
1224
+ entry.setdefault("base_url", _get__PROVIDER_BASE_URLS()[provider])
1225
+ pjson[provider] = entry
1226
+ _save_providers_json(pjson)
1227
+ # Also set in current process env so it works immediately
1228
+ env_var = _get__PROVIDER_KEY_MAP().get(provider)
1229
+ if env_var:
1230
+ os.environ[env_var] = key
1231
+ masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
1232
+ msg = f"✓ {provider.capitalize()} API key 已保存 ({masked})"
1233
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
1234
+
1235
+ elif sub == "list":
1236
+ _LLM_ORDER = [
1237
+ # 国际
1238
+ "deepseek", "anthropic", "openai", "google", "xai",
1239
+ "groq", "mistral", "cohere", "perplexity", "together",
1240
+ # 国内
1241
+ "siliconflow", "dashscope", "moonshot", "zhipu",
1242
+ "baidu", "bytedance", "minimax", "stepfun", "01ai",
1243
+ ]
1244
+ _DATA_ORDER = ["finnhub", "alphavantage", "twelvedata", "polygon",
1245
+ "fmp", "newsapi", "coingecko", "tavily", "brave"]
1246
+ data_configured = _load_data_keys()
1247
+
1248
+ if self.context.has_rich:
1249
+ from rich.table import Table
1250
+ from rich import box as _rbox
1251
+ self.context.console.print()
1252
+ self.context.console.print(" [bold]🤖 LLM 服务 Keys[/bold] [dim]— /apikey 进入向导[/dim]")
1253
+ self.context.console.print()
1254
+ for prov in _LLM_ORDER:
1255
+ env_var = _get__PROVIDER_KEY_MAP().get(prov, "")
1256
+ key_val = os.getenv(env_var or "") or pjson.get(prov, {}).get("api_key", "")
1257
+ desc = _get__PROVIDER_DESC().get(prov, "")
1258
+ if key_val:
1259
+ masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
1260
+ self.context.console.print(f" [green]●[/green] [green]{prov:<14}[/green] {masked} [dim]{desc}[/dim]")
1261
+ else:
1262
+ self.context.console.print(f" [dim]○ {prov:<14} 未配置 {desc}[/dim]")
1263
+ self.context.console.print()
1264
+ self.context.console.print(" [bold]📊 数据服务 Keys[/bold] [dim]— 后端离线时直连数据源[/dim]")
1265
+ self.context.console.print()
1266
+ for svc in _DATA_ORDER:
1267
+ key_val = data_configured.get(svc, "")
1268
+ desc = _get__PROVIDER_DESC().get(svc, "")
1269
+ if key_val:
1270
+ masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
1271
+ self.context.console.print(f" [green]●[/green] [green]{svc:<14}[/green] {masked} [dim]{desc}[/dim]")
1272
+ else:
1273
+ self.context.console.print(f" [dim]○ {svc:<14} 未配置 {desc}[/dim]")
1274
+ self.context.console.print()
1275
+ self.context.console.print(" [dim]提示: /apikey 进入交互向导 · /apikey test <provider> 测试连接[/dim]")
1276
+ self.context.console.print()
1277
+ else:
1278
+ print("\n LLM Providers:")
1279
+ for prov in _LLM_ORDER:
1280
+ env_var = _get__PROVIDER_KEY_MAP().get(prov, "")
1281
+ key_val = os.getenv(env_var or "") or pjson.get(prov, {}).get("api_key", "")
1282
+ status = key_val[:6] + "****" if key_val else "未配置"
1283
+ print(f" {prov:14s} {status}")
1284
+ print("\n Data Services:")
1285
+ for svc in _DATA_ORDER:
1286
+ key_val = data_configured.get(svc, "")
1287
+ status = key_val[:6] + "****" if key_val else "未配置"
1288
+ print(f" {svc:16s} {status}")
1289
+
1290
+ elif sub == "remove":
1291
+ if len(parts) < 2:
1292
+ self.context.console.print("[dim]Usage: /apikey remove <provider>[/dim]") if self.context.has_rich else print("Usage: /apikey remove <provider>")
1293
+ return
1294
+ provider = parts[1].lower()
1295
+ # LLM section
1296
+ if provider in pjson:
1297
+ pjson[provider].pop("api_key", None)
1298
+ if not pjson[provider]:
1299
+ del pjson[provider]
1300
+ _save_providers_json(pjson)
1301
+ # Data section
1302
+ if provider in _get__DATA_KEY_MAP():
1303
+ try:
1304
+ if _get_PROVIDERS_FILE().exists():
1305
+ raw = json.loads(_get_PROVIDERS_FILE().read_text(encoding="utf-8"))
1306
+ if provider in raw.get("data", {}):
1307
+ del raw["data"][provider]
1308
+ from aria_code.packages.aria_core.secure_file import write_secret_json
1309
+ write_secret_json(_get_PROVIDERS_FILE(), raw)
1310
+ except Exception as _e:
1311
+ logger.debug("apikey delete from file failed: %s", _e)
1312
+ # Clear from env
1313
+ env_var = _get__PROVIDER_KEY_MAP().get(provider) or _get__DATA_KEY_MAP().get(provider)
1314
+ if env_var and env_var in os.environ:
1315
+ del os.environ[env_var]
1316
+ msg = f"✓ {provider.capitalize()} key 已删除"
1317
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
1318
+
1319
+ elif sub == "test":
1320
+ if len(parts) < 2:
1321
+ self.context.console.print("[dim]Usage: /apikey test <provider>[/dim]") if self.context.has_rich else print("Usage: /apikey test <provider>")
1322
+ return
1323
+ provider = parts[1].lower()
1324
+ key = _get_provider_key(provider) or _load_data_keys().get(provider, "")
1325
+ if not key:
1326
+ msg = f"⚠ {provider} API key 未配置,先运行 /apikey {provider}"
1327
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1328
+ return
1329
+ self.context.console.print(f"[dim] 正在测试 {provider}…[/dim]") if self.context.has_rich else print(f" 测试 {provider}…")
1330
+ import asyncio as _aio
1331
+ loop = _aio.get_event_loop()
1332
+ ok, result_msg = await loop.run_in_executor(None, _test_api_key, provider, key)
1333
+ color = "green" if ok else "yellow"
1334
+ self.context.console.print(f" [{color}]{result_msg}[/{color}]") if self.context.has_rich else print(f" {result_msg}")
1335
+
1336
+ else:
1337
+ self.context.console.print("[dim]Usage: /apikey [set|list|remove|test] — 或直接 /apikey 进入向导[/dim]") if self.context.has_rich else print("Usage: /apikey [set|list|remove|test]")
1338
+
1339
+ async def _cmd_apikey_wizard(self):
1340
+ """交互式 API Key 配置向导:选 provider → 查看指引 → 输入 key → 测试连接。"""
1341
+ import getpass as _getpass
1342
+
1343
+ pjson = _load_providers_json()
1344
+ data_cfg = _load_data_keys()
1345
+
1346
+ def _is_configured(name: str) -> bool:
1347
+ env = _get__PROVIDER_KEY_MAP().get(name) or _get__DATA_KEY_MAP().get(name)
1348
+ if env and os.getenv(env):
1349
+ return True
1350
+ if name in pjson and pjson[name].get("api_key"):
1351
+ return True
1352
+ if name in data_cfg:
1353
+ return True
1354
+ return False
1355
+
1356
+ # ── 分组构建 picker ───────────────────────────────────────────────────
1357
+ _LLM_ORDER = [
1358
+ # 国际
1359
+ "deepseek", "anthropic", "openai", "google", "xai",
1360
+ "groq", "mistral", "cohere", "perplexity", "together",
1361
+ # 国内
1362
+ "siliconflow", "dashscope", "moonshot", "zhipu",
1363
+ "baidu", "bytedance", "minimax", "stepfun", "01ai",
1364
+ ]
1365
+ _DATA_ORDER = ["finnhub", "alphavantage", "twelvedata", "polygon",
1366
+ "fmp", "newsapi", "coingecko", "tavily", "brave"]
1367
+
1368
+ all_items = [] # (label, desc, key_name | None)
1369
+
1370
+ all_items.append(("─── 🤖 LLM 服务 (对话·分析·推理) ", "", None))
1371
+ for k in _LLM_ORDER:
1372
+ dot = "[green]●[/green]" if _is_configured(k) else "[dim]○[/dim]"
1373
+ desc = _get__PROVIDER_DESC().get(k, "")
1374
+ configured_tag = " ✓" if _is_configured(k) else ""
1375
+ all_items.append((f" {k:<14}{configured_tag}", desc, k))
1376
+
1377
+ all_items.append(("─── 📊 数据服务 (行情·财报·新闻) ", "", None))
1378
+ for k in _DATA_ORDER:
1379
+ desc = _get__PROVIDER_DESC().get(k, "")
1380
+ configured_tag = " ✓" if _is_configured(k) else ""
1381
+ all_items.append((f" {k:<14}{configured_tag}", desc, k))
1382
+
1383
+ picker_opts = [(label, desc) for label, desc, _ in all_items]
1384
+ sep_indices = {i for i, (_, _, key) in enumerate(all_items) if key is None}
1385
+ key_at = {i: key for i, (_, _, key) in enumerate(all_items) if key}
1386
+
1387
+ # 默认选中第一个真实条目
1388
+ first_real = next(i for i in range(len(all_items)) if i not in sep_indices)
1389
+ selected = first_real
1390
+
1391
+ while True:
1392
+ self.context.console.print() if self.context.has_rich else None
1393
+
1394
+ if self.context.has_rich:
1395
+ from rich.panel import Panel as _Panel
1396
+ from rich import box as _rbox
1397
+ self.context.console.print(_Panel(
1398
+ " ↑↓ 上下选择 · Enter 确认 · ESC/q 退出向导\n"
1399
+ " [green]●[/green] 已配置 [dim]○[/dim] 未配置 ✓ 表示 key 已存在",
1400
+ border_style="dim", box=_rbox.ROUNDED, padding=(0, 2),
1401
+ ))
1402
+
1403
+ idx = _arrow_select(picker_opts, selected=selected, title="选择要配置的 Provider", max_visible=20)
1404
+
1405
+ if idx < 0:
1406
+ self.context.console.print("[dim]已退出向导[/dim]") if self.context.has_rich else print("已退出")
1407
+ return
1408
+
1409
+ if idx in sep_indices:
1410
+ nxt = next((i for i in range(idx + 1, len(all_items)) if i not in sep_indices), first_real)
1411
+ selected = nxt
1412
+ continue
1413
+
1414
+ provider = key_at[idx]
1415
+ selected = idx
1416
+
1417
+ # ── 显示获取指引 ──────────────────────────────────────────────────
1418
+ guide = _get__PROVIDER_GUIDE().get(provider, "")
1419
+ signup = _get__LLM_SIGNUP_URLS().get(provider) or _get__DATA_SIGNUP_URLS().get(provider, "")
1420
+ if self.context.has_rich:
1421
+ from rich.panel import Panel as _Panel
1422
+ from rich import box as _rbox
1423
+ guide_body = guide
1424
+ if signup:
1425
+ guide_body += f"\n\n[bold cyan]🔗 {signup}[/bold cyan]"
1426
+ current_key = _get_provider_key(provider)
1427
+ if current_key:
1428
+ masked = current_key[:6] + "****" + current_key[-4:] if len(current_key) > 10 else "****"
1429
+ guide_body += f"\n\n[green]当前 key: {masked}[/green] (直接回车保留现有 key)"
1430
+ self.context.console.print()
1431
+ self.context.console.print(_Panel(
1432
+ guide_body,
1433
+ title=f"[bold]{provider.upper()} 配置指引[/bold]",
1434
+ border_style="cyan", box=_rbox.ROUNDED, padding=(0, 2),
1435
+ ))
1436
+ else:
1437
+ print(f"\n=== {provider.upper()} ===")
1438
+ print(guide)
1439
+ if signup:
1440
+ print(f"注册地址: {signup}")
1441
+
1442
+ # ── 输入 key ──────────────────────────────────────────────────────
1443
+ prompt_str = f" 请输入 {provider} API Key (输入后不显示): "
1444
+ try:
1445
+ raw_key = _getpass.getpass(prompt_str)
1446
+ except (KeyboardInterrupt, EOFError):
1447
+ self.context.console.print("\n[dim]已跳过[/dim]") if self.context.has_rich else print("\n已跳过")
1448
+ continue
1449
+
1450
+ raw_key = raw_key.strip()
1451
+ if not raw_key:
1452
+ # 保留现有 key,直接回到 picker
1453
+ msg = "未输入 key,保留现有配置"
1454
+ self.context.console.print(f"[dim] {msg}[/dim]") if self.context.has_rich else print(f" {msg}")
1455
+ continue
1456
+
1457
+ # ── 保存 ──────────────────────────────────────────────────────────
1458
+ if provider in _get__DATA_KEY_MAP():
1459
+ _save_data_key(provider, raw_key)
1460
+ env_var = _get__DATA_KEY_MAP()[provider]
1461
+ os.environ[env_var] = raw_key
1462
+ else:
1463
+ pjson_fresh = _load_providers_json()
1464
+ entry = pjson_fresh.get(provider, {})
1465
+ entry["api_key"] = raw_key
1466
+ if provider in _get__PROVIDER_BASE_URLS():
1467
+ entry.setdefault("base_url", _get__PROVIDER_BASE_URLS()[provider])
1468
+ pjson_fresh[provider] = entry
1469
+ _save_providers_json(pjson_fresh)
1470
+ env_var = _get__PROVIDER_KEY_MAP().get(provider)
1471
+ if env_var:
1472
+ os.environ[env_var] = raw_key
1473
+ pjson = pjson_fresh
1474
+
1475
+ masked = raw_key[:6] + "****" + raw_key[-4:] if len(raw_key) > 10 else "****"
1476
+ msg = f"✓ {provider} key 已保存 ({masked})"
1477
+ self.context.console.print(f"[green] {msg}[/green]") if self.context.has_rich else print(f" {msg}")
1478
+
1479
+ # ── 连接测试 ──────────────────────────────────────────────────────
1480
+ print(" 正在测试连接…", end="", flush=True)
1481
+ import asyncio as _aio
1482
+ loop = _aio.get_event_loop()
1483
+ ok, result_msg = await loop.run_in_executor(None, _test_api_key, provider, raw_key)
1484
+ print("\r", end="") # 清除"正在测试"那行
1485
+ if self.context.has_rich:
1486
+ color = "green" if ok else "yellow"
1487
+ self.context.console.print(f" [{color}]{result_msg}[/{color}]")
1488
+ else:
1489
+ print(f" {result_msg}")
1490
+
1491
+ # ── 继续配置其他 provider? ───────────────────────────────────────
1492
+ self.context.console.print() if self.context.has_rich else None
1493
+ try:
1494
+ again = input(" 继续配置其他 provider? (y/N) › ").strip().lower()
1495
+ except (KeyboardInterrupt, EOFError):
1496
+ again = "n"
1497
+ if again not in ("y", "yes", "是"):
1498
+ self.context.console.print("[dim] 向导已完成。输入 /apikey list 查看所有配置。[/dim]") if self.context.has_rich else print("向导完成")
1499
+ return
1500
+
1501
+ def _providers_init(self, *, force: bool = False):
1502
+ """按本机实际环境生成 ~/.aria/providers.yaml。"""
1503
+ from pathlib import Path as _P
1504
+
1505
+ try:
1506
+ from aria_code.providers.llm.autoconfig import probe_environment, render_providers_yaml
1507
+ except Exception as exc:
1508
+ self.context.console.print(f"[red]无法加载配置生成器: {exc}[/red]" if self.context.has_rich else f"无法加载配置生成器: {exc}")
1509
+ return
1510
+
1511
+ self.context.console.print("[dim]正在探测本机可用的模型来源…[/dim]" if self.context.has_rich else "正在探测…")
1512
+ findings = probe_environment()
1513
+
1514
+ for f in findings:
1515
+ mark = "[green]✓[/green]" if f.available else "[dim]·[/dim]"
1516
+ line = f" {mark} {f.provider:<14} {f.detail}"
1517
+ self.context.console.print(line if self.context.has_rich else line.replace("[green]✓[/green]", "✓").replace("[dim]·[/dim]", "·"))
1518
+
1519
+ target = _P.home() / ".aria" / "providers.yaml"
1520
+ content = render_providers_yaml(findings)
1521
+
1522
+ # 已有配置绝不静默覆盖——那可能是用户手写并调试了很久的东西。
1523
+ if target.exists() and not force:
1524
+ preview = target.with_suffix(".yaml.generated")
1525
+ preview.parent.mkdir(parents=True, exist_ok=True)
1526
+ preview.write_text(content, encoding="utf-8")
1527
+ msg = (f"\n已有配置 {target} 未改动。\n"
1528
+ f"生成结果写到 {preview},比对满意后自行替换,\n"
1529
+ f"或用 /providers init --force 直接覆盖。")
1530
+ self.context.console.print(f"[yellow]{msg}[/yellow]" if self.context.has_rich else msg)
1531
+ return
1532
+
1533
+ target.parent.mkdir(parents=True, exist_ok=True)
1534
+ target.write_text(content, encoding="utf-8")
1535
+ self.context.console.print(f"\n[green]✓[/green] 已写入 {target}" if self.context.has_rich else f"\n✓ 已写入 {target}")
1536
+
1537
+ if not any(f.available for f in findings):
1538
+ tip = "这台机器上还没有可用来源。最快:brew install ollama && ollama serve && ollama pull qwen2.5:7b"
1539
+ self.context.console.print(f"[yellow]{tip}[/yellow]" if self.context.has_rich else tip)
1540
+
1541
+
1542
+ def cmd_providers(self, args: str):
1543
+ """Show all LLM providers: local backends + cloud API status (Open Interpreter style).
1544
+
1545
+ `/providers init` 额外生成一份按本机实际环境定制的 providers.yaml——
1546
+ 探测到什么就写什么,缺什么就把"去哪拿"写成注释放进同一个文件,
1547
+ 用户不必在文档和配置之间来回对照。
1548
+ """
1549
+ if args.strip().lower().startswith("init"):
1550
+ return self._providers_init(force="--force" in args)
1551
+
1552
+ if self.context.has_rich:
1553
+ self.context.console.print()
1554
+
1555
+ # ── Section 1: Local backends ────────────────────────────────────────
1556
+ try:
1557
+ from local_llm_provider import probe_local_backends, BACKEND_DEFAULTS
1558
+ results = probe_local_backends()
1559
+ current_provider = self.terminal.config.get("local_provider", "ollama")
1560
+ # Count Ollama models if online
1561
+ _ollama_count = ""
1562
+ if results.get("ollama"):
1563
+ try:
1564
+ _omodels, _ = detect_ollama_models_rich(
1565
+ self.terminal.config.get("ollama_url", "http://localhost:11434"))
1566
+ _ollama_count = f" [dim]{len(_omodels)} 个模型[/dim]" if _omodels else ""
1567
+ except Exception:
1568
+ pass
1569
+
1570
+ if self.context.has_rich:
1571
+ self.context.console.print(" [bold]本地 Backend[/bold]")
1572
+ self.context.console.print()
1573
+ else:
1574
+ print(" == Local Backends ==")
1575
+
1576
+ for name, available in results.items():
1577
+ info = BACKEND_DEFAULTS.get(name, {})
1578
+ url = info.get("default_url", "")
1579
+ color = "green" if available else "dim"
1580
+ icon = "✅" if available else "○"
1581
+ active = " ◀ active" if name == current_provider else ""
1582
+ extra = _ollama_count if (name == "ollama" and available) else ""
1583
+ if self.context.has_rich:
1584
+ self.context.console.print(
1585
+ f" {icon} [{color}]{name:12s}[/{color}]"
1586
+ f" [dim]{url:30s}[/dim]{extra}"
1587
+ f"[green]{active}[/green]"
1588
+ )
1589
+ else:
1590
+ status = "✓" if available else "✗"
1591
+ print(f" {status} {name:12s} {url}{active}")
1592
+ except ImportError:
1593
+ pass
1594
+
1595
+ # ── Section 2: Cloud provider API keys ───────────────────────────────
1596
+ pjson = _load_providers_json()
1597
+ _CLOUD_LIST = [
1598
+ # ── 国际云端 ────────────────────────────────────────────────
1599
+ ("deepseek", "DeepSeek", "deepseek/deepseek-chat"),
1600
+ ("anthropic", "Anthropic", "anthropic/claude-sonnet-4-6"),
1601
+ ("openai", "OpenAI", "openai/gpt-4.5"),
1602
+ ("google", "Google Gemini", "google/gemini-3.8-flash"),
1603
+ ("xai", "xAI Grok", "xai/grok-3-fast"),
1604
+ ("groq", "Groq", "groq/llama-3.3-70b-versatile"),
1605
+ ("mistral", "Mistral", "mistral/mistral-large-latest"),
1606
+ ("cohere", "Cohere", "cohere/command-r-plus"),
1607
+ ("perplexity", "Perplexity", "perplexity/sonar-pro"),
1608
+ ("together", "Together", "together/meta-llama/Meta-Llama-3.1-70B"),
1609
+ # ── 国内云端 ────────────────────────────────────────────────
1610
+ ("siliconflow", "SiliconFlow", "siliconflow/Qwen/Qwen2.5-7B-Instruct"),
1611
+ ("dashscope", "DashScope", "dashscope/qwen-max"),
1612
+ ("moonshot", "Moonshot Kimi", "moonshot/moonshot-v1-128k"),
1613
+ ("zhipu", "Zhipu GLM", "zhipu/glm-4-plus"),
1614
+ ("baidu", "Baidu ERNIE", "baidu/ernie-4.5-turbo-128k"),
1615
+ ("bytedance", "ByteDance", "bytedance/<endpoint-id>"),
1616
+ ("minimax", "MiniMax", "minimax/MiniMax-Text-01"),
1617
+ ("stepfun", "StepFun", "stepfun/step-2-16k"),
1618
+ ("01ai", "01.AI Yi", "01ai/yi-large"),
1619
+ ]
1620
+ if self.context.has_rich:
1621
+ self.context.console.print()
1622
+ self.context.console.print(" [bold]Cloud Provider API[/bold]")
1623
+ self.context.console.print()
1624
+ else:
1625
+ print()
1626
+ print(" == Cloud Providers ==")
1627
+
1628
+ for prov, label, example_model in _CLOUD_LIST:
1629
+ env_var = _get__PROVIDER_KEY_MAP().get(prov, "")
1630
+ key = (os.getenv(env_var, "") if env_var else "") or \
1631
+ (pjson.get(prov, {}).get("api_key", "") if isinstance(pjson, dict) else "")
1632
+ if key:
1633
+ masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
1634
+ if self.context.has_rich:
1635
+ self.context.console.print(f" 🔑 [green]{label:14s}[/green] [dim]{masked}[/dim]")
1636
+ else:
1637
+ print(f" ✓ {label:14s} {masked}")
1638
+ else:
1639
+ hint = f"/apikey set {prov} <key>"
1640
+ if self.context.has_rich:
1641
+ self.context.console.print(f" ○ [dim]{label:14s} 未配置 → {hint}[/dim]")
1642
+ else:
1643
+ print(f" ✗ {label:14s} {hint}")
1644
+
1645
+ # ── Custom endpoint ──────────────────────────────────────────────────
1646
+ custom_ep = self.terminal.config.get("custom_endpoint", "")
1647
+ custom_m = self.terminal.config.get("custom_model", "")
1648
+ if custom_ep:
1649
+ if self.context.has_rich:
1650
+ self.context.console.print()
1651
+ self.context.console.print(f" 🔧 [bold]Custom endpoint[/bold] [dim]{custom_ep}[/dim] model=[cyan]{custom_m or '?'}[/cyan]")
1652
+ else:
1653
+ print(f"\n Custom: {custom_ep} model={custom_m}")
1654
+
1655
+ # ── Data service keys section ─────────────────────────────────────────
1656
+ _data_keys = _load_data_keys()
1657
+ _DATA_DISPLAY = [
1658
+ ("finnhub", "Finnhub", "股票+新闻"),
1659
+ ("newsapi", "NewsAPI", "财经新闻"),
1660
+ ("brave", "Brave Search", "网页搜索"),
1661
+ ("alphavantage", "Alpha Vantage", "历史数据"),
1662
+ ("coingecko", "CoinGecko Pro", "加密数据"),
1663
+ ("twelvedata", "Twelve Data", "全球行情"),
1664
+ ]
1665
+ if self.context.has_rich:
1666
+ self.context.console.print()
1667
+ self.context.console.print(" [bold]📊 数据服务 API[/bold] [dim](后端离线时的本地数据源)[/dim]")
1668
+ self.context.console.print()
1669
+ else:
1670
+ print("\n == Data Service APIs ==")
1671
+ for svc, label, desc in _DATA_DISPLAY:
1672
+ key_val = _data_keys.get(svc, "")
1673
+ if key_val:
1674
+ masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
1675
+ signup = _get__DATA_SIGNUP_URLS().get(svc, "")
1676
+ if self.context.has_rich:
1677
+ self.context.console.print(f" 🔑 [green]{label:18s}[/green] [dim]{masked} {desc}[/dim]")
1678
+ else:
1679
+ print(f" ✓ {label:18s} {masked}")
1680
+ else:
1681
+ hint = f"/apikey set {svc} <key>"
1682
+ signup = _get__DATA_SIGNUP_URLS().get(svc, "")
1683
+ if self.context.has_rich:
1684
+ self.context.console.print(f" ○ [dim]{label:18s} 未配置 → {hint}[/dim]")
1685
+ else:
1686
+ print(f" ✗ {label:18s} {hint}")
1687
+
1688
+ # ── Free data source registry (akshare / yfinance / tushare) ────────────
1689
+ try:
1690
+ from aria_code.datasources.router import DataRouter as _DR
1691
+ free_sources = _DR().list_sources()
1692
+ except Exception:
1693
+ free_sources = []
1694
+
1695
+ if free_sources:
1696
+ if self.context.has_rich:
1697
+ self.context.console.print()
1698
+ self.context.console.print(" [bold]免费行情数据源[/bold] [dim](datasources/router — no API key required)[/dim]")
1699
+ self.context.console.print()
1700
+ else:
1701
+ print("\n == Free Market Data Sources ==")
1702
+ for s in free_sources:
1703
+ ok_icon = "[green]✓[/green]" if s["configured"] else "[dim]○[/dim]"
1704
+ key_tag = " [dim](no key)[/dim]" if not s["needs_key"] else " [dim](API key)[/dim]"
1705
+ mkts = ", ".join(s.get("markets", []))
1706
+ if self.context.has_rich:
1707
+ self.context.console.print(
1708
+ f" {ok_icon} [bold]{s['name']:12s}[/bold] "
1709
+ f"[dim]{mkts:22s}[/dim]{key_tag}"
1710
+ )
1711
+ else:
1712
+ ok = "✓" if s["configured"] else "○"
1713
+ key = "(no key)" if not s["needs_key"] else "(key)"
1714
+ print(f" {ok} {s['name']:12s} {mkts:22s} {key}")
1715
+ if self.context.has_rich:
1716
+ self.context.console.print(" [dim]Config: ~/.aria/datasources.yaml[/dim]")
1717
+
1718
+ if self.context.has_rich:
1719
+ self.context.console.print()
1720
+ self.context.console.print(" [dim]配置 LLM Key: /apikey set deepseek <key>[/dim]")
1721
+ self.context.console.print(" [dim]配置数据 Key: /apikey set finnhub <key>[/dim]")
1722
+ self.context.console.print(" [dim]切换模型: /model deepseek/deepseek-chat[/dim]")
1723
+ self.context.console.print(" [dim]首次向导: /setup[/dim]")
1724
+ self.context.console.print(" [dim]自定义端点: /config set custom_endpoint=http://...[/dim]")
1725
+ self.context.console.print()
1726
+
1727
+ async def cmd_cloud(self, args: str):
1728
+ """
1729
+ Manage Alibaba Cloud data service connection.
1730
+
1731
+ Usage:
1732
+ /cloud status — show connection status & circuit breaker state
1733
+ /cloud set <url> — set cloud_api_server URL (e.g. http://your-aliyun-ip:8000)
1734
+ /cloud data <url> — set akshare_data_server URL (e.g. http://your-aliyun-ip:8002)
1735
+ /cloud token <jwt-token> — set API token
1736
+ /cloud health — live health-check both services
1737
+ /cloud reset — reset circuit breakers
1738
+ """
1739
+ try:
1740
+ from aliyun_data_client import AliyunDataClient, save_cloud_config, summarize_cloud_health
1741
+ except ImportError:
1742
+ if self.context.has_rich:
1743
+ self.context.console.print(" [red]aliyun_data_client.py not found[/red]")
1744
+ else:
1745
+ print(" aliyun_data_client.py not found")
1746
+ return
1747
+
1748
+ parts = args.strip().split(None, 2)
1749
+ sub = parts[0].lower() if parts else "status"
1750
+
1751
+ if sub == "set" and len(parts) >= 2:
1752
+ url = parts[1]
1753
+ save_cloud_config(cloud_url=url)
1754
+ AliyunDataClient.reset()
1755
+ if self.context.has_rich:
1756
+ self.context.console.print(f" [green]Cloud API URL set to: {url}[/green]")
1757
+ self.context.console.print(" [dim]Saved to ~/.arthera/config.json[/dim]")
1758
+ return
1759
+
1760
+ if sub == "data" and len(parts) >= 2:
1761
+ url = parts[1]
1762
+ save_cloud_config(data_url=url)
1763
+ AliyunDataClient.reset()
1764
+ if self.context.has_rich:
1765
+ self.context.console.print(f" [green]AKShare Data URL set to: {url}[/green]")
1766
+ self.context.console.print(" [dim]Saved to ~/.arthera/config.json[/dim]")
1767
+ return
1768
+
1769
+ if sub == "token" and len(parts) >= 2:
1770
+ token = parts[1]
1771
+ save_cloud_config(api_token=token)
1772
+ AliyunDataClient.reset()
1773
+ if self.context.has_rich:
1774
+ self.context.console.print(f" [green]API token saved (length {len(token)})[/green]")
1775
+ return
1776
+
1777
+ if sub == "reset":
1778
+ AliyunDataClient.reset()
1779
+ if self.context.has_rich:
1780
+ self.context.console.print(" [green]Circuit breakers reset, config reloaded[/green]")
1781
+ return
1782
+
1783
+ client = AliyunDataClient.get()
1784
+
1785
+ if sub == "health":
1786
+ if self.context.has_rich:
1787
+ self.context.console.print(" [dim]Checking health…[/dim]")
1788
+ with self.context.console.status("[dim]Checking cloud services…[/dim]", spinner="dots") if self.context.has_rich else _null_ctx():
1789
+ cloud_h = await client.health_cloud()
1790
+ data_h = await client.health_data()
1791
+ st = client.status()
1792
+ summary = summarize_cloud_health(cloud_h, data_h, st)
1793
+
1794
+ def _svc_label(name: str, health: dict) -> str:
1795
+ status = str(health.get("status", "?"))
1796
+ ok = status in ("healthy", "ok", "ready", "online")
1797
+ color = "green" if ok else "red"
1798
+ icon = "✓" if ok else "✗"
1799
+ breaker = st.get("cloud_cb" if name == "cloud_api_server" else "data_cb", "?")
1800
+ return f" [{color}]●[/{color}] {name} {icon} {status} [dim]breaker={breaker}[/dim]"
1801
+
1802
+ def _print_health_detail(title: str, health: dict):
1803
+ if self.context.has_rich:
1804
+ self.context.console.print()
1805
+ self.context.console.print(_svc_label(title, health))
1806
+ detail_keys = [
1807
+ (k, v) for k, v in health.items()
1808
+ if k not in {"status", "services", "cloud_url", "data_url"}
1809
+ ]
1810
+ if detail_keys:
1811
+ for k, v in detail_keys:
1812
+ self.context.console.print(f" [dim]{k}: {v}[/dim]")
1813
+ services = health.get("services") or {}
1814
+ if services:
1815
+ for svc, svc_status in services.items():
1816
+ svc_ok = "online" in str(svc_status) or "ready" in str(svc_status)
1817
+ svc_icon = "✓" if svc_ok else "○"
1818
+ svc_color = "green" if svc_ok else "yellow"
1819
+ self.context.console.print(f" [dim]{svc_icon} {svc}: [{svc_color}]{svc_status}[/{svc_color}][/dim]")
1820
+ else:
1821
+ print(f" {title}: {health.get('status', '?')}")
1822
+ for k, v in health.items():
1823
+ if k not in {"status", "services", "cloud_url", "data_url"}:
1824
+ print(f" {k}: {v}")
1825
+ for svc, svc_status in (health.get("services") or {}).items():
1826
+ print(f" {svc}: {svc_status}")
1827
+
1828
+ if self.context.has_rich:
1829
+ self.context.console.print()
1830
+ color = "green" if summary.status == "ok" else "yellow" if summary.status == "warn" else "red"
1831
+ self.context.console.print(f" [bold]Summary[/bold] [{color}]{summary.detail}[/{color}]")
1832
+ self.context.console.print(f" [dim]breaker_open={summary.breaker_open} token_set={summary.token_set}[/dim]")
1833
+ self.context.console.print(f" [dim]suggestion: {summary.suggestion}[/dim]")
1834
+ self.context.console.print(f" [dim]cloud_api_server: {client.cloud_url}[/dim]")
1835
+ self.context.console.print(f" [dim]akshare_data_server: {client.data_url}[/dim]")
1836
+ _print_health_detail("cloud_api_server", cloud_h)
1837
+ _print_health_detail("akshare_data_server", data_h)
1838
+ self.context.console.print()
1839
+ else:
1840
+ print(f" Summary: {summary.detail} ({summary.status})")
1841
+ print(f" breaker_open={summary.breaker_open} token_set={summary.token_set}")
1842
+ print(f" suggestion: {summary.suggestion}")
1843
+ _print_health_detail("cloud_api_server", cloud_h)
1844
+ _print_health_detail("akshare_data_server", data_h)
1845
+ return
1846
+
1847
+ # Default: /cloud status
1848
+ st = client.status()
1849
+ if self.context.has_rich:
1850
+ self.context.console.print()
1851
+ self.context.console.print(" [bold]Alibaba Cloud Data Services[/bold]")
1852
+ self.context.console.print()
1853
+ health_summary = st.get("health_summary") or {}
1854
+ color = "green" if health_summary.get("status") == "ok" else "yellow" if health_summary.get("status") == "warn" else "red"
1855
+ if health_summary:
1856
+ self.context.console.print(f" [bold]Health[/bold] [{color}]{health_summary.get('detail', '')}[/{color}]")
1857
+ self.context.console.print(f" [dim]breaker_open={health_summary.get('breaker_open', 0)} token_set={health_summary.get('token_set', False)}[/dim]")
1858
+ _c = "green" if st["cloud_cb"] == "closed" else "red"
1859
+ _d = "green" if st["data_cb"] == "closed" else "red"
1860
+ self.context.console.print(f" [{_c}]●[/{_c}] cloud_api_server [dim]{st['cloud_url']}[/dim]"
1861
+ f" [{_c}]{st['cloud_cb']}[/{_c}]")
1862
+ self.context.console.print(f" [{_d}]●[/{_d}] akshare_data_server [dim]{st['data_url']}[/dim]"
1863
+ f" [{_d}]{st['data_cb']}[/{_d}]")
1864
+ tok_str = "[green]set[/green]" if st["has_token"] else "[dim]not set[/dim]"
1865
+ self.context.console.print(f" Auth token: {tok_str}")
1866
+ self.context.console.print()
1867
+ self.context.console.print(" [dim]Configure: /cloud set <url> /cloud data <url> /cloud token <jwt>[/dim]")
1868
+ self.context.console.print(" [dim]Health: /cloud health[/dim]")
1869
+ self.context.console.print()
1870
+ else:
1871
+ health_summary = st.get("health_summary") or {}
1872
+ if health_summary:
1873
+ print(f" Health: {health_summary.get('detail', '')} ({health_summary.get('status', '')})")
1874
+ print(f" Cloud: {st['cloud_url']} ({st['cloud_cb']})")
1875
+ print(f" Data: {st['data_url']} ({st['data_cb']})")
1876
+ print(f" Token: {'set' if st['has_token'] else 'not set'}")
1877
+
1878
+ def cmd_permissions(self, args: str):
1879
+ """Tool permission policy manager.
1880
+
1881
+ /permissions — show current policy table
1882
+ /permissions allow <tool> — auto-approve this tool forever
1883
+ /permissions deny <tool> — permanently block this tool
1884
+ /permissions ask <tool> — always prompt before this tool
1885
+ /permissions reset — clear all custom rules
1886
+ /permissions remove <tool>— remove this tool from policy
1887
+ """
1888
+ try:
1889
+ from aria_code.runtime.tool_policy import (
1890
+ load_tool_policy, save_tool_policy,
1891
+ add_to_policy, remove_from_policy,
1892
+ )
1893
+ except ImportError as _e:
1894
+ self.context.console.print(f"[red]runtime.tool_policy not available: {_e}[/red]") if self.context.has_rich else print(f"Error: {_e}")
1895
+ return
1896
+
1897
+ parts = args.strip().split(maxsplit=1)
1898
+ sub = parts[0].lower() if parts else "show"
1899
+
1900
+ if sub in ("allow", "deny", "ask"):
1901
+ if len(parts) < 2:
1902
+ msg = f"Usage: /permissions {sub} <tool_name>"
1903
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
1904
+ return
1905
+ tool = parts[1].strip()
1906
+ add_to_policy(tool, sub)
1907
+ labels = {"allow": ("[green]✓ 白名单[/green]", "auto-approve"), "deny": ("[red]✗ 黑名单[/red]", "block"), "ask": ("[yellow]? 询问[/yellow]", "ask-always")}
1908
+ rich_label, plain_label = labels[sub]
1909
+ if self.context.has_rich:
1910
+ self.context.console.print(f" {rich_label} [bold]{tool}[/bold] [dim]已更新[/dim]")
1911
+ else:
1912
+ print(f" {plain_label}: {tool}")
1913
+ return
1914
+
1915
+ if sub == "reset":
1916
+ save_tool_policy({"allowed": [], "denied": [], "ask_always": []})
1917
+ msg = "✓ 工具权限策略已重置"
1918
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
1919
+ return
1920
+
1921
+ if sub in ("remove", "rm"):
1922
+ if len(parts) < 2:
1923
+ msg = "Usage: /permissions remove <tool_name>"
1924
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
1925
+ return
1926
+ tool = parts[1].strip()
1927
+ if remove_from_policy(tool):
1928
+ msg = f"✓ '{tool}' 已从策略中移除"
1929
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
1930
+ else:
1931
+ msg = f"'{tool}' 不在任何策略列表中"
1932
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
1933
+ return
1934
+
1935
+ # Default: show policy table
1936
+ policy = load_tool_policy()
1937
+ allowed = policy.get("allowed", [])
1938
+ denied = policy.get("denied", [])
1939
+ ask_always = policy.get("ask_always", [])
1940
+
1941
+ if self.context.has_rich:
1942
+ from rich.table import Table
1943
+ from rich import box as _rbox
1944
+ self.context.console.print()
1945
+ self.context.console.print(" [bold]Tool Permissions[/bold] [dim](~/.arthera/tool_policy.json)[/dim]")
1946
+ self.context.console.print()
1947
+ t = Table(box=_rbox.SIMPLE, padding=(0, 1), show_header=True)
1948
+ t.add_column("Tool", style="bold", min_width=22)
1949
+ t.add_column("Policy", min_width=12)
1950
+ t.add_column("Effect", style="dim")
1951
+ for tool in sorted(allowed):
1952
+ t.add_row(tool, "[green]✓ allow[/green]", "auto-approve, never prompt")
1953
+ for tool in sorted(denied):
1954
+ t.add_row(tool, "[red]✗ deny[/red]", "always reject, never run")
1955
+ for tool in sorted(ask_always):
1956
+ t.add_row(tool, "[yellow]? ask[/yellow]", "always prompt even in auto mode")
1957
+ if not (allowed or denied or ask_always):
1958
+ t.add_row("[dim]— no custom rules —[/dim]", "", "")
1959
+ self.context.console.print(t)
1960
+ self.context.console.print(" [dim]/permissions allow <tool> — 白名单(自动批准)[/dim]")
1961
+ self.context.console.print(" [dim]/permissions deny <tool> — 黑名单(始终拒绝)[/dim]")
1962
+ self.context.console.print(" [dim]/permissions ask <tool> — 始终询问[/dim]")
1963
+ self.context.console.print(" [dim]/permissions remove <tool> — 移除规则[/dim]")
1964
+ self.context.console.print(" [dim]/permissions reset — 清空所有规则[/dim]")
1965
+ self.context.console.print()
1966
+ else:
1967
+ print("\n Tool Permissions (~/.arthera/tool_policy.json)")
1968
+ print(f" Allow: {', '.join(allowed) or 'none'}")
1969
+ print(f" Deny: {', '.join(denied) or 'none'}")
1970
+ print(f" Ask: {', '.join(ask_always) or 'none'}")
1971
+ print()
1972
+ print(" Usage: /permissions allow|deny|ask|remove|reset <tool>")
1973
+
1974
+ def cmd_config(self, args: str):
1975
+ """Show or set CLI configuration."""
1976
+ from aria_code.apps.cli.config_paths import config_snapshot
1977
+ parts = args.strip().split(maxsplit=1)
1978
+ if not parts or parts[0] == "show":
1979
+ # Show current config
1980
+ cfg = self.terminal.config
1981
+ if self.context.has_rich:
1982
+ self.context.console.print()
1983
+ self.context.console.print("[bold]Configuration[/bold]")
1984
+ self.context.console.print()
1985
+ snap = config_snapshot()
1986
+ for key in ("api_url", "ollama_url", "local_provider", "model",
1987
+ "provider_fallback", "llm_base_url", "thinking_mode",
1988
+ "command_policy", "permission_mode", "network_enabled",
1989
+ "approval_mode", "auto_approve_level", "review_gate", "review_model", "delivery_report",
1990
+ "write_policy", "lsp_autocheck", "input_style", "input_theme",
1991
+ "response_footer", "auto_compact_context",
1992
+ "auto_compact_threshold", "auto_save_sessions"):
1993
+ val = cfg.get(key, "-")
1994
+ self.context.console.print(f" [dim]{key:<24s}[/dim]{val}")
1995
+ self.context.console.print(f" [dim]{'config_dir':<24s}[/dim]{snap['config_dir']}")
1996
+ self.context.console.print(f" [dim]{'config_file':<24s}[/dim]{snap['config_file']}")
1997
+ self.context.console.print(f" [dim]{'sessions_dir':<24s}[/dim]{snap['sessions_dir']}")
1998
+ self.context.console.print(f" [dim]{'user_output_root':<24s}[/dim]{snap['user_output_root']}")
1999
+ # Show notification/search config from resolved config.json
2000
+ try:
2001
+ import json as _j
2002
+ _ncfg_path = pathlib.Path(snap["config_file"])
2003
+ _ncfg = _j.loads(_ncfg_path.read_text()) if _ncfg_path.exists() else {}
2004
+ if _wh := _ncfg.get("notify_webhook"):
2005
+ self.context.console.print(f" [dim]{'notify_webhook':<24s}[/dim]{_wh[:50]}{'…' if len(_wh)>50 else ''}")
2006
+ except Exception:
2007
+ pass
2008
+ import os as _os_show
2009
+ if _os_show.getenv("BRAVE_SEARCH_API_KEY"):
2010
+ self.context.console.print(f" [dim]{'brave_key':<24s}[/dim][green]已配置[/green]")
2011
+ else:
2012
+ self.context.console.print(f" [dim]{'brave_key':<24s}[/dim][dim]未设置 — /config set brave_key=BSAAxxx[/dim]")
2013
+ # Security check: warn if providers.json has plaintext api_key
2014
+ _pf = pathlib.Path(snap["providers_file"])
2015
+ if _pf.exists():
2016
+ try:
2017
+ _pd = _j.loads(_pf.read_text())
2018
+ _has_plain = any(
2019
+ v.get("api_key") for v in _pd.values()
2020
+ if isinstance(v, dict) and v.get("api_key")
2021
+ and not str(v["api_key"]).startswith("${")
2022
+ )
2023
+ if _has_plain:
2024
+ self.context.console.print()
2025
+ self.context.console.print(
2026
+ " [yellow]⚠ ~/.arthera/providers.json 含明文 API Key[/yellow]\n"
2027
+ " [dim] 建议迁移到环境变量: export OPENAI_API_KEY=sk-...[/dim]\n"
2028
+ " [dim] 然后删除 providers.json 中的 api_key 字段[/dim]"
2029
+ )
2030
+ except Exception:
2031
+ pass
2032
+ self.context.console.print()
2033
+ else:
2034
+ for key in ("api_url", "ollama_url", "local_provider", "model",
2035
+ "provider_fallback", "llm_base_url", "thinking_mode",
2036
+ "command_policy", "permission_mode", "network_enabled",
2037
+ "approval_mode", "auto_approve_level", "review_gate", "review_model", "delivery_report",
2038
+ "write_policy", "lsp_autocheck", "input_style", "input_theme",
2039
+ "response_footer", "auto_compact_context",
2040
+ "auto_compact_threshold"):
2041
+ print(f" {key}: {cfg.get(key, '-')}")
2042
+ elif len(parts) == 2 and parts[0] == "set":
2043
+ # Parse key=value
2044
+ kv = parts[1].split("=", 1)
2045
+ if len(kv) == 2:
2046
+ key, val = kv[0].strip(), kv[1].strip()
2047
+ # Validate known config keys
2048
+ if key == "command_policy":
2049
+ if val not in {"safe", "balanced", "full"}:
2050
+ msg = "command_policy must be one of: safe | balanced | full"
2051
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2052
+ return
2053
+ elif key == "permission_mode":
2054
+ if val not in {"read-only", "workspace-write", "full-access"}:
2055
+ msg = "permission_mode must be one of: read-only | workspace-write | full-access"
2056
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2057
+ return
2058
+ elif key == "approval_mode":
2059
+ if val not in {"manual", "risk"}:
2060
+ msg = ("approval_mode must be one of: manual | risk "
2061
+ "(risk: auto-approve actions at or below auto_approve_level)")
2062
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2063
+ return
2064
+ elif key == "auto_approve_level":
2065
+ if val not in {"0", "1", "2"}:
2066
+ msg = "auto_approve_level must be 0, 1 or 2 (L3 and L4 always ask)"
2067
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2068
+ return
2069
+ val = int(val)
2070
+ elif key in {"network_enabled", "data_sharing", "feedback_upload", "review_gate", "delivery_report"}:
2071
+ if val.lower() in {"true", "1", "yes", "on"}:
2072
+ val = True
2073
+ elif val.lower() in {"false", "0", "no", "off"}:
2074
+ val = False
2075
+ else:
2076
+ msg = f"{key} must be: true | false"
2077
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2078
+ return
2079
+ elif key == "review_model":
2080
+ # Another provider's model reads the diff; "off" (or empty) goes back to the builder.
2081
+ from aria_code.apps.cli.providers.chat_routing import KNOWN_MODEL_PROVIDERS, normalize_provider_name
2082
+
2083
+ if val.lower() in {"", "off", "none"}:
2084
+ val = ""
2085
+ else:
2086
+ provider = normalize_provider_name(val.split("/", 1)[0]) if "/" in val else ""
2087
+ if provider not in KNOWN_MODEL_PROVIDERS:
2088
+ msg = ("review_model must name a provider and model, e.g. anthropic/claude-sonnet-5-5, "
2089
+ "openai/<model> or google/gemini-3.8-flash (or 'off'). "
2090
+ "The diff and goal are sent to that provider.")
2091
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2092
+ return
2093
+ elif key == "thinking_mode":
2094
+ if val not in {"auto", "instant", "thinking"}:
2095
+ msg = "thinking_mode must be one of: auto | instant | thinking"
2096
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2097
+ return
2098
+ elif key == "provider_fallback":
2099
+ if val not in {"off", "configured", "auto"}:
2100
+ msg = "provider_fallback must be: off | configured | auto"
2101
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2102
+ return
2103
+ elif key == "local_provider":
2104
+ from aria_code.apps.cli.providers.chat_routing import normalize_provider_name
2105
+
2106
+ val = normalize_provider_name(val)
2107
+ elif key == "model":
2108
+ resolved = _get_MODEL_ALIASES().get(val) or (val if val in _get_MODELS() else None)
2109
+ if not resolved:
2110
+ valid = ", ".join(sorted(_get_MODEL_ALIASES().keys()))
2111
+ msg = f"Unknown model '{val}'. Valid: {valid}"
2112
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2113
+ return
2114
+ val = _get_MODELS()[resolved]["id"]
2115
+ elif key == "auto_save_sessions":
2116
+ if val.lower() in {"true", "1", "yes", "on"}:
2117
+ val = True
2118
+ elif val.lower() in {"false", "0", "no", "off"}:
2119
+ val = False
2120
+ else:
2121
+ msg = "auto_save_sessions must be: true | false"
2122
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2123
+ return
2124
+ elif key == "auto_compact_context":
2125
+ if val.lower() in {"true", "1", "yes", "on"}:
2126
+ val = True
2127
+ elif val.lower() in {"false", "0", "no", "off"}:
2128
+ val = False
2129
+ else:
2130
+ msg = "auto_compact_context must be: true | false"
2131
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2132
+ return
2133
+ elif key == "lsp_autocheck":
2134
+ if val.lower() in {"true", "1", "yes", "on"}:
2135
+ val = True
2136
+ elif val.lower() in {"false", "0", "no", "off"}:
2137
+ val = False
2138
+ else:
2139
+ msg = "lsp_autocheck must be: true | false"
2140
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2141
+ return
2142
+ elif key == "auto_compact_threshold":
2143
+ try:
2144
+ val = float(val)
2145
+ except Exception:
2146
+ msg = "auto_compact_threshold must be a number between 0.50 and 0.95"
2147
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2148
+ return
2149
+ if not 0.50 <= val <= 0.95:
2150
+ msg = "auto_compact_threshold must be between 0.50 and 0.95"
2151
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2152
+ return
2153
+ elif key == "write_policy":
2154
+ if val not in {"desktop_only", "confirm_outside", "always_confirm"}:
2155
+ msg = "write_policy must be: desktop_only | confirm_outside | always_confirm"
2156
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2157
+ return
2158
+ elif key == "local_mode":
2159
+ if val.lower() in {"true", "1", "yes", "on"}:
2160
+ val = True
2161
+ elif val.lower() in {"false", "0", "no", "off"}:
2162
+ val = False
2163
+ else:
2164
+ msg = "local_mode must be: true | false"
2165
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2166
+ return
2167
+ elif key == "banner":
2168
+ if val not in {"full", "compact", "off"}:
2169
+ msg = "banner must be: full | compact | off"
2170
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2171
+ return
2172
+ elif key == "input_style":
2173
+ if val not in {"panel", "box", "plain"}:
2174
+ msg = "input_style must be: panel | box | plain"
2175
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2176
+ return
2177
+ elif key == "input_theme":
2178
+ if val not in {"auto", "dark", "light"}:
2179
+ msg = "input_theme must be: auto | dark | light"
2180
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2181
+ return
2182
+ elif key == "response_footer":
2183
+ if val not in {"compact", "full", "off"}:
2184
+ msg = "response_footer must be: compact | full | off"
2185
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2186
+ return
2187
+ elif key == "ui_lang":
2188
+ if val not in {"zh", "en", "ja", "ko", "auto"}:
2189
+ msg = "ui_lang must be: zh | en | auto (auto = detect from OS locale)"
2190
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
2191
+ return
2192
+ if val == "auto":
2193
+ try:
2194
+ from aria_code.apps.cli.i18n import detect_system_lang as _dsl
2195
+ val = _dsl()
2196
+ except Exception:
2197
+ val = "en"
2198
+ msg = f"✓ UI 语言已设为 {val} (重启生效)" if val == "zh" else f"✓ UI language set to {val} (takes effect on restart)"
2199
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
2200
+ self.terminal.config[key] = val
2201
+ self.context.save_config(self.terminal.config)
2202
+ return
2203
+ elif key == "notify_webhook":
2204
+ # /config set notify_webhook=https://qyapi.weixin.qq.com/...
2205
+ # 写入 ~/.arthera/config.json(notification_tools 直接读取)
2206
+ try:
2207
+ _ncfg_path = aria_home() / "config.json"
2208
+ _ncfg = json.loads(_ncfg_path.read_text()) if _ncfg_path.exists() else {}
2209
+ _ncfg["notify_webhook"] = val
2210
+ _ncfg_path.write_text(json.dumps(_ncfg, indent=2, ensure_ascii=False))
2211
+ except Exception as _e:
2212
+ logger.debug("notify_webhook save failed: %s", _e)
2213
+ msg = f"✓ 通知 Webhook 已设为 {val[:60]}…" if len(val) > 60 else f"✓ 通知 Webhook 已设为 {val}"
2214
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
2215
+ return
2216
+ elif key == "brave_key":
2217
+ # /config set brave_key=BSAAxxx → 写入 ~/.aria/.env
2218
+ _env_path = pathlib.Path.home() / ".aria" / ".env"
2219
+ _env_path.parent.mkdir(parents=True, exist_ok=True)
2220
+ existing = _env_path.read_text() if _env_path.exists() else ""
2221
+ import re as _re_cfg
2222
+ if "BRAVE_SEARCH_API_KEY" in existing:
2223
+ existing = _re_cfg.sub(r"BRAVE_SEARCH_API_KEY=.*", f"BRAVE_SEARCH_API_KEY={val}", existing)
2224
+ else:
2225
+ existing = existing.rstrip("\n") + f"\nBRAVE_SEARCH_API_KEY={val}\n"
2226
+ _env_path.write_text(existing)
2227
+ _env_path.chmod(0o600)
2228
+ import os as _os_cfg
2229
+ _os_cfg.environ["BRAVE_SEARCH_API_KEY"] = val
2230
+ msg = "✓ Brave Search API key 已保存到~/.aria/.env (生效于当前会话)"
2231
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
2232
+ return
2233
+ elif key == "custom_endpoint":
2234
+ # /config set custom_endpoint=http://my-litellm:4000/v1
2235
+ # Automatically sets local_provider=custom
2236
+ self.terminal.config["local_provider"] = "custom"
2237
+ self.terminal.config["custom_endpoint"] = val
2238
+ _sync_write_policy(self.terminal.config)
2239
+ self.context.save_config(self.terminal.config)
2240
+ msg = f"✓ 自定义 endpoint 设为 {val} (local_provider=custom)"
2241
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
2242
+ return
2243
+ elif key == "custom_model":
2244
+ # /config set custom_model=gpt-4o
2245
+ self.terminal.config["custom_model"] = val
2246
+ if self.terminal.config.get("local_provider") == "custom":
2247
+ self.terminal.config["model"] = val
2248
+ _sync_write_policy(self.terminal.config)
2249
+ self.context.save_config(self.terminal.config)
2250
+ self.context.console.print(f" [dim]custom_model[/dim] = {val}" if self.context.has_rich else f" custom_model = {val}")
2251
+ return
2252
+ self.terminal.config[key] = val
2253
+ _sync_write_policy(self.terminal.config)
2254
+ self.context.save_config(self.terminal.config)
2255
+ self.context.console.print(f" [dim]{key}[/dim] = {val}" if self.context.has_rich else f" {key} = {val}")
2256
+ else:
2257
+ self.context.console.print("[dim]Usage: /config set key=value[/dim]" if self.context.has_rich
2258
+ else "Usage: /config set key=value")
2259
+ elif parts[0] == "reload":
2260
+ fresh = load_config()
2261
+ self.terminal.config.update(fresh)
2262
+ msg = f"Config reloaded from {config_snapshot()['config_file']}"
2263
+ self.context.console.print(f"[dim]{msg}[/dim]" if self.context.has_rich else msg)
2264
+
2265
+ elif parts[0] == "allow":
2266
+ # /config allow <tool_name> — permanently auto-approve this tool
2267
+ if len(parts) < 2:
2268
+ msg = "Usage: /config allow <tool_name> (e.g. /config allow read_file)"
2269
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
2270
+ return
2271
+ tool = parts[1].strip()
2272
+ try:
2273
+ from aria_code.runtime.tool_policy import add_to_policy
2274
+ add_to_policy(tool, "allow")
2275
+ msg = f"✓ 工具 '{tool}' 加入永久白名单(始终自动批准,无需确认)"
2276
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
2277
+ except Exception as _e:
2278
+ self.context.console.print(f"[red]Error: {_e}[/red]") if self.context.has_rich else print(f"Error: {_e}")
2279
+
2280
+ elif parts[0] == "deny":
2281
+ # /config deny <tool_name> — permanently block this tool
2282
+ if len(parts) < 2:
2283
+ msg = "Usage: /config deny <tool_name> (e.g. /config deny run_command)"
2284
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
2285
+ return
2286
+ tool = parts[1].strip()
2287
+ try:
2288
+ from aria_code.runtime.tool_policy import add_to_policy
2289
+ add_to_policy(tool, "deny")
2290
+ msg = f"✓ 工具 '{tool}' 加入黑名单(始终拒绝,不会执行)"
2291
+ self.context.console.print(f"[red]{msg}[/red]") if self.context.has_rich else print(msg)
2292
+ except Exception as _e:
2293
+ self.context.console.print(f"[red]Error: {_e}[/red]") if self.context.has_rich else print(f"Error: {_e}")
2294
+
2295
+ elif parts[0] == "ask":
2296
+ # /config ask <tool_name> — always prompt before this tool
2297
+ if len(parts) < 2:
2298
+ msg = "Usage: /config ask <tool_name> (e.g. /config ask write_file)"
2299
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
2300
+ return
2301
+ tool = parts[1].strip()
2302
+ try:
2303
+ from aria_code.runtime.tool_policy import add_to_policy
2304
+ add_to_policy(tool, "ask")
2305
+ msg = f"✓ 工具 '{tool}' 设为始终询问(每次执行前都弹出确认)"
2306
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
2307
+ except Exception as _e:
2308
+ self.context.console.print(f"[red]Error: {_e}[/red]") if self.context.has_rich else print(f"Error: {_e}")
2309
+
2310
+ elif parts[0] == "policy":
2311
+ # /config policy — show all policy settings
2312
+ # /config policy reset — reset to defaults
2313
+ sub = parts[1].lower() if len(parts) > 1 else "show"
2314
+ try:
2315
+ from aria_code.runtime.tool_policy import load_tool_policy, save_tool_policy, remove_from_policy
2316
+ if sub == "reset":
2317
+ save_tool_policy({"allowed": [], "denied": [], "ask_always": []})
2318
+ msg = "✓ 工具权限策略已重置为默认值"
2319
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
2320
+ elif sub in ("remove", "rm") and len(parts) > 2:
2321
+ tool = parts[2].strip()
2322
+ removed = remove_from_policy(tool)
2323
+ if removed:
2324
+ msg = f"✓ '{tool}' 已从策略中移除"
2325
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
2326
+ else:
2327
+ msg = f"'{tool}' 不在任何策略列表中"
2328
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
2329
+ else:
2330
+ policy = load_tool_policy()
2331
+ if self.context.has_rich:
2332
+ self.context.console.print()
2333
+ self.context.console.print(" [bold]工具权限策略[/bold] [dim]~/.arthera/tool_policy.json[/dim]")
2334
+ self.context.console.print()
2335
+ allowed = policy.get("allowed", [])
2336
+ denied = policy.get("denied", [])
2337
+ asked = policy.get("ask_always", [])
2338
+ if allowed:
2339
+ self.context.console.print(f" [green]✓ 白名单(自动允许):[/green] {', '.join(allowed)}")
2340
+ else:
2341
+ self.context.console.print(" [dim]✓ 白名单: 空[/dim]")
2342
+ if denied:
2343
+ self.context.console.print(f" [red]✗ 黑名单(始终拒绝):[/red] {', '.join(denied)}")
2344
+ else:
2345
+ self.context.console.print(" [dim]✗ 黑名单: 空[/dim]")
2346
+ if asked:
2347
+ self.context.console.print(f" [yellow]? 始终询问:[/yellow] {', '.join(asked)}")
2348
+ else:
2349
+ self.context.console.print(" [dim]? 始终询问: 空[/dim]")
2350
+ self.context.console.print()
2351
+ self.context.console.print(" [dim]/config allow <tool> — 加入白名单[/dim]")
2352
+ self.context.console.print(" [dim]/config deny <tool> — 加入黑名单[/dim]")
2353
+ self.context.console.print(" [dim]/config ask <tool> — 设为始终询问[/dim]")
2354
+ self.context.console.print(" [dim]/config policy remove <tool> — 移除策略[/dim]")
2355
+ self.context.console.print(" [dim]/config policy reset — 清空所有策略[/dim]")
2356
+ self.context.console.print()
2357
+ else:
2358
+ print("\n Tool Policy:")
2359
+ print(f" Allowed: {', '.join(policy.get('allowed', [])) or 'none'}")
2360
+ print(f" Denied: {', '.join(policy.get('denied', [])) or 'none'}")
2361
+ print(f" Ask-always: {', '.join(policy.get('ask_always', [])) or 'none'}")
2362
+ except Exception as _e:
2363
+ self.context.console.print(f"[red]Error: {_e}[/red]") if self.context.has_rich else print(f"Error: {_e}")
2364
+
2365
+ else:
2366
+ self.context.console.print(
2367
+ "[dim]Usage: /config [show] | /config set key=value | /config reload\n"
2368
+ " /config allow <tool> | /config deny <tool> | /config ask <tool>\n"
2369
+ " /config policy [reset|remove <tool>][/dim]"
2370
+ if self.context.has_rich else
2371
+ "Usage: /config [show] | /config set key=value | /config reload\n"
2372
+ " /config allow <tool> | /config deny <tool> | /config ask <tool>\n"
2373
+ " /config policy [reset|remove <tool>]"
2374
+ )