aria-code 0.52.0__py3-none-any.whl

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