aria-code 4.1.3__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 (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
@@ -0,0 +1,1579 @@
1
+ """
2
+ ModelCommandsMixin — Model/config commands: model, apikey, providers, cloud, config, tools, skills.
3
+
4
+ Extracted from aria_cli.py. Methods' __globals__ are rebound to aria_cli's namespace
5
+ by _rebind_mixin_globals() called at module load time.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ class ModelCommandsMixin:
11
+ """Mixin: Model/config commands: model, apikey, providers, cloud, config, tools, skills."""
12
+
13
+ async def cmd_model(self, args: str):
14
+ name = args.strip()
15
+
16
+ # ── "provider/model" format (Open Interpreter style) ─────────────────
17
+ # Examples: /model deepseek/deepseek-chat /model ollama/qwen2.5:7b
18
+ # /model openai/gpt-4.5 /model openai/o3 /model openai/o4-mini
19
+ if "/" in name and not name.startswith("http"):
20
+ _prov, _mod = name.split("/", 1)
21
+ _prov = _prov.strip().lower()
22
+ _mod = _mod.strip()
23
+ _local_backends = {"ollama", "lmstudio", "vllm", "llamacpp", "jan", "custom"}
24
+ if _prov not in _local_backends:
25
+ # Cloud provider — check API key
26
+ _key = _get_provider_key(_prov)
27
+ if not _key:
28
+ msg = (f"⚠ {_prov} API key 未配置。"
29
+ f"运行: /apikey set {_prov} <key>")
30
+ console.print(f"[yellow]{msg}[/yellow]") if HAS_RICH else print(msg)
31
+ return
32
+ self.terminal.config["local_provider"] = _prov
33
+ self.terminal.config["model"] = _mod
34
+ save_config(self.terminal.config)
35
+ msg = f"✓ 已切换到 {_prov}/{_mod}"
36
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
37
+ return
38
+
39
+ # Direct selection by number: /model 1 /model 2 … (Codex style)
40
+ if name.isdigit():
41
+ idx = int(name) - 1
42
+ keys = list(MODELS.keys())
43
+ if 0 <= idx < len(keys):
44
+ self._set_model(keys[idx])
45
+ else:
46
+ console.print(f"[dim]No model #{name}[/dim]" if HAS_RICH else f"No model #{name}")
47
+ return
48
+
49
+ # Direct selection by key (case-insensitive): /model qwen7b
50
+ if name.lower() in MODELS:
51
+ self._set_model(name.lower())
52
+ return
53
+
54
+ # Direct selection by alias: /model st / s / p / coder
55
+ if name.lower() in MODEL_ALIASES:
56
+ self._set_model(MODEL_ALIASES[name.lower()])
57
+ return
58
+
59
+ # Direct selection by full Ollama model ID: /model qwen2.5-coder:1.5b
60
+ if name and ":" in name:
61
+ self._set_model_by_id(name)
62
+ return
63
+
64
+ # ── Interactive picker (Codex style: numbered list + descriptions) ────
65
+ ollama_url = self.terminal.config.get("ollama_url", "http://localhost:11434")
66
+ current_id = self.terminal.config.get("model", "qwen2.5:7b")
67
+ try:
68
+ from apps.cli.i18n import t as _i18nt
69
+ _lang = self.terminal.config.get("ui_lang", "en") or "en"
70
+ _i18n = lambda k: _i18nt(k, lang=_lang)
71
+ except Exception:
72
+ _lang = "en"
73
+ _i18n = lambda k: k
74
+
75
+ rich_models, ollama_err = detect_ollama_models_rich(ollama_url)
76
+ installed_names = {m["name"] for m in rich_models}
77
+ aria_ids = {m["id"] for m in MODELS.values()}
78
+
79
+ # ── Build picker title (one line, shown inside arrow_select header) ──
80
+ _sel_model = _i18n("select_model")
81
+ _installed = _i18n("installed")
82
+ if ollama_err:
83
+ _picker_title = f"{_sel_model} [Ollama: {ollama_err[:40]}]"
84
+ else:
85
+ n_installed = sum(1 for m in MODELS.values() if m["id"] in installed_names)
86
+ _picker_title = f"{_sel_model} {n_installed}/{len(MODELS)} {_installed} · /model <id> or number"
87
+
88
+ def _status_tag(mid: str, badge: str) -> str:
89
+ """Return short status: ● installed / ○ not installed / ☁ cloud"""
90
+ if badge == "Cloud":
91
+ return "☁"
92
+ return "●" if mid in installed_names else "○"
93
+
94
+ # Get terminal width for safe label truncation
95
+ try:
96
+ _term_cols = os.get_terminal_size().columns
97
+ except Exception:
98
+ _term_cols = 80
99
+
100
+ def _cjk_width(s: str) -> int:
101
+ """Display-column width (CJK = 2 cols each)."""
102
+ w = 0
103
+ for ch in s:
104
+ cp = ord(ch)
105
+ w += 2 if (0x2E80 <= cp <= 0xA4CF or 0xAC00 <= cp <= 0xD7AF or
106
+ 0xFF01 <= cp <= 0xFF60 or 0x3000 <= cp <= 0x303F) else 1
107
+ return w
108
+
109
+ def _cjk_truncate(s: str, max_cols: int) -> str:
110
+ """Truncate s so its display width ≤ max_cols, adding … if cut."""
111
+ w, out = 0, ""
112
+ for ch in s:
113
+ cw = 2 if (0x2E80 <= ord(ch) <= 0xA4CF or
114
+ 0xAC00 <= ord(ch) <= 0xD7AF or
115
+ 0xFF01 <= ord(ch) <= 0xFF60 or
116
+ 0x3000 <= ord(ch) <= 0x303F) else 1
117
+ if w + cw > max_cols:
118
+ return out + "…"
119
+ out += ch
120
+ w += cw
121
+ return out
122
+
123
+ def _short_desc(m: dict) -> str:
124
+ """Single-line description with right-aligned meta tags."""
125
+ desc = m.get("description", "")
126
+ badge = m.get("badge", "")
127
+ extras = []
128
+ if _HAS_MODEL_CAP:
129
+ cap = get_model_capability(m["id"])
130
+ extras.append(f"ctx={cap.context_window//1024}K")
131
+ if cap.tool_calls: extras.append("tools✓")
132
+ if cap.thinking: extras.append("think")
133
+ else:
134
+ extras.append(f"ctx={m.get('num_ctx', 8192)//1024}K")
135
+ if badge in ("Fast", "Code", "Think", "Cloud"):
136
+ extras.insert(0, badge)
137
+ meta = " " + " · ".join(extras) if extras else ""
138
+ # prefix " N. ☁ ModelName " ≈ 24 cols; give description 60% of remaining
139
+ _prefix_cols = 24
140
+ _avail = max(30, _term_cols - _prefix_cols - len(meta) - 2)
141
+ _desc_budget = max(20, _avail * 3 // 4) # 75% of available → description
142
+ desc = _cjk_truncate(desc, _desc_budget)
143
+ return f"{desc}{meta}"
144
+
145
+ # Build option list (Codex: numbered, no separators within Aria section)
146
+ options: list = [] # (label_str, desc_str) for _arrow_select
147
+ all_ids: list = []
148
+
149
+ # ── Print numbered list only in non-interactive (-p) mode ────────────
150
+ # In interactive TTY mode the arrow picker below already shows all items.
151
+ # Printing twice causes the visual duplication seen in the session log.
152
+ _is_tty = sys.stdin.isatty()
153
+ idx_counter = 1
154
+ if not _is_tty:
155
+ # Non-interactive (-p mode): show static numbered list then return.
156
+ # The arrow picker cannot run without a TTY.
157
+ community_list = [cm for cm in rich_models if cm["name"] not in aria_ids]
158
+ for key, m in MODELS.items():
159
+ mid = m["id"]
160
+ is_cur = mid == current_id
161
+ status = _status_tag(mid, m.get("badge", ""))
162
+ cur_tag = " (current)" if is_cur else ""
163
+ desc = _short_desc(m)
164
+ line = f" {idx_counter}. {status} {m['name']:<14s} {desc}{cur_tag}"
165
+ console.print(line) if HAS_RICH else print(line)
166
+ idx_counter += 1
167
+ if community_list:
168
+ console.print() if HAS_RICH else print()
169
+ lbl = " Community (Ollama)"
170
+ console.print(f"[dim]{lbl}[/dim]") if HAS_RICH else print(lbl)
171
+ for cm in community_list:
172
+ mid = cm["name"]
173
+ is_cur = mid == current_id
174
+ cur_tag = " (current)" if is_cur else ""
175
+ line = f" {idx_counter}. ● {mid}{cur_tag}"
176
+ console.print(line) if HAS_RICH else print(line)
177
+ idx_counter += 1
178
+ console.print() if HAS_RICH else print()
179
+ console.print(" [dim]Use /model <id> to switch. E.g. /model deepseek/deepseek-chat[/dim]") if HAS_RICH else print(" Use /model <id> to switch.")
180
+ return
181
+
182
+ # ── Build compact options for _arrow_select ────────────────────────
183
+ # In TTY mode: include short description (static list is suppressed above).
184
+ # In non-TTY: descriptions already shown in static list, keep labels short.
185
+ num = 1
186
+ for key, m in MODELS.items():
187
+ mid = m["id"]
188
+ status = _status_tag(mid, m.get("badge", ""))
189
+ is_cur = " ◀" if mid == current_id else ""
190
+ if _is_tty:
191
+ desc_part = f" {_short_desc(m)}"
192
+ else:
193
+ desc_part = ""
194
+ label = f" {num}. {status} {m['name']}{is_cur}{desc_part}"
195
+ options.append((label, ""))
196
+ all_ids.append(mid)
197
+ num += 1
198
+
199
+ community = [cm for cm in rich_models if cm["name"] not in aria_ids]
200
+ if community:
201
+ _comm_label = _i18n("community_models")
202
+ options.append((f" ── {_comm_label} ──", ""))
203
+ all_ids.append(None)
204
+ for cm in community:
205
+ mid = cm["name"]
206
+ is_cur = " ◀" if mid == current_id else ""
207
+ options.append((f" {num}. ● {mid}{is_cur}", ""))
208
+ all_ids.append(mid)
209
+ num += 1
210
+
211
+ if ollama_err and not rich_models:
212
+ _unreach = _i18n("ollama_unreachable")
213
+ options.append((f" ── {_unreach} ──────────", ""))
214
+ all_ids.append(None)
215
+
216
+ # ── Run thread-based arrow picker (short labels = no line wrap) ────
217
+ current_idx = next((i for i, mid in enumerate(all_ids) if mid == current_id), 0)
218
+
219
+ while True:
220
+ choice = await _run_picker_in_thread(
221
+ options, current_idx,
222
+ _picker_title,
223
+ max_visible=len(options),
224
+ )
225
+ if choice < 0:
226
+ _msg = _i18n("cancelled")
227
+ console.print(f"[dim]{_msg}[/dim]" if HAS_RICH else _msg)
228
+ return
229
+ if all_ids[choice] is None:
230
+ current_idx = min(choice + 1, len(options) - 1)
231
+ continue
232
+ break
233
+
234
+ self._set_model_by_id(all_ids[choice])
235
+
236
+ def _set_model(self, key: str):
237
+ """Set model by MODELS key."""
238
+ m = MODELS[key]
239
+ self._set_model_by_id(m["id"])
240
+
241
+ def _set_model_by_id(self, model_id: str):
242
+ """Set model by Ollama model ID (works for both built-in and community models)."""
243
+ self.terminal.config["model"] = model_id
244
+ self.terminal._actual_model = None # reset: new config model, no known fallback yet
245
+ save_config(self.terminal.config)
246
+ # Pretty label
247
+ for m in MODELS.values():
248
+ if m["id"] == model_id:
249
+ if HAS_RICH:
250
+ console.print(f"[bold]Model:[/bold] [bold]{m['name']} {m['version']}[/bold] "
251
+ f"[dim]{m['tag']}[/dim]")
252
+ else:
253
+ print(f"Model: {m['name']} {m['version']} ({m['tag']})")
254
+ return
255
+ # Community / unknown model
256
+ if HAS_RICH:
257
+ console.print(f"[bold]Model:[/bold] [bold]{model_id}[/bold] [dim](local)[/dim]")
258
+ else:
259
+ print(f"Model: {model_id} (local)")
260
+
261
+ def cmd_thinking(self, args: str):
262
+ mode = args.strip().lower()
263
+
264
+ # Direct set: /thinking on
265
+ if mode in ("on", "thinking"):
266
+ self.terminal.config["thinking_mode"] = "thinking"
267
+ elif mode in ("off", "instant"):
268
+ self.terminal.config["thinking_mode"] = "instant"
269
+ elif mode == "auto":
270
+ self.terminal.config["thinking_mode"] = "auto"
271
+ elif mode:
272
+ # Unknown mode, show picker
273
+ pass
274
+ else:
275
+ # Interactive picker
276
+ current = self.terminal.config.get("thinking_mode", "auto")
277
+ mode_keys = list(THINKING_MODES.keys())
278
+ current_idx = mode_keys.index(current) if current in mode_keys else 0
279
+ options = [(info["label"], info["description"]) for info in THINKING_MODES.values()]
280
+ choice = _arrow_select(options, selected=current_idx, title="Thinking Mode")
281
+ if 0 <= choice < len(mode_keys):
282
+ self.terminal.config["thinking_mode"] = mode_keys[choice]
283
+ else:
284
+ if HAS_RICH:
285
+ console.print("[dim]No change[/dim]")
286
+ else:
287
+ print("No change")
288
+ return
289
+
290
+ save_config(self.terminal.config)
291
+ result = self.terminal.config["thinking_mode"]
292
+ info = THINKING_MODES.get(result, {})
293
+ if HAS_RICH:
294
+ console.print(f"[green]Thinking: {info.get('label', result)}[/green] [dim]{info.get('description', '')}[/dim]")
295
+ else:
296
+ print(f"Thinking: {result}")
297
+
298
+ def cmd_skills(self, args: str):
299
+ """List all available skills grouped by category."""
300
+ categories = {}
301
+ for s in SKILLS:
302
+ cat = s["category"]
303
+ if cat not in categories:
304
+ categories[cat] = []
305
+ categories[cat].append(s)
306
+
307
+ cat_labels = {
308
+ "research": "Research",
309
+ "analysis": "Analysis",
310
+ "strategy": "Strategy",
311
+ "risk": "Risk Management",
312
+ "quant": "Quantitative",
313
+ "crypto": "Crypto",
314
+ "tools": "Tools",
315
+ "code": "Code Generation",
316
+ }
317
+
318
+ if HAS_RICH:
319
+ console.print()
320
+ for cat, skills in categories.items():
321
+ label = cat_labels.get(cat, cat.title())
322
+ console.print(f" [bold]{label}[/bold]")
323
+ for s in skills:
324
+ args_hint = f" [dim]{s.get('args', '')}[/dim]" if s.get("args") else ""
325
+ console.print(f" [bold]{s['command']:20s}[/bold][dim]{s['description']}[/dim]{args_hint}")
326
+ console.print()
327
+
328
+ console.print("[dim] Type a skill command to execute, e.g. /deep-analysis AAPL[/dim]\n")
329
+ else:
330
+ print("\nSkills:")
331
+ for cat, skills in categories.items():
332
+ label = cat_labels.get(cat, cat.title())
333
+ print(f"\n [{label}]")
334
+ for s in skills:
335
+ print(f" {s['command']:20s} {s['description']}")
336
+
337
+ async def _execute_skill(self, skill: dict, args: str):
338
+ """Execute a skill by expanding its prompt template and sending to AI."""
339
+ parts = args.strip().upper().split() if args.strip() else []
340
+ cmd = skill["command"]
341
+
342
+ # Skill invocation header — matches the ⏺ tool-call rhythm
343
+ _skill_name = skill.get("name") or cmd.lstrip("/")
344
+ _arg_hint = f" [dim]{args.strip()}[/dim]" if args.strip() else ""
345
+ if HAS_RICH:
346
+ console.print(f"\n [#C08050]⏺[/#C08050] [bold]技能 · {_skill_name}[/bold]{_arg_hint}")
347
+ else:
348
+ print(f"\n ⏺ 技能 · {_skill_name} {args.strip()}")
349
+
350
+ # Build the prompt from template
351
+ template = skill["prompt"]
352
+
353
+ if cmd == "/deep-analysis":
354
+ symbol = parts[0] if parts else "AAPL"
355
+ prompt = template.format(symbol=symbol)
356
+
357
+ elif cmd == "/trade-idea":
358
+ context = f" in {' '.join(parts)}" if parts else " in the US market"
359
+ prompt = template.format(context=context)
360
+
361
+ elif cmd == "/risk-report":
362
+ if parts:
363
+ symbols = ", ".join(parts)
364
+ else:
365
+ symbols = ", ".join(self.terminal.config.get("watchlist", ["AAPL", "MSFT", "GOOGL"]))
366
+ prompt = template.format(symbols=symbols)
367
+
368
+ elif cmd == "/factor-screen":
369
+ factor = " ".join(parts).lower() if parts else "momentum"
370
+ prompt = template.format(factor=factor)
371
+
372
+ elif cmd == "/backtest-report":
373
+ strategy = parts[0].lower() if len(parts) > 0 else "momentum"
374
+ symbol = parts[1] if len(parts) > 1 else "SPY"
375
+ start = parts[2] if len(parts) > 2 else "2023-01-01"
376
+ end = parts[3] if len(parts) > 3 else "2025-01-01"
377
+ prompt = template.format(strategy=strategy, symbol=symbol, start=start, end=end)
378
+
379
+ elif cmd == "/morning-brief":
380
+ extra = f"\nFocus on: {' '.join(parts)}" if parts else ""
381
+ prompt = template.format(extra=extra)
382
+
383
+ elif cmd == "/macro-outlook":
384
+ context = f" for {' '.join(parts)}" if parts else " for the US and global economy"
385
+ prompt = template.format(context=context)
386
+
387
+ elif cmd == "/crypto-scan":
388
+ extra = f"\nFocus on: {' '.join(parts)}" if parts else ""
389
+ prompt = template.format(extra=extra)
390
+
391
+ elif cmd == "/watchlist-scan":
392
+ symbols = ", ".join(self.terminal.config.get("watchlist", ["AAPL", "MSFT", "GOOGL"]))
393
+ prompt = template.format(symbols=symbols)
394
+
395
+ elif cmd == "/sector-rotation":
396
+ prompt = template
397
+
398
+ elif cmd == "/gen-strategy":
399
+ strategy = parts[0].lower() if len(parts) > 0 else "momentum"
400
+ symbol = parts[1] if len(parts) > 1 else "SPY"
401
+ prompt = template.format(strategy=strategy, symbol=symbol)
402
+
403
+ elif cmd == "/gen-analysis":
404
+ topic = " ".join(parts[:2]).lower() if parts else "technical analysis"
405
+ symbols = ", ".join(parts[2:]) if len(parts) > 2 else "SPY"
406
+ prompt = template.format(topic=topic, symbols=symbols)
407
+
408
+ elif cmd == "/gen-bot":
409
+ exchange = parts[0].lower() if len(parts) > 0 else "binance"
410
+ strategy = " ".join(parts[1:]).lower() if len(parts) > 1 else "grid trading"
411
+ prompt = template.format(exchange=exchange, strategy=strategy)
412
+
413
+ else:
414
+ prompt = template
415
+
416
+ # Show skill activation
417
+ if HAS_RICH:
418
+ tools = ", ".join(skill.get("tools_hint", [])[:3])
419
+ console.print(f"[bold]Skill:[/bold] [bold]{skill['name']}[/bold] [dim]tools: {tools}[/dim]")
420
+ else:
421
+ print(f"Skill: {skill['name']}")
422
+
423
+ await self.terminal.send_message(prompt)
424
+
425
+ def cmd_tools(self, args: str):
426
+ if HAS_RICH:
427
+ console.print()
428
+ console.print(" [bold]Local Tools[/bold] [dim](Code Agent)[/dim]")
429
+ for i, (name, (_, desc)) in enumerate(LOCAL_TOOLS.items(), 1):
430
+ console.print(f" [bold]{name:28s}[/bold][dim]{desc}[/dim]")
431
+ console.print()
432
+
433
+ console.print(f" [bold]Remote Tools[/bold] [dim]({len(ARIA_TOOLS)})[/dim]")
434
+ for i, (name, desc) in enumerate(ARIA_TOOLS, 1):
435
+ console.print(f" [bold]{name:28s}[/bold][dim]{desc}[/dim]")
436
+ console.print()
437
+ else:
438
+ print("\nLocal Tools (Code Agent):")
439
+ for i, (name, (_, desc)) in enumerate(LOCAL_TOOLS.items(), 1):
440
+ print(f" {i:2d}. {name:30s} {desc}")
441
+ print("\nRemote Aria Tools (22):")
442
+ for i, (name, desc) in enumerate(ARIA_TOOLS, 1):
443
+ print(f" {i:2d}. {name:30s} {desc}")
444
+
445
+ async def cmd_apikey(self, args: str):
446
+ """Manage Cloud API keys.
447
+
448
+ Usage:
449
+ /apikey — 交互式向导:选择 provider → 输入 key → 测试连接
450
+ /apikey set <p> <k> — 直接保存
451
+ /apikey list — 列出所有已配置 key
452
+ /apikey remove <p> — 删除 key
453
+ /apikey test <p> — 测试连接
454
+ """
455
+ parts = args.strip().split()
456
+ sub = parts[0].lower() if parts else ""
457
+
458
+ # ── 无参数 or "add" → 交互式向导 ─────────────────────────────────────
459
+ if not sub or sub in ("add", "wizard"):
460
+ await self._cmd_apikey_wizard()
461
+ return
462
+
463
+ pjson = _load_providers_json() # dict of {provider: {api_key, base_url, ...}}
464
+
465
+ if sub == "set-url":
466
+ # /apikey set-url <provider> <base_url>
467
+ # 允许自定义端点(中转代理、国内镜像等),示例:
468
+ # /apikey set-url openai https://my-proxy.com
469
+ # /apikey set-url siliconflow https://api.siliconflow.cn
470
+ if len(parts) < 3:
471
+ msg = "Usage: /apikey set-url <provider> <base_url>"
472
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
473
+ return
474
+ provider = parts[1].lower()
475
+ url = parts[2].rstrip("/")
476
+ entry = pjson.get(provider, {})
477
+ entry["base_url"] = url
478
+ pjson[provider] = entry
479
+ _save_providers_json(pjson)
480
+ msg = f"✓ {provider.capitalize()} base_url 已更新: {url}"
481
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
482
+ return
483
+
484
+ if sub == "set":
485
+ if len(parts) < 3:
486
+ msg = ("Usage: /apikey set <provider> <key> (e.g. /apikey set deepseek sk-...)\n"
487
+ " /apikey set-url <provider> <base_url> (自定义代理端点)")
488
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
489
+ return
490
+ provider = parts[1].lower()
491
+ key = parts[2]
492
+ _all_known = set(_PROVIDER_KEY_MAP) | set(_DATA_KEY_MAP) | set(_PROVIDER_BASE_URLS)
493
+ if provider not in _all_known:
494
+ known_llm = ", ".join(sorted(_PROVIDER_KEY_MAP.keys()))
495
+ known_data = ", ".join(sorted(_DATA_KEY_MAP.keys()))
496
+ msg = (f"Unknown provider '{provider}'.\n"
497
+ f" LLM providers: {known_llm}\n"
498
+ f" Data services: {known_data}")
499
+ console.print(f"[yellow]{msg}[/yellow]") if HAS_RICH else print(msg)
500
+ return
501
+
502
+ # ── Data service key ──────────────────────────────────────────────
503
+ if provider in _DATA_KEY_MAP:
504
+ _save_data_key(provider, key)
505
+ env_var = _DATA_KEY_MAP[provider]
506
+ os.environ[env_var] = key # take effect immediately
507
+ masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
508
+ signup = _DATA_SIGNUP_URLS.get(provider, "")
509
+ msg = f"✓ {provider.capitalize()} 数据服务 key 已保存 ({masked})"
510
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
511
+ return
512
+
513
+ # ── LLM provider key (original logic) ────────────────────────────
514
+ # Persist to providers.json
515
+ entry = pjson.get(provider, {})
516
+ entry["api_key"] = key
517
+ if provider in _PROVIDER_BASE_URLS:
518
+ entry.setdefault("base_url", _PROVIDER_BASE_URLS[provider])
519
+ pjson[provider] = entry
520
+ _save_providers_json(pjson)
521
+ # Also set in current process env so it works immediately
522
+ env_var = _PROVIDER_KEY_MAP.get(provider)
523
+ if env_var:
524
+ os.environ[env_var] = key
525
+ masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
526
+ msg = f"✓ {provider.capitalize()} API key 已保存 ({masked})"
527
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
528
+
529
+ elif sub == "list":
530
+ _LLM_ORDER = [
531
+ # 国际
532
+ "deepseek", "anthropic", "openai", "google", "xai",
533
+ "groq", "mistral", "cohere", "perplexity", "together",
534
+ # 国内
535
+ "siliconflow", "dashscope", "moonshot", "zhipu",
536
+ "baidu", "bytedance", "minimax", "stepfun", "01ai",
537
+ ]
538
+ _DATA_ORDER = ["finnhub", "alphavantage", "twelvedata", "polygon",
539
+ "fmp", "newsapi", "coingecko", "tavily", "brave"]
540
+ data_configured = _load_data_keys()
541
+
542
+ if HAS_RICH:
543
+ from rich.table import Table
544
+ from rich import box as _rbox
545
+ console.print()
546
+ console.print(" [bold]🤖 LLM 服务 Keys[/bold] [dim]— /apikey 进入向导[/dim]")
547
+ console.print()
548
+ for prov in _LLM_ORDER:
549
+ env_var = _PROVIDER_KEY_MAP.get(prov, "")
550
+ key_val = os.getenv(env_var or "") or pjson.get(prov, {}).get("api_key", "")
551
+ desc = _PROVIDER_DESC.get(prov, "")
552
+ if key_val:
553
+ masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
554
+ console.print(f" [green]●[/green] [green]{prov:<14}[/green] {masked} [dim]{desc}[/dim]")
555
+ else:
556
+ console.print(f" [dim]○ {prov:<14} 未配置 {desc}[/dim]")
557
+ console.print()
558
+ console.print(" [bold]📊 数据服务 Keys[/bold] [dim]— 后端离线时直连数据源[/dim]")
559
+ console.print()
560
+ for svc in _DATA_ORDER:
561
+ key_val = data_configured.get(svc, "")
562
+ desc = _PROVIDER_DESC.get(svc, "")
563
+ if key_val:
564
+ masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
565
+ console.print(f" [green]●[/green] [green]{svc:<14}[/green] {masked} [dim]{desc}[/dim]")
566
+ else:
567
+ console.print(f" [dim]○ {svc:<14} 未配置 {desc}[/dim]")
568
+ console.print()
569
+ console.print(" [dim]提示: /apikey 进入交互向导 · /apikey test <provider> 测试连接[/dim]")
570
+ console.print()
571
+ else:
572
+ print("\n LLM Providers:")
573
+ for prov in _LLM_ORDER:
574
+ env_var = _PROVIDER_KEY_MAP.get(prov, "")
575
+ key_val = os.getenv(env_var or "") or pjson.get(prov, {}).get("api_key", "")
576
+ status = key_val[:6] + "****" if key_val else "未配置"
577
+ print(f" {prov:14s} {status}")
578
+ print("\n Data Services:")
579
+ for svc in _DATA_ORDER:
580
+ key_val = data_configured.get(svc, "")
581
+ status = key_val[:6] + "****" if key_val else "未配置"
582
+ print(f" {svc:16s} {status}")
583
+
584
+ elif sub == "remove":
585
+ if len(parts) < 2:
586
+ console.print("[dim]Usage: /apikey remove <provider>[/dim]") if HAS_RICH else print("Usage: /apikey remove <provider>")
587
+ return
588
+ provider = parts[1].lower()
589
+ # LLM section
590
+ if provider in pjson:
591
+ pjson[provider].pop("api_key", None)
592
+ if not pjson[provider]:
593
+ del pjson[provider]
594
+ _save_providers_json(pjson)
595
+ # Data section
596
+ if provider in _DATA_KEY_MAP:
597
+ try:
598
+ if PROVIDERS_FILE.exists():
599
+ raw = json.loads(PROVIDERS_FILE.read_text(encoding="utf-8"))
600
+ if provider in raw.get("data", {}):
601
+ del raw["data"][provider]
602
+ PROVIDERS_FILE.write_text(json.dumps(raw, indent=2, ensure_ascii=False), encoding="utf-8")
603
+ except Exception as _e:
604
+ logger.debug("apikey delete from file failed: %s", _e)
605
+ # Clear from env
606
+ env_var = _PROVIDER_KEY_MAP.get(provider) or _DATA_KEY_MAP.get(provider)
607
+ if env_var and env_var in os.environ:
608
+ del os.environ[env_var]
609
+ msg = f"✓ {provider.capitalize()} key 已删除"
610
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
611
+
612
+ elif sub == "test":
613
+ if len(parts) < 2:
614
+ console.print("[dim]Usage: /apikey test <provider>[/dim]") if HAS_RICH else print("Usage: /apikey test <provider>")
615
+ return
616
+ provider = parts[1].lower()
617
+ key = _get_provider_key(provider) or _load_data_keys().get(provider, "")
618
+ if not key:
619
+ msg = f"⚠ {provider} API key 未配置,先运行 /apikey {provider}"
620
+ console.print(f"[yellow]{msg}[/yellow]") if HAS_RICH else print(msg)
621
+ return
622
+ console.print(f"[dim] 正在测试 {provider}…[/dim]") if HAS_RICH else print(f" 测试 {provider}…")
623
+ import asyncio as _aio
624
+ loop = _aio.get_event_loop()
625
+ ok, result_msg = await loop.run_in_executor(None, _test_api_key, provider, key)
626
+ color = "green" if ok else "yellow"
627
+ console.print(f" [{color}]{result_msg}[/{color}]") if HAS_RICH else print(f" {result_msg}")
628
+
629
+ else:
630
+ console.print("[dim]Usage: /apikey [set|list|remove|test] — 或直接 /apikey 进入向导[/dim]") if HAS_RICH else print("Usage: /apikey [set|list|remove|test]")
631
+
632
+ async def _cmd_apikey_wizard(self):
633
+ """交互式 API Key 配置向导:选 provider → 查看指引 → 输入 key → 测试连接。"""
634
+ import getpass as _getpass
635
+
636
+ pjson = _load_providers_json()
637
+ data_cfg = _load_data_keys()
638
+
639
+ def _is_configured(name: str) -> bool:
640
+ env = _PROVIDER_KEY_MAP.get(name) or _DATA_KEY_MAP.get(name)
641
+ if env and os.getenv(env):
642
+ return True
643
+ if name in pjson and pjson[name].get("api_key"):
644
+ return True
645
+ if name in data_cfg:
646
+ return True
647
+ return False
648
+
649
+ # ── 分组构建 picker ───────────────────────────────────────────────────
650
+ _LLM_ORDER = [
651
+ # 国际
652
+ "deepseek", "anthropic", "openai", "google", "xai",
653
+ "groq", "mistral", "cohere", "perplexity", "together",
654
+ # 国内
655
+ "siliconflow", "dashscope", "moonshot", "zhipu",
656
+ "baidu", "bytedance", "minimax", "stepfun", "01ai",
657
+ ]
658
+ _DATA_ORDER = ["finnhub", "alphavantage", "twelvedata", "polygon",
659
+ "fmp", "newsapi", "coingecko", "tavily", "brave"]
660
+
661
+ all_items = [] # (label, desc, key_name | None)
662
+
663
+ all_items.append(("─── 🤖 LLM 服务 (对话·分析·推理) ", "", None))
664
+ for k in _LLM_ORDER:
665
+ dot = "[green]●[/green]" if _is_configured(k) else "[dim]○[/dim]"
666
+ desc = _PROVIDER_DESC.get(k, "")
667
+ configured_tag = " ✓" if _is_configured(k) else ""
668
+ all_items.append((f" {k:<14}{configured_tag}", desc, k))
669
+
670
+ all_items.append(("─── 📊 数据服务 (行情·财报·新闻) ", "", None))
671
+ for k in _DATA_ORDER:
672
+ desc = _PROVIDER_DESC.get(k, "")
673
+ configured_tag = " ✓" if _is_configured(k) else ""
674
+ all_items.append((f" {k:<14}{configured_tag}", desc, k))
675
+
676
+ picker_opts = [(label, desc) for label, desc, _ in all_items]
677
+ sep_indices = {i for i, (_, _, key) in enumerate(all_items) if key is None}
678
+ key_at = {i: key for i, (_, _, key) in enumerate(all_items) if key}
679
+
680
+ # 默认选中第一个真实条目
681
+ first_real = next(i for i in range(len(all_items)) if i not in sep_indices)
682
+ selected = first_real
683
+
684
+ while True:
685
+ console.print() if HAS_RICH else None
686
+
687
+ if HAS_RICH:
688
+ from rich.panel import Panel as _Panel
689
+ from rich import box as _rbox
690
+ console.print(_Panel(
691
+ " ↑↓ 上下选择 · Enter 确认 · ESC/q 退出向导\n"
692
+ " [green]●[/green] 已配置 [dim]○[/dim] 未配置 ✓ 表示 key 已存在",
693
+ border_style="dim", box=_rbox.ROUNDED, padding=(0, 2),
694
+ ))
695
+
696
+ idx = _arrow_select(picker_opts, selected=selected, title="选择要配置的 Provider", max_visible=20)
697
+
698
+ if idx < 0:
699
+ console.print("[dim]已退出向导[/dim]") if HAS_RICH else print("已退出")
700
+ return
701
+
702
+ if idx in sep_indices:
703
+ nxt = next((i for i in range(idx + 1, len(all_items)) if i not in sep_indices), first_real)
704
+ selected = nxt
705
+ continue
706
+
707
+ provider = key_at[idx]
708
+ selected = idx
709
+
710
+ # ── 显示获取指引 ──────────────────────────────────────────────────
711
+ guide = _PROVIDER_GUIDE.get(provider, "")
712
+ signup = _LLM_SIGNUP_URLS.get(provider) or _DATA_SIGNUP_URLS.get(provider, "")
713
+ if HAS_RICH:
714
+ from rich.panel import Panel as _Panel
715
+ from rich import box as _rbox
716
+ guide_body = guide
717
+ if signup:
718
+ guide_body += f"\n\n[bold cyan]🔗 {signup}[/bold cyan]"
719
+ current_key = _get_provider_key(provider)
720
+ if current_key:
721
+ masked = current_key[:6] + "****" + current_key[-4:] if len(current_key) > 10 else "****"
722
+ guide_body += f"\n\n[green]当前 key: {masked}[/green] (直接回车保留现有 key)"
723
+ console.print()
724
+ console.print(_Panel(
725
+ guide_body,
726
+ title=f"[bold]{provider.upper()} 配置指引[/bold]",
727
+ border_style="cyan", box=_rbox.ROUNDED, padding=(0, 2),
728
+ ))
729
+ else:
730
+ print(f"\n=== {provider.upper()} ===")
731
+ print(guide)
732
+ if signup:
733
+ print(f"注册地址: {signup}")
734
+
735
+ # ── 输入 key ──────────────────────────────────────────────────────
736
+ prompt_str = f" 请输入 {provider} API Key (输入后不显示): "
737
+ try:
738
+ raw_key = _getpass.getpass(prompt_str)
739
+ except (KeyboardInterrupt, EOFError):
740
+ console.print("\n[dim]已跳过[/dim]") if HAS_RICH else print("\n已跳过")
741
+ continue
742
+
743
+ raw_key = raw_key.strip()
744
+ if not raw_key:
745
+ # 保留现有 key,直接回到 picker
746
+ msg = "未输入 key,保留现有配置"
747
+ console.print(f"[dim] {msg}[/dim]") if HAS_RICH else print(f" {msg}")
748
+ continue
749
+
750
+ # ── 保存 ──────────────────────────────────────────────────────────
751
+ if provider in _DATA_KEY_MAP:
752
+ _save_data_key(provider, raw_key)
753
+ env_var = _DATA_KEY_MAP[provider]
754
+ os.environ[env_var] = raw_key
755
+ else:
756
+ pjson_fresh = _load_providers_json()
757
+ entry = pjson_fresh.get(provider, {})
758
+ entry["api_key"] = raw_key
759
+ if provider in _PROVIDER_BASE_URLS:
760
+ entry.setdefault("base_url", _PROVIDER_BASE_URLS[provider])
761
+ pjson_fresh[provider] = entry
762
+ _save_providers_json(pjson_fresh)
763
+ env_var = _PROVIDER_KEY_MAP.get(provider)
764
+ if env_var:
765
+ os.environ[env_var] = raw_key
766
+ pjson = pjson_fresh
767
+
768
+ masked = raw_key[:6] + "****" + raw_key[-4:] if len(raw_key) > 10 else "****"
769
+ msg = f"✓ {provider} key 已保存 ({masked})"
770
+ console.print(f"[green] {msg}[/green]") if HAS_RICH else print(f" {msg}")
771
+
772
+ # ── 连接测试 ──────────────────────────────────────────────────────
773
+ print(f" 正在测试连接…", end="", flush=True)
774
+ import asyncio as _aio
775
+ loop = _aio.get_event_loop()
776
+ ok, result_msg = await loop.run_in_executor(None, _test_api_key, provider, raw_key)
777
+ print("\r", end="") # 清除"正在测试"那行
778
+ if HAS_RICH:
779
+ color = "green" if ok else "yellow"
780
+ console.print(f" [{color}]{result_msg}[/{color}]")
781
+ else:
782
+ print(f" {result_msg}")
783
+
784
+ # ── 继续配置其他 provider? ───────────────────────────────────────
785
+ console.print() if HAS_RICH else None
786
+ try:
787
+ again = input(" 继续配置其他 provider? (y/N) › ").strip().lower()
788
+ except (KeyboardInterrupt, EOFError):
789
+ again = "n"
790
+ if again not in ("y", "yes", "是"):
791
+ console.print("[dim] 向导已完成。输入 /apikey list 查看所有配置。[/dim]") if HAS_RICH else print("向导完成")
792
+ return
793
+
794
+ def cmd_providers(self, args: str):
795
+ """Show all LLM providers: local backends + cloud API status (Open Interpreter style)."""
796
+ if HAS_RICH:
797
+ console.print()
798
+
799
+ # ── Section 1: Local backends ────────────────────────────────────────
800
+ try:
801
+ from local_llm_provider import probe_all_backends, BACKEND_DEFAULTS
802
+ results = probe_all_backends()
803
+ current_provider = self.terminal.config.get("local_provider", "ollama")
804
+ # Count Ollama models if online
805
+ _ollama_count = ""
806
+ if results.get("ollama"):
807
+ try:
808
+ _omodels, _ = detect_ollama_models_rich(
809
+ self.terminal.config.get("ollama_url", "http://localhost:11434"))
810
+ _ollama_count = f" [dim]{len(_omodels)} 个模型[/dim]" if _omodels else ""
811
+ except Exception:
812
+ pass
813
+
814
+ if HAS_RICH:
815
+ console.print(" [bold]本地 Backend[/bold]")
816
+ console.print()
817
+ else:
818
+ print(" == Local Backends ==")
819
+
820
+ for name, available in results.items():
821
+ info = BACKEND_DEFAULTS.get(name, {})
822
+ url = info.get("default_url", "")
823
+ color = "green" if available else "dim"
824
+ icon = "✅" if available else "○"
825
+ active = " ◀ active" if name == current_provider else ""
826
+ extra = _ollama_count if (name == "ollama" and available) else ""
827
+ if HAS_RICH:
828
+ console.print(
829
+ f" {icon} [{color}]{name:12s}[/{color}]"
830
+ f" [dim]{url:30s}[/dim]{extra}"
831
+ f"[green]{active}[/green]"
832
+ )
833
+ else:
834
+ status = "✓" if available else "✗"
835
+ print(f" {status} {name:12s} {url}{active}")
836
+ except ImportError:
837
+ pass
838
+
839
+ # ── Section 2: Cloud provider API keys ───────────────────────────────
840
+ pjson = _load_providers_json()
841
+ _CLOUD_LIST = [
842
+ # ── 国际云端 ────────────────────────────────────────────────
843
+ ("deepseek", "DeepSeek", "deepseek/deepseek-chat"),
844
+ ("anthropic", "Anthropic", "anthropic/claude-sonnet-4-6"),
845
+ ("openai", "OpenAI", "openai/gpt-4.5"),
846
+ ("google", "Google Gemini", "google/gemini-2.0-flash-exp"),
847
+ ("xai", "xAI Grok", "xai/grok-3-fast"),
848
+ ("groq", "Groq", "groq/llama-3.3-70b-versatile"),
849
+ ("mistral", "Mistral", "mistral/mistral-large-latest"),
850
+ ("cohere", "Cohere", "cohere/command-r-plus"),
851
+ ("perplexity", "Perplexity", "perplexity/sonar-pro"),
852
+ ("together", "Together", "together/meta-llama/Meta-Llama-3.1-70B"),
853
+ # ── 国内云端 ────────────────────────────────────────────────
854
+ ("siliconflow", "SiliconFlow", "siliconflow/Qwen/Qwen2.5-7B-Instruct"),
855
+ ("dashscope", "DashScope", "dashscope/qwen-max"),
856
+ ("moonshot", "Moonshot Kimi", "moonshot/moonshot-v1-128k"),
857
+ ("zhipu", "Zhipu GLM", "zhipu/glm-4-plus"),
858
+ ("baidu", "Baidu ERNIE", "baidu/ernie-4.5-turbo-128k"),
859
+ ("bytedance", "ByteDance", "bytedance/<endpoint-id>"),
860
+ ("minimax", "MiniMax", "minimax/MiniMax-Text-01"),
861
+ ("stepfun", "StepFun", "stepfun/step-2-16k"),
862
+ ("01ai", "01.AI Yi", "01ai/yi-large"),
863
+ ]
864
+ if HAS_RICH:
865
+ console.print()
866
+ console.print(" [bold]Cloud Provider API[/bold]")
867
+ console.print()
868
+ else:
869
+ print()
870
+ print(" == Cloud Providers ==")
871
+
872
+ for prov, label, example_model in _CLOUD_LIST:
873
+ env_var = _PROVIDER_KEY_MAP.get(prov, "")
874
+ key = (os.getenv(env_var, "") if env_var else "") or \
875
+ (pjson.get(prov, {}).get("api_key", "") if isinstance(pjson, dict) else "")
876
+ if key:
877
+ masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
878
+ if HAS_RICH:
879
+ console.print(f" 🔑 [green]{label:14s}[/green] [dim]{masked}[/dim]")
880
+ else:
881
+ print(f" ✓ {label:14s} {masked}")
882
+ else:
883
+ hint = f"/apikey set {prov} <key>"
884
+ if HAS_RICH:
885
+ console.print(f" ○ [dim]{label:14s} 未配置 → {hint}[/dim]")
886
+ else:
887
+ print(f" ✗ {label:14s} {hint}")
888
+
889
+ # ── Custom endpoint ──────────────────────────────────────────────────
890
+ custom_ep = self.terminal.config.get("custom_endpoint", "")
891
+ custom_m = self.terminal.config.get("custom_model", "")
892
+ if custom_ep:
893
+ if HAS_RICH:
894
+ console.print()
895
+ console.print(f" 🔧 [bold]Custom endpoint[/bold] [dim]{custom_ep}[/dim] model=[cyan]{custom_m or '?'}[/cyan]")
896
+ else:
897
+ print(f"\n Custom: {custom_ep} model={custom_m}")
898
+
899
+ # ── Data service keys section ─────────────────────────────────────────
900
+ _data_keys = _load_data_keys()
901
+ _DATA_DISPLAY = [
902
+ ("finnhub", "Finnhub", "股票+新闻"),
903
+ ("newsapi", "NewsAPI", "财经新闻"),
904
+ ("brave", "Brave Search", "网页搜索"),
905
+ ("alphavantage", "Alpha Vantage", "历史数据"),
906
+ ("coingecko", "CoinGecko Pro", "加密数据"),
907
+ ("twelvedata", "Twelve Data", "全球行情"),
908
+ ]
909
+ if HAS_RICH:
910
+ console.print()
911
+ console.print(" [bold]📊 数据服务 API[/bold] [dim](后端离线时的本地数据源)[/dim]")
912
+ console.print()
913
+ else:
914
+ print("\n == Data Service APIs ==")
915
+ for svc, label, desc in _DATA_DISPLAY:
916
+ key_val = _data_keys.get(svc, "")
917
+ if key_val:
918
+ masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
919
+ signup = _DATA_SIGNUP_URLS.get(svc, "")
920
+ if HAS_RICH:
921
+ console.print(f" 🔑 [green]{label:18s}[/green] [dim]{masked} {desc}[/dim]")
922
+ else:
923
+ print(f" ✓ {label:18s} {masked}")
924
+ else:
925
+ hint = f"/apikey set {svc} <key>"
926
+ signup = _DATA_SIGNUP_URLS.get(svc, "")
927
+ if HAS_RICH:
928
+ console.print(f" ○ [dim]{label:18s} 未配置 → {hint}[/dim]")
929
+ else:
930
+ print(f" ✗ {label:18s} {hint}")
931
+
932
+ # ── Free data source registry (akshare / yfinance / tushare) ────────────
933
+ try:
934
+ from datasources.router import DataRouter as _DR
935
+ free_sources = _DR().list_sources()
936
+ except Exception:
937
+ free_sources = []
938
+
939
+ if free_sources:
940
+ if HAS_RICH:
941
+ console.print()
942
+ console.print(" [bold]免费行情数据源[/bold] [dim](datasources/router — no API key required)[/dim]")
943
+ console.print()
944
+ else:
945
+ print("\n == Free Market Data Sources ==")
946
+ for s in free_sources:
947
+ ok_icon = "[green]✓[/green]" if s["configured"] else "[dim]○[/dim]"
948
+ key_tag = " [dim](no key)[/dim]" if not s["needs_key"] else " [dim](API key)[/dim]"
949
+ mkts = ", ".join(s.get("markets", []))
950
+ if HAS_RICH:
951
+ console.print(
952
+ f" {ok_icon} [bold]{s['name']:12s}[/bold] "
953
+ f"[dim]{mkts:22s}[/dim]{key_tag}"
954
+ )
955
+ else:
956
+ ok = "✓" if s["configured"] else "○"
957
+ key = "(no key)" if not s["needs_key"] else "(key)"
958
+ print(f" {ok} {s['name']:12s} {mkts:22s} {key}")
959
+ if HAS_RICH:
960
+ console.print(" [dim]Config: ~/.aria/datasources.yaml[/dim]")
961
+
962
+ if HAS_RICH:
963
+ console.print()
964
+ console.print(" [dim]配置 LLM Key: /apikey set deepseek <key>[/dim]")
965
+ console.print(" [dim]配置数据 Key: /apikey set finnhub <key>[/dim]")
966
+ console.print(" [dim]切换模型: /model deepseek/deepseek-chat[/dim]")
967
+ console.print(" [dim]首次向导: /setup[/dim]")
968
+ console.print(" [dim]自定义端点: /config set custom_endpoint=http://...[/dim]")
969
+ console.print()
970
+
971
+ async def cmd_cloud(self, args: str):
972
+ """
973
+ Manage Alibaba Cloud data service connection.
974
+
975
+ Usage:
976
+ /cloud status — show connection status & circuit breaker state
977
+ /cloud set <url> — set cloud_api_server URL (e.g. http://your-aliyun-ip:8000)
978
+ /cloud data <url> — set akshare_data_server URL (e.g. http://your-aliyun-ip:8002)
979
+ /cloud token <jwt-token> — set API token
980
+ /cloud health — live health-check both services
981
+ /cloud reset — reset circuit breakers
982
+ """
983
+ try:
984
+ from aliyun_data_client import AliyunDataClient, save_cloud_config, summarize_cloud_health
985
+ except ImportError:
986
+ if HAS_RICH:
987
+ console.print(" [red]aliyun_data_client.py not found[/red]")
988
+ else:
989
+ print(" aliyun_data_client.py not found")
990
+ return
991
+
992
+ parts = args.strip().split(None, 2)
993
+ sub = parts[0].lower() if parts else "status"
994
+
995
+ if sub == "set" and len(parts) >= 2:
996
+ url = parts[1]
997
+ save_cloud_config(cloud_url=url)
998
+ AliyunDataClient.reset()
999
+ if HAS_RICH:
1000
+ console.print(f" [green]Cloud API URL set to: {url}[/green]")
1001
+ console.print(f" [dim]Saved to ~/.arthera/config.json[/dim]")
1002
+ return
1003
+
1004
+ if sub == "data" and len(parts) >= 2:
1005
+ url = parts[1]
1006
+ save_cloud_config(data_url=url)
1007
+ AliyunDataClient.reset()
1008
+ if HAS_RICH:
1009
+ console.print(f" [green]AKShare Data URL set to: {url}[/green]")
1010
+ console.print(f" [dim]Saved to ~/.arthera/config.json[/dim]")
1011
+ return
1012
+
1013
+ if sub == "token" and len(parts) >= 2:
1014
+ token = parts[1]
1015
+ save_cloud_config(api_token=token)
1016
+ AliyunDataClient.reset()
1017
+ if HAS_RICH:
1018
+ console.print(f" [green]API token saved (length {len(token)})[/green]")
1019
+ return
1020
+
1021
+ if sub == "reset":
1022
+ AliyunDataClient.reset()
1023
+ if HAS_RICH:
1024
+ console.print(" [green]Circuit breakers reset, config reloaded[/green]")
1025
+ return
1026
+
1027
+ client = AliyunDataClient.get()
1028
+
1029
+ if sub == "health":
1030
+ if HAS_RICH:
1031
+ console.print(" [dim]Checking health…[/dim]")
1032
+ with console.status("[dim]Checking cloud services…[/dim]", spinner="dots") if HAS_RICH else _null_ctx():
1033
+ cloud_h = await client.health_cloud()
1034
+ data_h = await client.health_data()
1035
+ st = client.status()
1036
+ summary = summarize_cloud_health(cloud_h, data_h, st)
1037
+
1038
+ def _svc_label(name: str, health: dict) -> str:
1039
+ status = str(health.get("status", "?"))
1040
+ ok = status in ("healthy", "ok", "ready", "online")
1041
+ color = "green" if ok else "red"
1042
+ icon = "✓" if ok else "✗"
1043
+ breaker = st.get("cloud_cb" if name == "cloud_api_server" else "data_cb", "?")
1044
+ return f" [{color}]●[/{color}] {name} {icon} {status} [dim]breaker={breaker}[/dim]"
1045
+
1046
+ def _print_health_detail(title: str, health: dict):
1047
+ if HAS_RICH:
1048
+ console.print()
1049
+ console.print(_svc_label(title, health))
1050
+ detail_keys = [
1051
+ (k, v) for k, v in health.items()
1052
+ if k not in {"status", "services", "cloud_url", "data_url"}
1053
+ ]
1054
+ if detail_keys:
1055
+ for k, v in detail_keys:
1056
+ console.print(f" [dim]{k}: {v}[/dim]")
1057
+ services = health.get("services") or {}
1058
+ if services:
1059
+ for svc, svc_status in services.items():
1060
+ svc_ok = "online" in str(svc_status) or "ready" in str(svc_status)
1061
+ svc_icon = "✓" if svc_ok else "○"
1062
+ svc_color = "green" if svc_ok else "yellow"
1063
+ console.print(f" [dim]{svc_icon} {svc}: [{svc_color}]{svc_status}[/{svc_color}][/dim]")
1064
+ else:
1065
+ print(f" {title}: {health.get('status', '?')}")
1066
+ for k, v in health.items():
1067
+ if k not in {"status", "services", "cloud_url", "data_url"}:
1068
+ print(f" {k}: {v}")
1069
+ for svc, svc_status in (health.get("services") or {}).items():
1070
+ print(f" {svc}: {svc_status}")
1071
+
1072
+ if HAS_RICH:
1073
+ console.print()
1074
+ color = "green" if summary.status == "ok" else "yellow" if summary.status == "warn" else "red"
1075
+ console.print(f" [bold]Summary[/bold] [{color}]{summary.detail}[/{color}]")
1076
+ console.print(f" [dim]breaker_open={summary.breaker_open} token_set={summary.token_set}[/dim]")
1077
+ console.print(f" [dim]suggestion: {summary.suggestion}[/dim]")
1078
+ console.print(f" [dim]cloud_api_server: {client.cloud_url}[/dim]")
1079
+ console.print(f" [dim]akshare_data_server: {client.data_url}[/dim]")
1080
+ _print_health_detail("cloud_api_server", cloud_h)
1081
+ _print_health_detail("akshare_data_server", data_h)
1082
+ console.print()
1083
+ else:
1084
+ print(f" Summary: {summary.detail} ({summary.status})")
1085
+ print(f" breaker_open={summary.breaker_open} token_set={summary.token_set}")
1086
+ print(f" suggestion: {summary.suggestion}")
1087
+ _print_health_detail("cloud_api_server", cloud_h)
1088
+ _print_health_detail("akshare_data_server", data_h)
1089
+ return
1090
+
1091
+ # Default: /cloud status
1092
+ st = client.status()
1093
+ if HAS_RICH:
1094
+ console.print()
1095
+ console.print(" [bold]Alibaba Cloud Data Services[/bold]")
1096
+ console.print()
1097
+ health_summary = st.get("health_summary") or {}
1098
+ color = "green" if health_summary.get("status") == "ok" else "yellow" if health_summary.get("status") == "warn" else "red"
1099
+ if health_summary:
1100
+ console.print(f" [bold]Health[/bold] [{color}]{health_summary.get('detail', '')}[/{color}]")
1101
+ console.print(f" [dim]breaker_open={health_summary.get('breaker_open', 0)} token_set={health_summary.get('token_set', False)}[/dim]")
1102
+ _c = "green" if st["cloud_cb"] == "closed" else "red"
1103
+ _d = "green" if st["data_cb"] == "closed" else "red"
1104
+ console.print(f" [{_c}]●[/{_c}] cloud_api_server [dim]{st['cloud_url']}[/dim]"
1105
+ f" [{_c}]{st['cloud_cb']}[/{_c}]")
1106
+ console.print(f" [{_d}]●[/{_d}] akshare_data_server [dim]{st['data_url']}[/dim]"
1107
+ f" [{_d}]{st['data_cb']}[/{_d}]")
1108
+ tok_str = "[green]set[/green]" if st["has_token"] else "[dim]not set[/dim]"
1109
+ console.print(f" Auth token: {tok_str}")
1110
+ console.print()
1111
+ console.print(" [dim]Configure: /cloud set <url> /cloud data <url> /cloud token <jwt>[/dim]")
1112
+ console.print(" [dim]Health: /cloud health[/dim]")
1113
+ console.print()
1114
+ else:
1115
+ health_summary = st.get("health_summary") or {}
1116
+ if health_summary:
1117
+ print(f" Health: {health_summary.get('detail', '')} ({health_summary.get('status', '')})")
1118
+ print(f" Cloud: {st['cloud_url']} ({st['cloud_cb']})")
1119
+ print(f" Data: {st['data_url']} ({st['data_cb']})")
1120
+ print(f" Token: {'set' if st['has_token'] else 'not set'}")
1121
+
1122
+ def cmd_permissions(self, args: str):
1123
+ """Tool permission policy manager.
1124
+
1125
+ /permissions — show current policy table
1126
+ /permissions allow <tool> — auto-approve this tool forever
1127
+ /permissions deny <tool> — permanently block this tool
1128
+ /permissions ask <tool> — always prompt before this tool
1129
+ /permissions reset — clear all custom rules
1130
+ /permissions remove <tool>— remove this tool from policy
1131
+ """
1132
+ try:
1133
+ from runtime.tool_policy import (
1134
+ load_tool_policy, save_tool_policy,
1135
+ add_to_policy, remove_from_policy,
1136
+ )
1137
+ except ImportError as _e:
1138
+ console.print(f"[red]runtime.tool_policy not available: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
1139
+ return
1140
+
1141
+ parts = args.strip().split(maxsplit=1)
1142
+ sub = parts[0].lower() if parts else "show"
1143
+
1144
+ if sub in ("allow", "deny", "ask"):
1145
+ if len(parts) < 2:
1146
+ msg = f"Usage: /permissions {sub} <tool_name>"
1147
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
1148
+ return
1149
+ tool = parts[1].strip()
1150
+ add_to_policy(tool, sub)
1151
+ labels = {"allow": ("[green]✓ 白名单[/green]", "auto-approve"), "deny": ("[red]✗ 黑名单[/red]", "block"), "ask": ("[yellow]? 询问[/yellow]", "ask-always")}
1152
+ rich_label, plain_label = labels[sub]
1153
+ if HAS_RICH:
1154
+ console.print(f" {rich_label} [bold]{tool}[/bold] [dim]已更新[/dim]")
1155
+ else:
1156
+ print(f" {plain_label}: {tool}")
1157
+ return
1158
+
1159
+ if sub == "reset":
1160
+ save_tool_policy({"allowed": [], "denied": [], "ask_always": []})
1161
+ msg = "✓ 工具权限策略已重置"
1162
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1163
+ return
1164
+
1165
+ if sub in ("remove", "rm"):
1166
+ if len(parts) < 2:
1167
+ msg = "Usage: /permissions remove <tool_name>"
1168
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
1169
+ return
1170
+ tool = parts[1].strip()
1171
+ if remove_from_policy(tool):
1172
+ msg = f"✓ '{tool}' 已从策略中移除"
1173
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1174
+ else:
1175
+ msg = f"'{tool}' 不在任何策略列表中"
1176
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
1177
+ return
1178
+
1179
+ # Default: show policy table
1180
+ policy = load_tool_policy()
1181
+ allowed = policy.get("allowed", [])
1182
+ denied = policy.get("denied", [])
1183
+ ask_always = policy.get("ask_always", [])
1184
+
1185
+ if HAS_RICH:
1186
+ from rich.table import Table
1187
+ from rich import box as _rbox
1188
+ console.print()
1189
+ console.print(" [bold]Tool Permissions[/bold] [dim](~/.arthera/tool_policy.json)[/dim]")
1190
+ console.print()
1191
+ t = Table(box=_rbox.SIMPLE, padding=(0, 1), show_header=True)
1192
+ t.add_column("Tool", style="bold", min_width=22)
1193
+ t.add_column("Policy", min_width=12)
1194
+ t.add_column("Effect", style="dim")
1195
+ for tool in sorted(allowed):
1196
+ t.add_row(tool, "[green]✓ allow[/green]", "auto-approve, never prompt")
1197
+ for tool in sorted(denied):
1198
+ t.add_row(tool, "[red]✗ deny[/red]", "always reject, never run")
1199
+ for tool in sorted(ask_always):
1200
+ t.add_row(tool, "[yellow]? ask[/yellow]", "always prompt even in auto mode")
1201
+ if not (allowed or denied or ask_always):
1202
+ t.add_row("[dim]— no custom rules —[/dim]", "", "")
1203
+ console.print(t)
1204
+ console.print(" [dim]/permissions allow <tool> — 白名单(自动批准)[/dim]")
1205
+ console.print(" [dim]/permissions deny <tool> — 黑名单(始终拒绝)[/dim]")
1206
+ console.print(" [dim]/permissions ask <tool> — 始终询问[/dim]")
1207
+ console.print(" [dim]/permissions remove <tool> — 移除规则[/dim]")
1208
+ console.print(" [dim]/permissions reset — 清空所有规则[/dim]")
1209
+ console.print()
1210
+ else:
1211
+ print("\n Tool Permissions (~/.arthera/tool_policy.json)")
1212
+ print(f" Allow: {', '.join(allowed) or 'none'}")
1213
+ print(f" Deny: {', '.join(denied) or 'none'}")
1214
+ print(f" Ask: {', '.join(ask_always) or 'none'}")
1215
+ print()
1216
+ print(" Usage: /permissions allow|deny|ask|remove|reset <tool>")
1217
+
1218
+ def cmd_config(self, args: str):
1219
+ """Show or set CLI configuration."""
1220
+ from apps.cli.config_paths import config_snapshot
1221
+ parts = args.strip().split(maxsplit=1)
1222
+ if not parts or parts[0] == "show":
1223
+ # Show current config
1224
+ cfg = self.terminal.config
1225
+ if HAS_RICH:
1226
+ console.print()
1227
+ console.print("[bold]Configuration[/bold]")
1228
+ console.print()
1229
+ snap = config_snapshot()
1230
+ for key in ("api_url", "ollama_url", "model", "thinking_mode",
1231
+ "command_policy", "permission_mode", "network_enabled",
1232
+ "write_policy", "lsp_autocheck", "input_style", "input_theme",
1233
+ "response_footer", "auto_compact_context",
1234
+ "auto_compact_threshold", "auto_save_sessions"):
1235
+ val = cfg.get(key, "-")
1236
+ console.print(f" [dim]{key:<24s}[/dim]{val}")
1237
+ console.print(f" [dim]{'config_dir':<24s}[/dim]{snap['config_dir']}")
1238
+ console.print(f" [dim]{'config_file':<24s}[/dim]{snap['config_file']}")
1239
+ console.print(f" [dim]{'sessions_dir':<24s}[/dim]{snap['sessions_dir']}")
1240
+ console.print(f" [dim]{'user_output_root':<24s}[/dim]{snap['user_output_root']}")
1241
+ # Show notification/search config from resolved config.json
1242
+ try:
1243
+ import json as _j
1244
+ _ncfg_path = Path(snap["config_file"])
1245
+ _ncfg = _j.loads(_ncfg_path.read_text()) if _ncfg_path.exists() else {}
1246
+ if _wh := _ncfg.get("notify_webhook"):
1247
+ console.print(f" [dim]{'notify_webhook':<24s}[/dim]{_wh[:50]}{'…' if len(_wh)>50 else ''}")
1248
+ except Exception:
1249
+ pass
1250
+ import os as _os_show
1251
+ if _os_show.getenv("BRAVE_SEARCH_API_KEY"):
1252
+ console.print(f" [dim]{'brave_key':<24s}[/dim][green]已配置[/green]")
1253
+ else:
1254
+ console.print(f" [dim]{'brave_key':<24s}[/dim][dim]未设置 — /config set brave_key=BSAAxxx[/dim]")
1255
+ # Security check: warn if providers.json has plaintext api_key
1256
+ _pf = Path(snap["providers_file"])
1257
+ if _pf.exists():
1258
+ try:
1259
+ _pd = _j.loads(_pf.read_text())
1260
+ _has_plain = any(
1261
+ v.get("api_key") for v in _pd.values()
1262
+ if isinstance(v, dict) and v.get("api_key")
1263
+ and not str(v["api_key"]).startswith("${")
1264
+ )
1265
+ if _has_plain:
1266
+ console.print()
1267
+ console.print(
1268
+ " [yellow]⚠ ~/.arthera/providers.json 含明文 API Key[/yellow]\n"
1269
+ " [dim] 建议迁移到环境变量: export OPENAI_API_KEY=sk-...[/dim]\n"
1270
+ " [dim] 然后删除 providers.json 中的 api_key 字段[/dim]"
1271
+ )
1272
+ except Exception:
1273
+ pass
1274
+ console.print()
1275
+ else:
1276
+ for key in ("api_url", "ollama_url", "model", "thinking_mode",
1277
+ "command_policy", "permission_mode", "network_enabled",
1278
+ "write_policy", "lsp_autocheck", "input_style", "input_theme",
1279
+ "response_footer", "auto_compact_context",
1280
+ "auto_compact_threshold"):
1281
+ print(f" {key}: {cfg.get(key, '-')}")
1282
+ elif len(parts) == 2 and parts[0] == "set":
1283
+ # Parse key=value
1284
+ kv = parts[1].split("=", 1)
1285
+ if len(kv) == 2:
1286
+ key, val = kv[0].strip(), kv[1].strip()
1287
+ # Validate known config keys
1288
+ if key == "command_policy":
1289
+ if val not in {"safe", "balanced", "full"}:
1290
+ msg = "command_policy must be one of: safe | balanced | full"
1291
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1292
+ return
1293
+ elif key == "permission_mode":
1294
+ if val not in {"read-only", "workspace-write", "full-access"}:
1295
+ msg = "permission_mode must be one of: read-only | workspace-write | full-access"
1296
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1297
+ return
1298
+ elif key in {"network_enabled", "data_sharing", "feedback_upload"}:
1299
+ if val.lower() in {"true", "1", "yes", "on"}:
1300
+ val = True
1301
+ elif val.lower() in {"false", "0", "no", "off"}:
1302
+ val = False
1303
+ else:
1304
+ msg = f"{key} must be: true | false"
1305
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1306
+ return
1307
+ elif key == "thinking_mode":
1308
+ if val not in {"auto", "instant", "thinking"}:
1309
+ msg = "thinking_mode must be one of: auto | instant | thinking"
1310
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1311
+ return
1312
+ elif key == "model":
1313
+ resolved = MODEL_ALIASES.get(val) or (val if val in MODELS else None)
1314
+ if not resolved:
1315
+ valid = ", ".join(sorted(MODEL_ALIASES.keys()))
1316
+ msg = f"Unknown model '{val}'. Valid: {valid}"
1317
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1318
+ return
1319
+ val = MODELS[resolved]["id"]
1320
+ elif key == "auto_save_sessions":
1321
+ if val.lower() in {"true", "1", "yes", "on"}:
1322
+ val = True
1323
+ elif val.lower() in {"false", "0", "no", "off"}:
1324
+ val = False
1325
+ else:
1326
+ msg = "auto_save_sessions must be: true | false"
1327
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1328
+ return
1329
+ elif key == "auto_compact_context":
1330
+ if val.lower() in {"true", "1", "yes", "on"}:
1331
+ val = True
1332
+ elif val.lower() in {"false", "0", "no", "off"}:
1333
+ val = False
1334
+ else:
1335
+ msg = "auto_compact_context must be: true | false"
1336
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1337
+ return
1338
+ elif key == "lsp_autocheck":
1339
+ if val.lower() in {"true", "1", "yes", "on"}:
1340
+ val = True
1341
+ elif val.lower() in {"false", "0", "no", "off"}:
1342
+ val = False
1343
+ else:
1344
+ msg = "lsp_autocheck must be: true | false"
1345
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1346
+ return
1347
+ elif key == "auto_compact_threshold":
1348
+ try:
1349
+ val = float(val)
1350
+ except Exception:
1351
+ msg = "auto_compact_threshold must be a number between 0.50 and 0.95"
1352
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1353
+ return
1354
+ if not 0.50 <= val <= 0.95:
1355
+ msg = "auto_compact_threshold must be between 0.50 and 0.95"
1356
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1357
+ return
1358
+ elif key == "write_policy":
1359
+ if val not in {"desktop_only", "confirm_outside", "always_confirm"}:
1360
+ msg = "write_policy must be: desktop_only | confirm_outside | always_confirm"
1361
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1362
+ return
1363
+ elif key == "local_mode":
1364
+ if val.lower() in {"true", "1", "yes", "on"}:
1365
+ val = True
1366
+ elif val.lower() in {"false", "0", "no", "off"}:
1367
+ val = False
1368
+ else:
1369
+ msg = "local_mode must be: true | false"
1370
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1371
+ return
1372
+ elif key == "banner":
1373
+ if val not in {"full", "compact", "off"}:
1374
+ msg = "banner must be: full | compact | off"
1375
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1376
+ return
1377
+ elif key == "input_style":
1378
+ if val not in {"panel", "box", "plain"}:
1379
+ msg = "input_style must be: panel | box | plain"
1380
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1381
+ return
1382
+ elif key == "input_theme":
1383
+ if val not in {"auto", "dark", "light"}:
1384
+ msg = "input_theme must be: auto | dark | light"
1385
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1386
+ return
1387
+ elif key == "response_footer":
1388
+ if val not in {"compact", "full", "off"}:
1389
+ msg = "response_footer must be: compact | full | off"
1390
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1391
+ return
1392
+ elif key == "ui_lang":
1393
+ if val not in {"zh", "en", "ja", "ko", "auto"}:
1394
+ msg = "ui_lang must be: zh | en | auto (auto = detect from OS locale)"
1395
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1396
+ return
1397
+ if val == "auto":
1398
+ try:
1399
+ from apps.cli.i18n import detect_system_lang as _dsl
1400
+ val = _dsl()
1401
+ except Exception:
1402
+ val = "en"
1403
+ msg = f"✓ UI 语言已设为 {val} (重启生效)" if val == "zh" else f"✓ UI language set to {val} (takes effect on restart)"
1404
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1405
+ self.terminal.config[key] = val
1406
+ save_config(self.terminal.config)
1407
+ return
1408
+ elif key == "notify_webhook":
1409
+ # /config set notify_webhook=https://qyapi.weixin.qq.com/...
1410
+ # 写入 ~/.arthera/config.json(notification_tools 直接读取)
1411
+ try:
1412
+ _ncfg_path = Path.home() / ".arthera" / "config.json"
1413
+ _ncfg = json.loads(_ncfg_path.read_text()) if _ncfg_path.exists() else {}
1414
+ _ncfg["notify_webhook"] = val
1415
+ _ncfg_path.write_text(json.dumps(_ncfg, indent=2, ensure_ascii=False))
1416
+ except Exception as _e:
1417
+ logger.debug("notify_webhook save failed: %s", _e)
1418
+ msg = f"✓ 通知 Webhook 已设为 {val[:60]}…" if len(val) > 60 else f"✓ 通知 Webhook 已设为 {val}"
1419
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1420
+ return
1421
+ elif key == "brave_key":
1422
+ # /config set brave_key=BSAAxxx → 写入 ~/.aria/.env
1423
+ _env_path = Path.home() / ".aria" / ".env"
1424
+ _env_path.parent.mkdir(parents=True, exist_ok=True)
1425
+ existing = _env_path.read_text() if _env_path.exists() else ""
1426
+ import re as _re_cfg
1427
+ if "BRAVE_SEARCH_API_KEY" in existing:
1428
+ existing = _re_cfg.sub(r"BRAVE_SEARCH_API_KEY=.*", f"BRAVE_SEARCH_API_KEY={val}", existing)
1429
+ else:
1430
+ existing = existing.rstrip("\n") + f"\nBRAVE_SEARCH_API_KEY={val}\n"
1431
+ _env_path.write_text(existing)
1432
+ _env_path.chmod(0o600)
1433
+ import os as _os_cfg
1434
+ _os_cfg.environ["BRAVE_SEARCH_API_KEY"] = val
1435
+ msg = "✓ Brave Search API key 已保存到~/.aria/.env (生效于当前会话)"
1436
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1437
+ return
1438
+ elif key == "custom_endpoint":
1439
+ # /config set custom_endpoint=http://my-litellm:4000/v1
1440
+ # Automatically sets local_provider=custom
1441
+ self.terminal.config["local_provider"] = "custom"
1442
+ self.terminal.config["custom_endpoint"] = val
1443
+ _sync_write_policy(self.terminal.config)
1444
+ save_config(self.terminal.config)
1445
+ msg = f"✓ 自定义 endpoint 设为 {val} (local_provider=custom)"
1446
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1447
+ return
1448
+ elif key == "custom_model":
1449
+ # /config set custom_model=gpt-4o
1450
+ self.terminal.config["custom_model"] = val
1451
+ if self.terminal.config.get("local_provider") == "custom":
1452
+ self.terminal.config["model"] = val
1453
+ _sync_write_policy(self.terminal.config)
1454
+ save_config(self.terminal.config)
1455
+ console.print(f" [dim]custom_model[/dim] = {val}" if HAS_RICH else f" custom_model = {val}")
1456
+ return
1457
+ self.terminal.config[key] = val
1458
+ _sync_write_policy(self.terminal.config)
1459
+ save_config(self.terminal.config)
1460
+ console.print(f" [dim]{key}[/dim] = {val}" if HAS_RICH else f" {key} = {val}")
1461
+ else:
1462
+ console.print("[dim]Usage: /config set key=value[/dim]" if HAS_RICH
1463
+ else "Usage: /config set key=value")
1464
+ elif parts[0] == "reload":
1465
+ fresh = load_config()
1466
+ self.terminal.config.update(fresh)
1467
+ msg = f"Config reloaded from {config_snapshot()['config_file']}"
1468
+ console.print(f"[dim]{msg}[/dim]" if HAS_RICH else msg)
1469
+
1470
+ elif parts[0] == "allow":
1471
+ # /config allow <tool_name> — permanently auto-approve this tool
1472
+ if len(parts) < 2:
1473
+ msg = "Usage: /config allow <tool_name> (e.g. /config allow read_file)"
1474
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
1475
+ return
1476
+ tool = parts[1].strip()
1477
+ try:
1478
+ from runtime.tool_policy import add_to_policy
1479
+ add_to_policy(tool, "allow")
1480
+ msg = f"✓ 工具 '{tool}' 加入永久白名单(始终自动批准,无需确认)"
1481
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1482
+ except Exception as _e:
1483
+ console.print(f"[red]Error: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
1484
+
1485
+ elif parts[0] == "deny":
1486
+ # /config deny <tool_name> — permanently block this tool
1487
+ if len(parts) < 2:
1488
+ msg = "Usage: /config deny <tool_name> (e.g. /config deny run_command)"
1489
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
1490
+ return
1491
+ tool = parts[1].strip()
1492
+ try:
1493
+ from runtime.tool_policy import add_to_policy
1494
+ add_to_policy(tool, "deny")
1495
+ msg = f"✓ 工具 '{tool}' 加入黑名单(始终拒绝,不会执行)"
1496
+ console.print(f"[red]{msg}[/red]") if HAS_RICH else print(msg)
1497
+ except Exception as _e:
1498
+ console.print(f"[red]Error: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
1499
+
1500
+ elif parts[0] == "ask":
1501
+ # /config ask <tool_name> — always prompt before this tool
1502
+ if len(parts) < 2:
1503
+ msg = "Usage: /config ask <tool_name> (e.g. /config ask write_file)"
1504
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
1505
+ return
1506
+ tool = parts[1].strip()
1507
+ try:
1508
+ from runtime.tool_policy import add_to_policy
1509
+ add_to_policy(tool, "ask")
1510
+ msg = f"✓ 工具 '{tool}' 设为始终询问(每次执行前都弹出确认)"
1511
+ console.print(f"[yellow]{msg}[/yellow]") if HAS_RICH else print(msg)
1512
+ except Exception as _e:
1513
+ console.print(f"[red]Error: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
1514
+
1515
+ elif parts[0] == "policy":
1516
+ # /config policy — show all policy settings
1517
+ # /config policy reset — reset to defaults
1518
+ sub = parts[1].lower() if len(parts) > 1 else "show"
1519
+ try:
1520
+ from runtime.tool_policy import load_tool_policy, save_tool_policy, remove_from_policy
1521
+ if sub == "reset":
1522
+ save_tool_policy({"allowed": [], "denied": [], "ask_always": []})
1523
+ msg = "✓ 工具权限策略已重置为默认值"
1524
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1525
+ elif sub in ("remove", "rm") and len(parts) > 2:
1526
+ tool = parts[2].strip()
1527
+ removed = remove_from_policy(tool)
1528
+ if removed:
1529
+ msg = f"✓ '{tool}' 已从策略中移除"
1530
+ console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
1531
+ else:
1532
+ msg = f"'{tool}' 不在任何策略列表中"
1533
+ console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
1534
+ else:
1535
+ policy = load_tool_policy()
1536
+ if HAS_RICH:
1537
+ console.print()
1538
+ console.print(" [bold]工具权限策略[/bold] [dim]~/.arthera/tool_policy.json[/dim]")
1539
+ console.print()
1540
+ allowed = policy.get("allowed", [])
1541
+ denied = policy.get("denied", [])
1542
+ asked = policy.get("ask_always", [])
1543
+ if allowed:
1544
+ console.print(f" [green]✓ 白名单(自动允许):[/green] {', '.join(allowed)}")
1545
+ else:
1546
+ console.print(" [dim]✓ 白名单: 空[/dim]")
1547
+ if denied:
1548
+ console.print(f" [red]✗ 黑名单(始终拒绝):[/red] {', '.join(denied)}")
1549
+ else:
1550
+ console.print(" [dim]✗ 黑名单: 空[/dim]")
1551
+ if asked:
1552
+ console.print(f" [yellow]? 始终询问:[/yellow] {', '.join(asked)}")
1553
+ else:
1554
+ console.print(" [dim]? 始终询问: 空[/dim]")
1555
+ console.print()
1556
+ console.print(" [dim]/config allow <tool> — 加入白名单[/dim]")
1557
+ console.print(" [dim]/config deny <tool> — 加入黑名单[/dim]")
1558
+ console.print(" [dim]/config ask <tool> — 设为始终询问[/dim]")
1559
+ console.print(" [dim]/config policy remove <tool> — 移除策略[/dim]")
1560
+ console.print(" [dim]/config policy reset — 清空所有策略[/dim]")
1561
+ console.print()
1562
+ else:
1563
+ print("\n Tool Policy:")
1564
+ print(f" Allowed: {', '.join(policy.get('allowed', [])) or 'none'}")
1565
+ print(f" Denied: {', '.join(policy.get('denied', [])) or 'none'}")
1566
+ print(f" Ask-always: {', '.join(policy.get('ask_always', [])) or 'none'}")
1567
+ except Exception as _e:
1568
+ console.print(f"[red]Error: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
1569
+
1570
+ else:
1571
+ console.print(
1572
+ "[dim]Usage: /config [show] | /config set key=value | /config reload\n"
1573
+ " /config allow <tool> | /config deny <tool> | /config ask <tool>\n"
1574
+ " /config policy [reset|remove <tool>][/dim]"
1575
+ if HAS_RICH else
1576
+ "Usage: /config [show] | /config set key=value | /config reload\n"
1577
+ " /config allow <tool> | /config deny <tool> | /config ask <tool>\n"
1578
+ " /config policy [reset|remove <tool>]"
1579
+ )