qi-coding-agent 0.1.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 (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/cli.py ADDED
@@ -0,0 +1,2178 @@
1
+ """CLI 入口(对齐 docs/cli.md;P1-P6)。
2
+
3
+ 顶层:`qi [options] [--] [消息...]` —— 不带 `-p` 且 `--mode text` 时进 TUI(裸 `qi`
4
+ 或 `qi "问题"`,后者把消息作为首条提交);否则无头执行后退出(`-p` / `--mode json`)。
5
+ 子命令:doctor / models list / auth / init / agents / sessions / config / web / version
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import re
14
+ import shutil
15
+ import signal
16
+ import sys
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import typer
22
+ from rich.console import Console
23
+ from rich.markup import escape
24
+ from rich.table import Table
25
+ from typer.core import TyperGroup
26
+
27
+ from . import __version__, paths, prompt
28
+ from .auth import AuthStore, DEFAULT_API_KEY_ENV, resolve_key
29
+ from .config import (
30
+ DEFAULT_API,
31
+ models_file_for_provider,
32
+ DEFAULT_CONTEXT_WINDOW,
33
+ DEFAULT_MAX_TOKENS,
34
+ SUPPORTED_APIS,
35
+ ConfigError,
36
+ default_model_spec,
37
+ legacy_default_keys,
38
+ load_config,
39
+ load_models_file,
40
+ resolve_default_model,
41
+ resolve_model,
42
+ resolve_router_model,
43
+ save_models_file,
44
+ )
45
+ from .llm import THINKING_LEVELS, ThinkingLLMClient
46
+ from .extensions import ExtensionBus, CliCommandRegistry
47
+ from .loader import (
48
+ LoadError,
49
+ load_top_level_skills,
50
+ )
51
+ from .paths import MODELS_FILE_NAME, SETTINGS_FILE_NAME, global_home, project_home
52
+ from .registry import (
53
+ HOST_DISTRIBUTION,
54
+ CapabilityRegistry,
55
+ ToolCatalog,
56
+ discover_extensions,
57
+ )
58
+ from .packages import install_hints, package_report
59
+ from .session import SessionStore
60
+ from .settings import (
61
+ SCOPES,
62
+ TUI_MODES,
63
+ QiSettings,
64
+ SettingsError,
65
+ extension_dirs,
66
+ load_settings,
67
+ load_settings_by_scope,
68
+ load_settings_raw,
69
+ parse_value,
70
+ resolve_project_trust,
71
+ save_settings,
72
+ set_value,
73
+ settings_scope_path,
74
+ unset_value,
75
+ )
76
+ from .tools import register_builtin_tools
77
+
78
+ console = Console(highlight=False)
79
+ # 诊断/进度(分派、工具调用)走 stderr:`qi -p "..." > out.txt` 只会得到答案本身,
80
+ # 脚本不必过滤装饰行;终端里仍看得到。答案正文依旧走 stdout。
81
+ err_console = Console(stderr=True, highlight=False)
82
+
83
+
84
+ class QiGroup(TyperGroup):
85
+ """顶层群组:位置参数何时算“消息”、何时算“子命令”。
86
+
87
+ Click 的 Group 会把第一个位置参数无条件当子命令名解析,于是:
88
+ - `qi -p "你好"` → No such command '你好'(docs/cli.md 的「调用与输出」)
89
+ - `qi -p "version"` → 消息恰好同名子命令时,跑去了 `qi version`
90
+ 规则:`-p/--print` 已给出 —— 位置参数全是消息;否则首个位置参数不是已注册
91
+ 子命令时,也当消息(交给顶层 callback 拼成 prompt)。
92
+ """
93
+
94
+ def parse_args(self, ctx, args: list[str]) -> list[str]:
95
+ # `--` 之后一律当字面消息。但 click 会在解析时把那段内容当普通位置参数混进
96
+ # `ctx.args`,而且**把 `--` 标记本身丢掉** —— 那样就再也区分不出“转义后的
97
+ # `--plan`”与“扩展旗标 `--plan`”。所以在这里(唯一还看得到标记的地方)先记下分割点。
98
+ ctx.meta["qi_after_double_dash"] = (
99
+ list(args[args.index("--") + 1:]) if "--" in args else [])
100
+ super().parse_args(ctx, args)
101
+ # TyperGroup.parse_args 已把首个位置参数挪进 _protected_args(当子命令)。
102
+ if not ctx._protected_args:
103
+ return ctx.args
104
+ if ctx.params.get("print_mode") or self.get_command(ctx, ctx._protected_args[0]) is None:
105
+ ctx.args = [*ctx._protected_args, *ctx.args]
106
+ ctx._protected_args = []
107
+ return ctx.args
108
+
109
+
110
+ def _core_option_names(ctx: typer.Context) -> set[str]:
111
+ """顶层自己的长选项名(`--no-session` → `no-session`)。
112
+
113
+ 用处:识别"把 qi 自己的选项写到了消息后面"—— click 的 Group 遇到位置参数后就
114
+ 不再解析选项,那些 token 会落到 `ctx.args`,再被当成**扩展旗标**。报"没有对应的
115
+ 扩展旗标"是在指错方向,用户写的明明是 core 的旗标。
116
+ """
117
+ names: set[str] = set()
118
+ for param in getattr(ctx.command, "params", []) or []:
119
+ for opt in (*getattr(param, "opts", ()), *getattr(param, "secondary_opts", ())):
120
+ if str(opt).startswith("--"):
121
+ names.add(str(opt)[2:])
122
+ return names
123
+
124
+
125
+ def _classify_cli_args(tokens: list[str]) -> tuple[list[str], list[str], list[str]]:
126
+ """把 click 留下的 token 分成「消息 / 扩展旗标 / 错误」(照抄 pi 的三条规则)。
127
+
128
+ * `--name=value` / `--name` → 扩展旗标候选(装完再对账,**未注册的报错**)
129
+ * `-x`(单横线)→ **直接报错**:短旗标不允许扩展占用(pi 同款:`-z` → Unknown option)
130
+ * 其余 → 消息
131
+
132
+ **一处刻意与 pi 不同**:pi 会把 `--flag` 后面的 token 当成它的值**吃掉**(布尔旗标
133
+ 也一样 —— 于是 `pi --plan "帮我看这个"` 里的那句 prompt 就没了)。qi **不消费后面的
134
+ token**:字符串旗标写 `--name=value`(与 `--ext` 形式一致),`--name` 单独出现就按声明
135
+ 类型解释。代价是不支持 `--agent reviewer` 这种空格写法;收益是**永远不会吃掉用户的一句话**。
136
+ """
137
+ messages: list[str] = []
138
+ flags: list[str] = []
139
+ errors: list[str] = []
140
+ for index, token in enumerate(tokens):
141
+ if token == "--":
142
+ # `--` 之后一律当字面消息(pi 同款)。真实路径里 click 已经把它剥掉了
143
+ # (见 `QiGroup.parse_args` 存的 `qi_after_double_dash`),但这里也要处理:
144
+ # 否则它会被当成“名字为空的旗标”混进 flags。
145
+ messages.extend(tokens[index + 1:])
146
+ break
147
+ if token.startswith("--"):
148
+ flags.append(token[2:])
149
+ elif token.startswith("-") and token != "-":
150
+ errors.append(f"未知选项: {token}")
151
+ else:
152
+ messages.append(token)
153
+ return messages, flags, errors
154
+
155
+
156
+ app = typer.Typer(
157
+ name="qi",
158
+ cls=QiGroup,
159
+ help="编码 agent 框架:单 agent core;MCP / 多 agent / web 走扩展(参数尽量对齐 pi)",
160
+ no_args_is_help=False, # 无参 → 进 TUI(见 callback);帮助用 qi -h
161
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True,
162
+ "help_option_names": ["-h", "--help"]},
163
+ )
164
+
165
+
166
+ def _tool_snippet(text: str, limit: int = 400) -> str:
167
+ """工具结果摘要:保留换行结构(便于看 ls/grep 这类多行输出),超长时附提示。
168
+
169
+ 不把 `\n` 压成空格 —— 那样 ls 的“一行一条目”会糊成不可读的长串。
170
+ """
171
+ text = text.rstrip()
172
+ if len(text) <= limit:
173
+ return text
174
+ lines = text.splitlines()
175
+ head = text[:limit].rstrip()
176
+ return f"{head}\n …(已截断,共 {len(lines)} 行 / {len(text)} 字)"
177
+
178
+
179
+ def _short_cwd(cwd: str | None, limit: int = 48) -> str:
180
+ """会话 cwd 的终端友好形式。
181
+
182
+ 家目录缩为 `~`;仍过长则只保留末两段(`…/parent/name`)——否则长路径会把
183
+ `sessions list` 的一行挤成三行。缺失时为“(未知)”(v0.1 旧会话)。
184
+ """
185
+ if not cwd:
186
+ return "(未知)"
187
+ home = str(Path.home())
188
+ text = "~" + cwd[len(home):] if cwd.startswith(home) else cwd
189
+ if len(text) <= limit:
190
+ return text
191
+ parts = Path(cwd).parts
192
+ return "…/" + "/".join(parts[-2:]) if len(parts) >= 2 else text
193
+
194
+
195
+ # ── 顶层 callback:headless 运行 ─────────────────────────
196
+
197
+ @app.callback(invoke_without_command=True)
198
+ def root_callback(
199
+ ctx: typer.Context,
200
+ print_mode: bool = typer.Option(False, "--print", "-p", help="无头一次执行(单 agent)"),
201
+ cont: bool = typer.Option(False, "--continue", "-c", help="续上次会话"),
202
+ session_id: str | None = typer.Option(None, "--session", help="指定会话 id"),
203
+ fork_id: str | None = typer.Option(None, "--fork", help="从已有会话分叉出新会话(path|id 前缀)"),
204
+ name: str | None = typer.Option(None, "--name", "-n", help="会话显示名"),
205
+ no_session: bool = typer.Option(False, "--no-session", help="不落盘(临时)"),
206
+ export_file: str | None = typer.Option(None, "--export", help="导出会话 JSONL 到文件后退出"),
207
+ mode: str = typer.Option("text", "--mode", help="输出: text|json(json 隐含无头,不进 TUI)"),
208
+ verbose: bool = typer.Option(False, "--verbose", help="显示分派与工具调用进度(默认只输出答案,对齐 pi)"),
209
+ skill: list[str] = typer.Option(None, "--skill", help="额外技能文件/目录(可重复;叠加)"),
210
+ no_skills: bool = typer.Option(False, "--no-skills", "-ns", help="关闭技能自动发现(--skill 仍生效)"),
211
+ extension: list[str] = typer.Option(None, "-e", "--extension",
212
+ help="一次性试用一个扩展目录(可重复;仅本进程,scope=temporary)。"
213
+ "与 `--ext` 不同:`--ext` 是给扩展声明过的**旗标**传值(name=value)"),
214
+ ext: list[str] = typer.Option(None, "--ext",
215
+ help="扩展旗标:name=value(可重复;由扩展 registerFlag 声明。"
216
+ "已声明的旗标也可直接写 --name 或 --name=value)"), approve: bool = typer.Option(False, "--approve", "-a", help="信任项目 .qi(加载项目级扩展)"),
217
+ no_approve: bool = typer.Option(False, "--no-approve", "-na", help="不信任项目 .qi(显式拒绝)"),
218
+ thinking: str | None = typer.Option(None, "--thinking", help="思考级别: " + "/".join(THINKING_LEVELS)),
219
+ tools: str | None = typer.Option(None, "--tools",
220
+ help="工具白名单(严格;逗号/空格分隔。对齐 pi 的 --tools,可写 -t)"),
221
+ exclude_tools: str | None = typer.Option(None, "--exclude-tools",
222
+ help="从最终工具集里排除这些工具(对齐 pi 的 -xt)"),
223
+ no_tools: bool = typer.Option(False, "--no-tools", help="禁用全部工具(对齐 pi 的 -nt)"),
224
+ no_builtin_tools: bool = typer.Option(False, "--no-builtin-tools",
225
+ help="禁用内置工具、保留扩展工具(对齐 pi 的 -nbt)"),
226
+ offline: bool = typer.Option(False, "--offline",
227
+ help="对齐 pi 的旗标;qi 启动期没有任何网络操作,所以它无实际作用"),
228
+ version: bool = typer.Option(False, "--version", "-v", is_eager=True, help="显示版本并退出"),
229
+ # ── 模型(pi 同名)──
230
+ provider: str | None = typer.Option(None, "--provider", help="provider 名(本次运行覆盖 models.json 的默认)"),
231
+ model: str | None = typer.Option(None, "--model",
232
+ help='模型:"provider/模型",可带 ":<思考级别>" 后缀'),
233
+ api_key: str | None = typer.Option(None, "--api-key",
234
+ help="本次运行的密钥(优先于 auth store / env / models.json;**不落盘**)"),
235
+ models_cycle: str | None = typer.Option(None, "--models",
236
+ help="Ctrl+P 轮换的模型清单(逗号分隔;仅本次运行,不回写 settings)"),
237
+ list_models: bool = typer.Option(False, "--list-models",
238
+ help="列出可用模型后退出(搜索词写成位置参数:`qi --list-models sonnet`)"),
239
+ # ── 会话(pi 同名)──
240
+ resume: bool = typer.Option(False, "--resume", "-r", help="浏览并选择一个历史会话(需要 TTY)"),
241
+ exact_session_id: str | None = typer.Option(None, "--session-id",
242
+ help="用精确的项目会话 id(不存在则创建)"),
243
+ session_dir: str | None = typer.Option(None, "--session-dir",
244
+ help="会话存储目录(覆盖 settings.sessionDir)"),
245
+ # ── 系统提示词与资源开关(pi 同名)──
246
+ system_prompt: str | None = typer.Option(None, "--system-prompt",
247
+ help="整体替换基座(与 `.qi/SYSTEM.md` 同一语义)"),
248
+ no_extensions: bool = typer.Option(False, "--no-extensions", "-ne",
249
+ help="关掉扩展发现(`-e` 显式给的仍然生效)"),
250
+ no_context_files: bool = typer.Option(False, "--no-context-files", "-nc",
251
+ help="不注入 AGENTS.md / CLAUDE.md"),
252
+ # ── 对齐 pi 但**功能未实现**的旗标:接受,然后明确报出来(不假装,也不掉进扩展旗标的报错)──
253
+ prompt_template: list[str] = typer.Option(None, "--prompt-template", help="[未实现] prompt 模板"),
254
+ no_prompt_templates: bool = typer.Option(False, "--no-prompt-templates", "-np",
255
+ help="[未实现] 关掉 prompt 模板发现"),
256
+ theme_file: list[str] = typer.Option(None, "--theme", help="[未实现] 自定义主题文件"),
257
+ use_theme: str | None = typer.Option(None, "--use-theme", help="[未实现] 指定初始主题"),
258
+ no_themes: bool = typer.Option(False, "--no-themes", help="[未实现] 关掉主题发现"),
259
+ tui_mode: str | None = typer.Option(None, "--tui-mode",
260
+ help="TUI 模式(regular|fullscreen;默认 fullscreen)"),
261
+ append_prompt: list[str] = typer.Option(None, "--append-system-prompt",
262
+ help="追加到 system prompt 末尾(可重复;值是文件路径时读文件内容)"),
263
+ ) -> None:
264
+ if version:
265
+ console.print(f"qi {__version__}")
266
+ raise typer.Exit()
267
+ _reject_unimplemented_flags(
268
+ prompt_template=prompt_template, no_prompt_templates=no_prompt_templates,
269
+ theme_file=theme_file, use_theme=use_theme, no_themes=no_themes, mode=mode)
270
+ # `--tui-mode` 的值域很窄:打错就当场报(否则会静默回落默认,用户以为切过去了)
271
+ if tui_mode is not None and tui_mode.strip().lower() not in TUI_MODES:
272
+ err_console.print(f"[red]未知 TUI 模式: {escape(tui_mode)}"
273
+ f"(可选 {'/'.join(TUI_MODES)})[/red]")
274
+ raise typer.Exit(code=2)
275
+ tui_mode = tui_mode.strip().lower() if tui_mode else None
276
+ # `--tools`(严格白名单)与另外两个整集选择互相矛盾 —— 报出来,不替用户选一个赢。
277
+ if tools and (no_tools or no_builtin_tools):
278
+ err_console.print("[red]--tools 与 --no-tools/--no-builtin-tools 冲突:"
279
+ "前者是严格白名单,后两者是整集选择[/red]")
280
+ raise typer.Exit(code=2)
281
+ if ctx.invoked_subcommand is not None:
282
+ # 开了 `ignore_unknown_options` 后,子命令自己仍然严格解析,但**顶层**的未知选项
283
+ # 会落到这里(实测:`qi --zzz agents list` 会让子命令不再被分派)。不能静默丢。
284
+ _leftover_messages, _leftover_flags, leftover_errors = _classify_cli_args(
285
+ list(ctx.args))
286
+ if leftover_errors:
287
+ for problem in leftover_errors:
288
+ err_console.print(f"[red]{escape(problem)}[/red]")
289
+ raise typer.Exit(code=2)
290
+ return
291
+ if export_file:
292
+ _cmd_export(session_id or "", Path(export_file))
293
+ raise typer.Exit()
294
+ # `--` 之后一律当字面消息(click 已把标记吃掉,所以从 QiGroup 记下的分割点取回)
295
+ after_dashdash = list(ctx.meta.get("qi_after_double_dash") or [])
296
+ tokens = list(ctx.args)
297
+ if after_dashdash and tokens[-len(after_dashdash):] == after_dashdash:
298
+ tokens = tokens[:-len(after_dashdash)]
299
+ messages, cli_flags, cli_errors = _classify_cli_args(tokens)
300
+ # `--list-models [搜索词]`:pi 的可选值 click 表达不了(实测 `--list-models` 单独出现会报
301
+ # "requires an argument"),所以搜索词走**位置参数** —— `qi --list-models sonnet` 与 pi 同形。
302
+ if list_models:
303
+ _cmd_list_models(" ".join(messages).strip() or None)
304
+ raise typer.Exit()
305
+ # 把 core 自己的选项写到消息后面 = 解析不到:报得具体一点(否则用户会去查那个
306
+ # 根本没写的 `--ext`)。`--tools=read` 这种带值的也按名字判。
307
+ core_options = _core_option_names(ctx)
308
+ misplaced = [name for name in cli_flags
309
+ if name.split("=", 1)[0].replace("_", "-") in core_options]
310
+ if misplaced:
311
+ for name in misplaced:
312
+ err_console.print(
313
+ f"[red]选项 `--{name.split('=', 1)[0]}` 要写在**消息之前**:"
314
+ f"qi 的顶层选项在遇到消息后不再解析(写成 `qi --{name.split('=', 1)[0]} … \"消息\"`)[/red]")
315
+ raise typer.Exit(code=2)
316
+ messages += after_dashdash
317
+ if cli_errors:
318
+ for problem in cli_errors:
319
+ err_console.print(f"[red]{escape(problem)}[/red]")
320
+ raise typer.Exit(code=2)
321
+ prompt = " ".join(messages).strip()
322
+ # 扩展旗标两个通道都接住:`--ext name=value` 与直接写的 `--name[=value]`。
323
+ # 直接写的放后面 → 同名声时它胜(更具体的写法优先)。
324
+ extension_flags = list(ext or []) + cli_flags
325
+ # `--provider` / `--model` 合成一个 `provider/模型`;拼不出来就在**这里**报,
326
+ # 不退化成"默默用默认模型"(那样用户以为旗标生效了)
327
+ try:
328
+ model_spec = _model_spec(provider, model)
329
+ except ValueError as exc:
330
+ console.print(f"[red]{escape(str(exc))}[/red]")
331
+ raise typer.Exit(code=2) from exc
332
+ # `--models`:本次运行的 Ctrl+P 轮换清单(**不回写 settings**)
333
+ from .runtime import split_tool_list
334
+
335
+ scoped = split_tool_list(models_cycle) if models_cycle else None
336
+ # `--system-prompt` / `--append-system-prompt`:与 SYSTEM.md 同义(替换 / 追加)。
337
+ # 值是**可读文件**时读文件内容 —— pi 的 `--append-system-prompt` 就是这个口径
338
+ # (help 里写的是 "Append text or file contents")。
339
+ append_prompt = [_text_or_file(item) for item in (append_prompt or [])]
340
+ system_prompt = _text_or_file(system_prompt) if system_prompt else None
341
+ # docs/cli.md 的「调用与输出」:不带 -p 恒为交互(`-p` 才是无头),也不再需要 `qi tui`;
342
+ # 给了消息就进 TUI 并把它作为首条消息发出(pi 的 `pi "问题"` 同款)。
343
+ # `--mode json` 是脚本路径(输出事件流,不是 TUI),仍走无头。
344
+ interactive = not print_mode and mode == "text"
345
+ if resume and not interactive:
346
+ err_console.print("[red]`-r/--resume` 要选会话,只能在 TUI 里用"
347
+ "(无头下请用 `--session <id>`)[/red]")
348
+ raise typer.Exit(code=2)
349
+ if interactive:
350
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
351
+ # 非 TTY(管道/CI):退化为提示,而不是抛 traceback / 卡住。
352
+ console.print("交互界面需要 TTY;无头用法: qi -p \"问题\" [--agent name]")
353
+ raise typer.Exit(code=0)
354
+ _launch_tui(prompt or None, session_id=session_id, cont=cont, fork_id=fork_id,
355
+ no_session=no_session, name=name,
356
+ approve_project=_trust_flag(approve, no_approve),
357
+ extension_flags=extension_flags,
358
+ extra_extension_paths=[Path(p) for p in (extension or [])],
359
+ tools=tools, exclude_tools=exclude_tools,
360
+ no_tools=no_tools, no_builtin_tools=no_builtin_tools,
361
+ append_system_prompt=append_prompt,
362
+ resume=resume, exact_session_id=exact_session_id,
363
+ base_prompt_override=system_prompt,
364
+ no_extensions=no_extensions, no_context_files=no_context_files,
365
+ session_dir_path=session_dir, model_override=model_spec,
366
+ api_key=api_key, scoped_models=scoped, tui_mode=tui_mode)
367
+ return
368
+ if not prompt:
369
+ usage = (
370
+ "qi -p \"问题\"" if print_mode
371
+ else "qi --mode json \"问题\"(--mode json 为无头输出)"
372
+ )
373
+ console.print(f"[red]需要消息内容: {usage}[/red]")
374
+ raise typer.Exit(code=2)
375
+ if thinking is not None and thinking.strip().lower() not in THINKING_LEVELS:
376
+ console.print(f"[red]未知思考级别: {thinking}(可选 {"/".join(THINKING_LEVELS)})[/red]")
377
+ raise typer.Exit(code=2)
378
+ from .runtime import QiRuntime
379
+ from .config import ConfigError as _CfgErr
380
+ from .loader import LoadError as _LoadErr
381
+ try:
382
+ # 轮次:qi 自己**不设上限**(对齐 pi —— 核心循环与 print 模式都没有 maxTurns)。
383
+ # `RunnerSettings.stop_after` 是留给嵌入方的钩子(qi 生产代码不用它,
384
+ # 与 pi 定义了却从不实现 `shouldStopAfterTurn` 同形)。
385
+ runtime = QiRuntime(skills_enabled=not no_skills,
386
+ thinking_level=thinking,
387
+ approve_project=_trust_flag(approve, no_approve),
388
+ extension_flags=extension_flags,
389
+ extra_skill_paths=[Path(p) for p in (skill or [])],
390
+ extra_extension_paths=[Path(p) for p in (extension or [])],
391
+ tools=tools, exclude_tools=exclude_tools,
392
+ no_tools=no_tools, no_builtin_tools=no_builtin_tools,
393
+ append_system_prompt=append_prompt,
394
+ base_prompt_override=system_prompt,
395
+ no_extensions=no_extensions,
396
+ no_context_files=no_context_files,
397
+ session_dir_path=session_dir,
398
+ model_override=model_spec,
399
+ api_key=api_key,
400
+ scoped_models=scoped)
401
+ except _LoadErr as exc:
402
+ console.print(f"[red]装载失败:[/red] {escape(str(exc))}")
403
+ raise typer.Exit(code=1) from exc
404
+ except _CfgErr as exc:
405
+ console.print(f"[red]配置错误:[/red] {escape(str(exc))}")
406
+ raise typer.Exit(code=2) from exc
407
+ # `--ext` 报错 → **退出码 2**(与未知 CLI 选项同类:这是命令行打错了,不是运行时失败)。
408
+ # 必须在这里拦:继续跑的话用户会以为旗标生效了,而实际值一个都没进去。
409
+ if runtime.flag_errors:
410
+ for problem in runtime.flag_errors:
411
+ err_console.print(f"[red]{escape(problem)}[/red]")
412
+ raise typer.Exit(code=2)
413
+ store = runtime.sessions
414
+ # 启动提示(未信任跳过项目级扩展、旧 plugins/ 目录残留…)走 stderr:
415
+ # `-p` 的 stdout 是给脚本/管道用的,不能混入提示。
416
+ #
417
+ # 分两次打印:会话绑定**之后**还会产生提示(最典型的是"会话里记的模型没能恢复,
418
+ # 已用默认模型继续" —— 见 sessions.md §6.1)。只在这里打一次的话,那条提示
419
+ # 在无头模式下永远看不到,而它恰恰是"为什么不是我上次用的模型"的唯一解释。
420
+ printed_notes = len(runtime.notes)
421
+ for note in runtime.notes:
422
+ err_console.print(f"[yellow]{escape(note)}[/yellow]")
423
+ session = None
424
+ if no_session:
425
+ # 真·不落盘:以前走 `create()`,于是 `--no-session` 照样留一个文件
426
+ session = store.ephemeral(name or "ephemeral", cwd=runtime.cwd)
427
+ elif fork_id:
428
+ # 对齐 pi `--fork <path|id>`:把源会话的当前分支复制成一个新会话再跑
429
+ source = _open_session(store, fork_id)
430
+ if source is None:
431
+ console.print(f"[red]会话不存在: {fork_id}[/red]")
432
+ raise typer.Exit(code=1)
433
+ session = store.fork_at(source, source.current,
434
+ title=name or (f"{source.title} @fork" if source.title else "fork"))
435
+ err_console.print(f"[dim]已从 {source.id} 分叉出新会话 {session.id}[/dim]")
436
+ elif exact_session_id:
437
+ # `--session-id <id>`:精确 id,**不存在则创建**(pi 同名旗标)
438
+ session = store.get(exact_session_id) or store.create(
439
+ name or "", cwd=runtime.cwd, session_id=exact_session_id)
440
+ elif session_id:
441
+ session = _open_session(store, session_id)
442
+ if session is None:
443
+ console.print(f"[red]会话不存在: {session_id}[/red]")
444
+ raise typer.Exit(code=1)
445
+ elif cont:
446
+ session = store.latest()
447
+ if session is None:
448
+ console.print("[yellow]无历史会话,新建。[/yellow]")
449
+ session = store.create(name or "", cwd=runtime.cwd)
450
+ else:
451
+ session = store.create(name or prompt[:30], cwd=runtime.cwd)
452
+ assert session is not None
453
+ # 绑定会话:设置类 entry 要写进这个文件,而且会话里记的模型/级别要在**这一轮之前**
454
+ # 恢复好(否则第一轮就用错了模型 —— 见 sessions.md §6.1)
455
+ # 防御式取用:与 TUI 的 `notify_session_tree` 同一条约定 —— 测试替身不必实现
456
+ # 每个可选方法(此处是「可选」是因为替身也可以什么都不记)。
457
+ bind = getattr(runtime, "bind_session", None)
458
+ if callable(bind):
459
+ bind(session)
460
+ for note in runtime.notes[printed_notes:]:
461
+ err_console.print(f"[yellow]{escape(note)}[/yellow]")
462
+
463
+ async def _run() -> None:
464
+ # 会话已绑定 → 通知扩展(pi 的 `session_start { reason }`);错误进 notes,不挡这一轮
465
+ await runtime.start_session(session, reason=_session_reason(
466
+ no_session=no_session, fork_id=fork_id, session_id=session_id, cont=cont))
467
+ # 对齐 pi 的 `-p`:默认只输出答案;分派行/工具进度仅在 --verbose 时显示
468
+ # (且走 stderr,不污染 stdout)。`--mode json` 本就输出全部事件,不受此开关影响。
469
+ async for ev in runtime.stream(prompt, session):
470
+ if mode == "json":
471
+ # `indent=None` = 不美化:一个事件**一行**(JSONL)。
472
+ # 这条流是给脚本/管道消费的(见 docs/json.md),多行美化会让消费端无法
473
+ # 按行切分 —— “输出事件 JSON 行”与 pi 的 JSONL 都会不成立。
474
+ console.print_json(data={"kind": ev.kind, "agent": ev.agent,
475
+ "tool": ev.tool, "text": ev.text, "data": ev.data},
476
+ indent=None)
477
+ elif ev.kind == "text" and ev.text:
478
+ console.print(ev.text)
479
+ elif ev.kind == "error":
480
+ err_console.print(f"[red]{escape(ev.text)}[/red]")
481
+ elif verbose and ev.kind == "dispatch":
482
+ err_console.print(f"[cyan]→ {escape(ev.text)}[/cyan]")
483
+ elif verbose and ev.kind == "tool_start":
484
+ args = json.dumps(ev.data.get("args", {}), ensure_ascii=False)
485
+ err_console.print(f"[dim] ⚙ {ev.tool} {escape(args[:200])}[/dim]")
486
+ elif verbose and ev.kind == "tool_end":
487
+ data = ev.data or {}
488
+ mark = "✓" if data.get("status") == "ok" else "✗"
489
+ ms = data.get("duration_ms")
490
+ cost = f" {ms}ms" if isinstance(ms, int) else ""
491
+ err_console.print(f"[dim] ↳ {mark}{cost} {escape(_tool_snippet(ev.text))}[/dim]")
492
+ elif verbose and ev.kind == "opening":
493
+ err_console.print(f"[bold]{escape(ev.text)}[/bold]")
494
+
495
+ _run_headless(_run())
496
+ # provider 拒了 reasoning_effort:已经自动降级重试,但要告知(否则用户以为级别生效了)
497
+ client = getattr(runtime, "llm_exec", None)
498
+ if isinstance(client, ThinkingLLMClient) and client.reasoning_dropped:
499
+ err_console.print("[yellow]提示:该 provider 不接受 reasoning_effort,已按不思考运行[/yellow]")
500
+
501
+
502
+ def _run_headless(coro) -> None:
503
+ """无头执行:装退出信号处理器,并把 Ctrl-C 收敛成 130。
504
+
505
+ Ctrl-C 不经过信号处理器(asyncio 自己把主任务取消掉,子进程由 `run_shell` 的
506
+ finally 回收),但 Python 默认会带上 traceback 并以 1 退出 —— 惯例是 130。
507
+ 抽成函数是为了可测(见 tests/test_interrupt.py)。
508
+ """
509
+ restore = _install_exit_signal_handlers()
510
+ try:
511
+ asyncio.run(coro)
512
+ except KeyboardInterrupt:
513
+ raise typer.Exit(code=_exit_code_for(signal.SIGINT)) from None
514
+ finally:
515
+ restore()
516
+
517
+
518
+ # ── 无头运行的退出信号(对齐 pi 的 print 模式)──────────────────
519
+ #
520
+ # pi 在 print 模式下注册 SIGTERM /(非 Windows)SIGHUP:收到就 killTrackedDetachedChildren()
521
+ # 再以 143 / 129 退出。qi 少了轮次上限之后,这一层就是“无头被卡住”的主要兔底;
522
+ # 另外它补的是一条没有 Python 异常可依托的路径 —— 进程被 kill,清理代码不会自然跑到。
523
+ #
524
+ # SIGINT 不在这里:Python 默认转 KeyboardInterrupt → asyncio 取消主任务 →
525
+ # `run_shell` 的 finally 回收子进程组(见 tools/shell.py)。
526
+
527
+ _EXIT_CODE_BY_SIGNAL: dict[int, int] = {signal.SIGTERM: 143, signal.SIGHUP: 129}
528
+
529
+
530
+ def _exit_code_for(signum: int) -> int:
531
+ """128 + 信号号(对齐 pi:143 = 128+15、129 = 128+1)。
532
+
533
+ `signum` 已经是 int(信号处理器收到的也是 `signal.Signals`,IntEnum 子类),
534
+ 所以直接相加 —— 不需要 `int()` 包一层。
535
+ """
536
+ return _EXIT_CODE_BY_SIGNAL.get(signum, 128 + signum)
537
+
538
+
539
+ def _install_exit_signal_handlers() -> Callable[[], None]:
540
+ """装 SIGTERM/SIGHUP 处理器:先回收在跑的子进程组,再按信号码退出。
541
+
542
+ 返回“恢复原处理器”的回调(CLI 跑完就走,但测试/嵌入方需要干净收尾)。
543
+ """
544
+ from .tools.shell import kill_live_children
545
+
546
+ signals = [signal.SIGTERM]
547
+ if hasattr(signal, "SIGHUP"): # Windows 没有 SIGHUP
548
+ signals.append(signal.SIGHUP)
549
+ previous: dict[int, Any] = {}
550
+
551
+ def handler(signum: int, _frame: object) -> None:
552
+ kill_live_children() # 同步:信号处理器里不能 await
553
+ raise SystemExit(_exit_code_for(signum))
554
+
555
+ for sig in signals:
556
+ try:
557
+ previous[sig] = signal.signal(sig, handler)
558
+ except (ValueError, OSError): # 非主线程/平台不支持 → 跳过于净
559
+ pass
560
+
561
+ def restore() -> None:
562
+ for sig, old in previous.items():
563
+ with contextlib.suppress(ValueError, OSError, TypeError):
564
+ signal.signal(sig, old)
565
+
566
+ return restore
567
+
568
+
569
+ def _cmd_export(session_id: str, out: Path) -> None:
570
+ store = SessionStore()
571
+ session = store.get(session_id) if session_id else store.latest()
572
+ if session is None:
573
+ console.print("[red]无可导出会话[/red]")
574
+ raise typer.Exit(code=1)
575
+ out.parent.mkdir(parents=True, exist_ok=True)
576
+ try:
577
+ # 按**扩展名**选格式(pi 同口径:`.html` → 自包含 HTML,其余 → 原始 JSONL)
578
+ from .export_html import html_wanted, write_session_html
579
+
580
+ if html_wanted(out):
581
+ write_session_html(session, out)
582
+ console.print(f"[green]已导出会话(HTML,当前分支) → {out}[/green]")
583
+ return
584
+ shutil.copy(session.path, out)
585
+ except OSError as exc:
586
+ console.print(f"[red]导出失败: {escape(str(exc))}[/red]")
587
+ raise typer.Exit(code=1) from exc
588
+ console.print(f"[green]已导出 {session.path} → {out}[/green]")
589
+
590
+
591
+ def _refresh_entry_models(provider: str, entry: dict, *,
592
+ timeout: float = 20.0) -> bool:
593
+ """`GET {baseUrl}/models` 并进 `entry`,报告新增与本地多出的(**只加不删**),返回成败。
594
+
595
+ `qi init --refresh <名字>` 和交互流 Models 菜单的「↻ Refresh model list」
596
+ **共用这一条**:预置是离线种子,模型 id 会漂(DeepSeek 改名、MiMo v2.5 → v2.6),
597
+ 以厂商接口为准。接口不返回 `contextWindow` / `maxTokens`,新条目先只写 id,
598
+ 精确的两个数照厂商文档在 `models.json` 里补。
599
+ """
600
+ from .model_catalog import CatalogError, fetch_model_ids, merge_model_ids
601
+
602
+ rk = resolve_key(provider, str(entry.get("apiKey") or ""), AuthStore())
603
+ if not rk.ok:
604
+ console.print(f"[red]{provider}: 拿不到 API key[/red] {rk.describe()} —— "
605
+ f"`qi auth login {provider}` 或设约定环境变量")
606
+ return False
607
+ try:
608
+ ids = fetch_model_ids(str(entry.get("baseUrl") or ""), rk.key, timeout=timeout)
609
+ except CatalogError as exc:
610
+ console.print(f"[red]{provider}: 拉取失败[/red] {escape(str(exc))}")
611
+ return False
612
+ added, stale = merge_model_ids(entry, ids)
613
+ console.print(f"[green]{provider}[/green]: 接口返回 {len(ids)} 个模型;"
614
+ f"新增 {len(added)}"
615
+ + (":" + ", ".join(added) if added else ""))
616
+ if stale:
617
+ console.print(f" [dim]本地有但接口没返回(保留,不替你删):"
618
+ f"{', '.join(stale)}[/dim]")
619
+ return True
620
+
621
+
622
+ def _refresh_models(names: str, *, all_providers: bool, local: bool,
623
+ timeout: float = 20.0) -> None:
624
+ """从 provider 的 `/models` 接口拉模型列表,并进 `models.json`(**只加不删**)。
625
+
626
+ 为什么要这条:预置表是**离线种子**,而模型 id 会漂(如 DeepSeek 从
627
+ `deepseek-v4-flash` 换成 `deepseek-flash`)—— 以厂商接口为准最省事。
628
+ 接口不返回 `contextWindow` / `maxTokens`,新加的条目先写 id(两个数用 qi 默认值),
629
+ 要精确就照厂商文档在 `models.json` 里补。拉取/合并本身在 `_refresh_entry_models`
630
+ (交互流的「Refresh model list」共用同一条)。
631
+ """
632
+ try:
633
+ cfg, _files = load_config()
634
+ except ConfigError as exc:
635
+ console.print(f"[red]配置错误:[/red] {escape(str(exc))}")
636
+ raise typer.Exit(code=2) from exc
637
+ store = AuthStore()
638
+
639
+ wanted = [item.strip() for item in (names or "").split(",") if item.strip()]
640
+ if all_providers:
641
+ # “已配置”= models.json 里写过的 + 预置里**有凭证**的(与 `/model` 的口径一致)
642
+ wanted += [name for name, prov in cfg.providers.items()
643
+ if name not in cfg.presetProviders or resolve_key(name, prov.apiKey, store).ok]
644
+ wanted = list(dict.fromkeys(name.strip() for name in wanted if name.strip()))
645
+ if not wanted:
646
+ console.print("[red]要刷新哪个 provider?[/red] `qi init --refresh deepseek`"
647
+ " 或 `qi init --refresh-all`")
648
+ raise typer.Exit(code=2)
649
+ unknown = [name for name in wanted if name not in cfg.providers]
650
+ if unknown:
651
+ console.print(f"[red]未知 provider: {', '.join(unknown)}[/red]")
652
+ console.print("[dim]看看有哪些:`qi doctor`(预置的见 `qi init --list-presets`)[/dim]")
653
+ raise typer.Exit(code=2)
654
+
655
+ # 写回**定义它的那个文件**(见 models_file_for_provider)—— 没定义的(预置兜底来的)
656
+ # 才回落到用户级;`--local` 强制写项目级。
657
+ if local:
658
+ target_file = project_home() / MODELS_FILE_NAME
659
+ else:
660
+ target_file = models_file_for_provider(wanted[0]) or (global_home() / MODELS_FILE_NAME)
661
+ target_dir = target_file.parent
662
+ data = load_models_file(target_file)
663
+ failures: list[str] = []
664
+
665
+ for name in wanted:
666
+ prov = cfg.providers[name]
667
+ raw = (data.setdefault("providers", {})).get(name)
668
+ if isinstance(raw, dict):
669
+ entry = raw
670
+ else:
671
+ # 预置兜底来的 provider:物化时把预置**整段**写下来 —— 包括那些
672
+ # **带核过 ctx/max 的种子模型**。只写接口返回的 id 会把这两个数丢掉
673
+ # (接口不返回 ctx/max),而一旦写进 models.json,预置兜底就不再插手这个 provider。
674
+ from .presets import apply_presets, get_preset
675
+
676
+ if get_preset(name) is not None:
677
+ apply_presets(data, [name])
678
+ entry = data["providers"].setdefault(name, {})
679
+ for key, value in (("baseUrl", prov.baseUrl), ("api", prov.api or DEFAULT_API),
680
+ ("apiKey", prov.apiKey)):
681
+ if value and not entry.get(key):
682
+ entry[key] = value
683
+ if not _refresh_entry_models(name, entry, timeout=timeout):
684
+ failures.append(name)
685
+
686
+ if not failures:
687
+ _strip_legacy_defaults(data)
688
+ _write_models(target_dir, target_file, data)
689
+ else:
690
+ console.print("[yellow]有 provider 没刷新成功,这次不写文件。[/yellow]")
691
+ raise typer.Exit(code=1)
692
+
693
+
694
+ # ── doctor ──────────────────────────────────────────────
695
+
696
+ @app.command("doctor")
697
+ def doctor() -> None:
698
+ """检查配置、模型与凭证(启动诊断)。"""
699
+ exit_code = 0
700
+ try:
701
+ cfg, files = load_config()
702
+ except ConfigError as exc:
703
+ console.print(f"[red]配置错误:[/red] {escape(str(exc))}")
704
+ raise typer.Exit(code=2) from exc
705
+ if files:
706
+ console.print("[green]已装载配置:[/green]")
707
+ for f in files:
708
+ console.print(f" {f}")
709
+ else:
710
+ console.print(f"[yellow]未找到任何 {MODELS_FILE_NAME}[/yellow](env 指定 / 项目 .qi / 用户 ~/.qi/agent)")
711
+
712
+ try:
713
+ _settings, settings_files = load_settings()
714
+ except SettingsError as exc:
715
+ err_console.print(f"[red]{escape(str(exc))}[/red]")
716
+ settings_files = []
717
+ if settings_files:
718
+ console.print(f"[green]已装载 {SETTINGS_FILE_NAME}:[/green]")
719
+ for f in settings_files:
720
+ console.print(f" {f}")
721
+ else:
722
+ console.print(f"[dim]无 {SETTINGS_FILE_NAME}(可选;默认模型可写在里面)[/dim]")
723
+
724
+ ext_lines, ext_warnings = _extension_report()
725
+ if ext_lines:
726
+ console.print("[green]扩展:[/green]")
727
+ for line in ext_lines:
728
+ console.print(f" {line}")
729
+ else:
730
+ console.print("[dim]扩展:无(pip install qi-agents / qi-mcp / qi-web)[/dim]")
731
+ for warning in ext_warnings:
732
+ console.print(f" [yellow]⚠ {escape(warning)}[/yellow]")
733
+
734
+ # 包声明层比对(只读):声明了没装 / 装了没声明。装法输出**可复制的命令**,不自己调 pip。
735
+ try:
736
+ report = package_report(Path.cwd())
737
+ except SettingsError as exc:
738
+ # 上面已经报过一次;这里只说“包声明这一节为何缺席”
739
+ console.print(f" [dim]包声明:跳过({escape(str(exc))})[/dim]")
740
+ report = None
741
+ if report is not None:
742
+ _print_package_report(report)
743
+
744
+ store = AuthStore()
745
+ if cfg.providers:
746
+ table = Table(title="providers")
747
+ table.add_column("provider"); table.add_column("baseUrl")
748
+ table.add_column("api"); table.add_column("模型"); table.add_column("凭证")
749
+ for name_, prov in cfg.providers.items():
750
+ rk = resolve_key(name_, prov.apiKey, store)
751
+ label = name_ + (" [dim](预置)[/dim]" if name_ in cfg.presetProviders else "")
752
+ table.add_row(label, prov.baseUrl or "(内置)", prov.api or DEFAULT_API,
753
+ str(len(prov.models)),
754
+ f"{'[green]OK[/green]' if rk.ok else '[red]缺密钥[/red]'} {rk.describe()}")
755
+ console.print(table)
756
+ if cfg.presetProviders:
757
+ console.print("[dim]带 (预置) 的来自 qi 的预置表(`qi init --list-presets`);"
758
+ "models.json 里写了同名 provider 就以你的为准[/dim]")
759
+
760
+ try:
761
+ default = resolve_default_model(cfg)
762
+ except ConfigError as exc:
763
+ console.print(f"[red]{escape(str(exc))}[/red]")
764
+ raise typer.Exit(code=2) from exc
765
+ rk = resolve_key(default.provider, default.api_key_ref, store)
766
+ _provider, _model, model_source = default_model_spec()
767
+ console.print(f"[dim]默认模型来源: {model_source}[/dim]")
768
+ for path, legacy_provider, legacy_model in legacy_default_keys():
769
+ err_console.print(
770
+ f"[yellow]警告:[/yellow] {path} 里的 defaultProvider/defaultModel 已不再读取。\n"
771
+ f" 迁移: qi config --set defaultProvider={legacy_provider or '<name>'} "
772
+ f"--set defaultModel={legacy_model or '<id>'},然后删掉该文件里的这两行。"
773
+ )
774
+ console.print(f"[bold]默认模型:[/bold] {default.label} "
775
+ f"api={default.api} ctx={default.context_window} max={default.max_tokens} "
776
+ f"reasoning={default.reasoning} "
777
+ f"{'[green]凭证 OK[/green]' if rk.ok else '[red]缺密钥[/red]'} {rk.describe()}")
778
+ if not rk.ok:
779
+ exit_code = 3
780
+ router = resolve_router_model(cfg)
781
+ if router.label != default.label:
782
+ console.print(f"[bold]分派模型:[/bold] {router.label}")
783
+ if exit_code:
784
+ console.print(f"[yellow]提示:qi auth login <provider> 写入 auth store,或在 {MODELS_FILE_NAME} 的 provider 配 apiKey。[/yellow]")
785
+ raise typer.Exit(code=exit_code)
786
+
787
+
788
+ # ── models ──────────────────────────────────────────────
789
+
790
+ def _print_package_report(report) -> None:
791
+ """`qi doctor` 与 `qi list` 共用的包声明输出。
792
+
793
+ 两个方向 + 认不出的那些都要印:只印一个方向会让另一个方向的偏差永远不被发现。
794
+ """
795
+ if report.missing:
796
+ console.print("[green]包声明:[/green]")
797
+ for decl in report.missing:
798
+ console.print(f" [yellow]✗[/yellow] 声明了但没装: {escape(decl.spec)}"
799
+ f" [dim]({decl.source})[/dim]")
800
+ for hint in install_hints(decl.spec):
801
+ console.print(f" [dim]{escape(hint)}[/dim]")
802
+ elif report.declared:
803
+ tail = "与已装扩展一致" if report.consistent else ""
804
+ console.print(f"[green]包声明:[/green] {len(report.declared)} 条{tail}")
805
+ # 认不出的声明必须印 —— “声明了但看起来没声明”比报错难诊断得多
806
+ for bad in report.unparsed:
807
+ console.print(f" [yellow]⚠ 无法解析的声明:[/yellow] {escape(bad)}")
808
+ console.print(" [dim]用 `名字 @ URL` 写法才认得出名"
809
+ "(如 qi-mcp @ git+https://host/repo)[/dim]")
810
+ if report.undeclared:
811
+ names = "、".join(item.name for item in report.undeclared)
812
+ console.print(f" [dim]已装但未声明({len(report.undeclared)}): {escape(names)}"
813
+ " —— 写进 settings.packages 才能在 uv tool 重建后补回[/dim]")
814
+
815
+
816
+ @app.command("list")
817
+ def list_extensions() -> None:
818
+ """列出已装的扩展,并与 settings.packages 的声明比对。"""
819
+ try:
820
+ report = package_report(Path.cwd())
821
+ except SettingsError as exc:
822
+ console.print(f"[red]{escape(str(exc))}[/red]")
823
+ raise typer.Exit(code=2) from exc
824
+ if not report.installed and not report.declared:
825
+ console.print("[dim]没有已装扩展,settings.packages 里也没有声明。[/dim]")
826
+ console.print(f"[dim]装法:uv tool install {HOST_DISTRIBUTION} --with qi-mcp[/dim]")
827
+ return
828
+ declared_names = {item.name for item in report.declared}
829
+ table = Table(title="扩展")
830
+ table.add_column("扩展"); table.add_column("通道"); table.add_column("版本")
831
+ table.add_column("来源"); table.add_column("声明")
832
+ for item in report.installed:
833
+ table.add_row(item.name, item.channel, item.version or "—",
834
+ f"{item.origin} · {item.scope}",
835
+ "是" if item.name in declared_names else "否")
836
+ for decl in report.missing: # 声明了却加载不到 —— 单独一行,不混进已装列表
837
+ table.add_row(decl.name, decl.channel, "—", f"{decl.spec} · {decl.source}", "未安装")
838
+ console.print(table)
839
+ _print_package_report(report)
840
+
841
+
842
+ def _cmd_list_models(search: str | None = None) -> None:
843
+ """`--list-models [搜索词]`:列可用模型与默认模型。
844
+
845
+ 搜索词按**子串**过滤 `provider/模型`(pi 是模糊匹配;qi 先做最直白的那种)。
846
+ """
847
+ try:
848
+ cfg, _files = load_config()
849
+ except ConfigError as exc:
850
+ console.print(f"[red]{escape(str(exc))}[/red]")
851
+ raise typer.Exit(code=2) from exc
852
+ store = AuthStore()
853
+ provider, model, _source = default_model_spec()
854
+ default_label = f"{provider}/{model}" if provider and model else None
855
+ table = Table(title="模型")
856
+ table.add_column("默认"); table.add_column("provider"); table.add_column("模型")
857
+ table.add_column("api"); table.add_column("ctx"); table.add_column("max")
858
+ table.add_column("credential")
859
+ shown: set[str] = set()
860
+ for name_, prov in cfg.providers.items():
861
+ rk = resolve_key(name_, prov.apiKey, store)
862
+ # 与 `/model` 同一条口径(`selectable_models`):解析不出凭证的 provider 不列 ——
863
+ # `models.json` 里显式写过的也没有豁免(写了 `$ENV` 却没导出 = 用不了)。
864
+ # 例外只有一个:**当前默认模型的那个 provider** 照列,否则用户看不出自己缺的是哪把
865
+ # 钥匙(那一行的 `credential` 列会写清缺什么)。想知道还有哪些预置可登录:
866
+ # `qi init --list-presets` 或直接 `qi auth login <名>`。
867
+ if not rk.ok and name_ != provider:
868
+ continue
869
+ for m in prov.models:
870
+ label = f"{name_}/{m.id}"
871
+ if search and search.strip().lower() not in label.lower():
872
+ continue
873
+ shown.add(label)
874
+ provider_label = name_ + (" [dim](预置)[/dim]"
875
+ if name_ in cfg.presetProviders else "")
876
+ table.add_row("*" if label == default_label else "", provider_label, m.id,
877
+ m.api or prov.api or DEFAULT_API, str(m.contextWindow),
878
+ str(m.maxTokens), rk.describe())
879
+ if (default_label and default_label not in shown and provider and model
880
+ # 搜索词对**每一行**都生效 —— 默认模型那行也不例外(它是循环外补的)
881
+ and (not search or search.strip().lower() in default_label.lower())):
882
+ spec = resolve_model(cfg, provider, model)
883
+ rk = resolve_key(spec.provider, spec.api_key_ref, store)
884
+ table.add_row("*", spec.provider, spec.model, spec.api, str(spec.context_window),
885
+ str(spec.max_tokens), rk.describe())
886
+ console.print(table)
887
+
888
+
889
+ # ── 装 / 卸 / 更新(对齐 pi;判决 C 已撑销)──────────────────
890
+
891
+
892
+ def _run_pip(args: list[str]) -> int:
893
+ """跑一次 pip,把命令**先打出来**。
894
+
895
+ 为什么先打:目标是哪个解释器只有用户知道 —— 打出来他才能看出"装错环境了",
896
+ 也能直接照拄。失败时补上 `uv tool install --with` 那条出路(只读解释器 / uv tool
897
+ 环境里 pip 会自己报错,而那时用户需要知道另一条路)。
898
+ """
899
+ import shlex
900
+ import subprocess
901
+
902
+ cmd = [sys.executable, "-m", "pip", *args]
903
+ console.print("[dim]$ " + " ".join(shlex.quote(c) for c in cmd) + "[/dim]")
904
+ try:
905
+ code = subprocess.call(cmd)
906
+ except OSError as exc:
907
+ err_console.print(f"[red]起不了 pip:{escape(str(exc))}[/red]")
908
+ return 1
909
+ if code != 0:
910
+ err_console.print(f"[red]pip 退出码 {code}[/red]")
911
+ err_console.print(f"[dim]如果目标是只读解释器或 uv tool 环境,改用:"
912
+ f"`uv tool install {HOST_DISTRIBUTION} --with <包>`[/dim]")
913
+ return code
914
+
915
+
916
+ def _as_declaration(source: str) -> str:
917
+ """把用户给的来源规整成一条**声明**:存在的目录 → `local:<绝对路径>`,其余原样。
918
+
919
+ 目录通道的存在意义就是"不装也能用" —— 所以本地目录**不调 pip**。
920
+ """
921
+ text = source.strip()
922
+ if text.startswith(("pip:", "local:")):
923
+ if text.startswith("local:"):
924
+ return "local:" + str(Path(text[6:]).expanduser().resolve())
925
+ return text
926
+ maybe = Path(text).expanduser()
927
+ if maybe.is_dir():
928
+ return "local:" + str(maybe.resolve())
929
+ return text
930
+
931
+
932
+ def _pip_requirement(spec: str) -> str:
933
+ """声明 → pip 的 requirement(`pip:` 前缀去掉;裸 `名字` 也算 pip)。"""
934
+ return spec[4:].strip() if spec.startswith("pip:") else spec.strip()
935
+
936
+
937
+ def _local_path(spec: str) -> Path:
938
+ """声明 → 目录路径(`local:` 前缀去掉)。
939
+
940
+ 别拿 `_pip_requirement` 当通用去前缀用:它只认 `pip:`,于是 `local:/abs/x` 会被
941
+ 当成一个叫 `local:` 的目录(这个坑真踩过,测试抓出来的)。
942
+ """
943
+ return Path(spec[6:].strip() if spec.startswith("local:") else spec).expanduser()
944
+
945
+
946
+ def _declaration_name(spec: str) -> str:
947
+ """声明的**归一名字**(用于去重 / 比对)。交给 `packages.parse_declaration` 判 ——
948
+ 它是唯一同时认识 `pip:` / `local:` / `名字 @ URL` / 裸路径的那处逻辑。"""
949
+ from .packages import normalize_name, parse_declaration
950
+
951
+ declaration = parse_declaration(spec, "cli")
952
+ return declaration.name if declaration is not None else normalize_name(spec)
953
+
954
+
955
+ def _write_declared(scope: str, specs: list[str]) -> None:
956
+ """把声明写回 `settings.packages`(整表替换;空列表也写 —— 那表示“都卸了”)。"""
957
+ set_value(scope, "packages", specs)
958
+
959
+
960
+ def _declared_for(scope: str) -> list[str]:
961
+ from .settings import load_settings_by_scope
962
+
963
+ found = load_settings_by_scope(None).get(scope)
964
+ return [str(item) for item in (getattr(found, "packages", None) or [])]
965
+
966
+
967
+ @app.command("install")
968
+ def install(source: str = typer.Argument(..., help="包名 / requirement / 本地目录"),
969
+ local: bool = typer.Option(False, "--local", "-l",
970
+ help="写进项目 `.qi/settings.json`(默认写全局)")) -> None:
971
+ """装一个扩展:调 pip(本地目录只登记)+ 写进 `settings.packages`。
972
+
973
+ 与 `pi install` 同形。**先装后记**:pip 失败就不改声明 —— 否则 `qi doctor` 会
974
+ 报告一条“声明了但没装”,而那是我们刚刚制造的。
975
+ """
976
+ from .packages import parse_declaration
977
+
978
+ scope = "project" if local else "user"
979
+ declaration = parse_declaration(_as_declaration(source), "cli:install")
980
+ if declaration is None:
981
+ err_console.print(f"[red]认不出这个来源:{escape(source)}[/red]")
982
+ err_console.print("[dim]裸 URL 要写成 `名字 @ URL`"
983
+ "(如 qi-mcp @ git+https://host/repo)[/dim]")
984
+ raise typer.Exit(code=2)
985
+
986
+ if declaration.channel == "pip":
987
+ code = _run_pip(["install", _pip_requirement(declaration.spec)])
988
+ if code != 0:
989
+ raise typer.Exit(code=code)
990
+ else:
991
+ target = _local_path(declaration.spec)
992
+ if not (target / "extension.py").is_file():
993
+ err_console.print(f"[red]目录里没有 extension.py:{escape(str(target))}[/red]")
994
+ err_console.print("[dim]一个目录扩展的入口固定叫 extension.py(E9)[/dim]")
995
+ raise typer.Exit(code=2)
996
+ console.print(f"[green]目录通道,不用装[/green] {escape(str(target))}")
997
+
998
+ specs = _declared_for(scope)
999
+ name = declaration.name
1000
+ specs = [s for s in specs if _declaration_name(s) != name]
1001
+ specs.append(declaration.spec)
1002
+ _write_declared(scope, specs)
1003
+ where = "项目" if local else "全局"
1004
+ console.print(f"[green]已写入{where} settings.packages:[/green] {escape(declaration.spec)}")
1005
+ console.print("[dim]`qi doctor` 会确认它真的能被宿主加载[/dim]")
1006
+
1007
+
1008
+ @app.command("remove")
1009
+ def remove(source: str = typer.Argument(..., help="包名 / 本地目录(与 install 同一个来源)"),
1010
+ local: bool = typer.Option(False, "--local", "-l", help="只从项目设置里移除")) -> None:
1011
+ """从 `settings.packages` 里移除一条声明(**不卸包** —— 与 pi 同义)。
1012
+
1013
+ pi 的 `remove` 只动设置;真要卸包它不替你决定。qi 同口径:移除时把
1014
+ `pip uninstall` 命令打出来,跑不跑由你。
1015
+ """
1016
+ from .packages import normalize_name, parse_declaration
1017
+
1018
+ scope = "project" if local else "user"
1019
+ declaration = parse_declaration(_as_declaration(source), "cli:remove")
1020
+ target = declaration.name if declaration is not None else normalize_name(Path(source).name)
1021
+ specs = _declared_for(scope)
1022
+ kept = [s for s in specs if _declaration_name(s) != target]
1023
+ if len(kept) == len(specs):
1024
+ err_console.print(f"[yellow]{scope} 的 settings.packages 里没有 {escape(source)}[/yellow]")
1025
+ raise typer.Exit(code=1)
1026
+ _write_declared(scope, kept)
1027
+ console.print(f"[green]已从{scope}声明里移除 {escape(source)}[/green]")
1028
+ if declaration is not None and declaration.channel == "pip":
1029
+ console.print("[dim]包本体还在环境里。真卸掉:"
1030
+ f"{sys.executable} -m pip uninstall {escape(_pip_requirement(declaration.spec))}[/dim]")
1031
+
1032
+
1033
+ @app.command("uninstall")
1034
+ def uninstall(source: str = typer.Argument(..., help="同 remove"),
1035
+ local: bool = typer.Option(False, "--local", "-l")) -> None:
1036
+ """`remove` 的别名(pi 三个名字都有)。"""
1037
+ remove(source, local)
1038
+
1039
+
1040
+ @app.command("update")
1041
+ def update(target: str = typer.Argument(None, help="更新谁:self | pi | 包名/来源"),
1042
+ self_only: bool = typer.Option(False, "--self", help="只更新 qi 自己"),
1043
+ extensions: bool = typer.Option(False, "--extensions", help="只更新已声明的扩展"),
1044
+ models_only: bool = typer.Option(False, "--models", help="[无对应] 刷新模型目录"),
1045
+ everything: bool = typer.Option(False, "--all", help="qi 自己 + 扩展"),
1046
+ extension: str | None = typer.Option(None, "--extension", help="只更一个"),
1047
+ force: bool = typer.Option(False, "--force", help="即使已是最新也重装")) -> None:
1048
+ """更新 qi 自己 / 已声明的扩展。**没有目标时只更 qi 自己**(与 pi 同默认)。"""
1049
+ if models_only:
1050
+ # qi 没有"远端模型目录"这个概念:模型全在 models.json 里,你自己维护
1051
+ console.print("[yellow]qi 没有模型目录可以刷新[/yellow]:模型写在 `models.json`,"
1052
+ "由你自己维护(pi 是从远端拉目录,qi 不是)。")
1053
+ return
1054
+ want_self = self_only or everything or (not extensions and not extension
1055
+ and target in (None, "self", "pi"))
1056
+ want_ext = extensions or everything or bool(extension) or (
1057
+ target is not None and target not in ("self", "pi"))
1058
+ if not want_self and not want_ext:
1059
+ console.print("[yellow]没说要更新什么[/yellow](用 `--self` / `--extensions` / `--all`)")
1060
+ raise typer.Exit(code=2)
1061
+
1062
+ failed = 0
1063
+ if want_self:
1064
+ failed += _run_pip(["install", "--upgrade", *(
1065
+ ["--force-reinstall"] if force else []), HOST_DISTRIBUTION]) != 0
1066
+ if want_ext:
1067
+ pick = extension or (target if target not in (None, "self", "pi") else None)
1068
+ specs = _declared_for("user") + _declared_for("project")
1069
+ if pick:
1070
+ wanted = _declaration_name(_as_declaration(pick))
1071
+ specs = [s for s in specs if _declaration_name(s) == wanted]
1072
+ if not specs:
1073
+ err_console.print(f"[red]没有声明过 {escape(pick)}[/red]")
1074
+ raise typer.Exit(code=1)
1075
+ pip_specs = [s for s in specs if not s.startswith("local:")]
1076
+ if not pip_specs:
1077
+ console.print("[yellow]已声明的都是目录通道,没有要更新的包[/yellow]")
1078
+ for spec in pip_specs:
1079
+ failed += _run_pip(["install", "--upgrade", *(
1080
+ ["--force-reinstall"] if force else []), _pip_requirement(spec)]) != 0
1081
+ raise typer.Exit(code=1 if failed else 0)
1082
+
1083
+
1084
+ # ── auth / init ──────────────────────────────────────
1085
+
1086
+ auth_app = typer.Typer(help="管理 ~/.qi/agent/auth.json 凭证")
1087
+ app.add_typer(auth_app, name="auth")
1088
+
1089
+
1090
+ class _AuthError(Exception):
1091
+ """对齐 pi 的 AuthCommandError:消息直接讲原因,退出码由调用处定。"""
1092
+
1093
+
1094
+ def _auth_target(provider: str | None, model: str | None) -> tuple[str, str | None]:
1095
+ """解析 --provider/--model(至少给一个);只给 --model 时反查 provider。"""
1096
+ cfg, _files = load_config()
1097
+ if provider:
1098
+ return provider.strip().lower(), ((model or "").strip() or None)
1099
+ name = (model or "").strip()
1100
+ if not name:
1101
+ raise _AuthError("需给 --provider <provider> 或 --model <model>")
1102
+ matches = [p for p, prov in cfg.providers.items()
1103
+ if any(entry.id == name for entry in prov.models)]
1104
+ if len(matches) == 1:
1105
+ return matches[0], name
1106
+ if not matches:
1107
+ raise _AuthError(f"未知模型 {name!r}(用 `qi --list-models` 看已配置的模型)")
1108
+ raise _AuthError(
1109
+ f"模型 {name!r} 在多个 provider 中出现({', '.join(matches)});请加 --provider"
1110
+ )
1111
+
1112
+
1113
+ def _auth_resolve(provider: str | None, model: str | None) -> tuple[str, str | None, str | None]:
1114
+ """解析出 (provider, model, key);解析顺序同 resolve_key。"""
1115
+ name, found_model = _auth_target(provider, model)
1116
+ cfg, _files = load_config()
1117
+ prov = cfg.providers.get(name)
1118
+ rk = resolve_key(name, prov.apiKey if prov else None, AuthStore())
1119
+ return name, found_model, (rk.key if rk.ok else None)
1120
+
1121
+
1122
+ def _auth_fail(message: str, code: int) -> None:
1123
+ err_console.print(f"[red]Error: {escape(message)}[/red]")
1124
+ raise typer.Exit(code=code)
1125
+
1126
+
1127
+ @auth_app.command("print-api-key")
1128
+ def auth_print_api_key(
1129
+ provider: str | None = typer.Option(None, "--provider", help="provider 名"),
1130
+ model: str | None = typer.Option(None, "--model", help="用模型反查 provider"),
1131
+ ) -> None:
1132
+ """打印某 provider 的 API key 到 stdout(对齐 pi:`pi auth print-api-key`)。"""
1133
+ try:
1134
+ name, _mdl, key = _auth_resolve(provider, model)
1135
+ except (_AuthError, ConfigError) as exc:
1136
+ _auth_fail(str(exc), 1)
1137
+ return
1138
+ if not key:
1139
+ _auth_fail(f"provider {name!r} 无可用 API key(auth store / 环境变量 / models.json 均未提供)", 1)
1140
+ return
1141
+ sys.stdout.write(f"{key}\n") # 裸 stdout:可管道,不加 rich 装饰
1142
+
1143
+
1144
+ @auth_app.command("print-bearer-token")
1145
+ def auth_print_bearer_token(
1146
+ provider: str | None = typer.Option(None, "--provider", help="provider 名"),
1147
+ model: str | None = typer.Option(None, "--model", help="用模型反查 provider"),
1148
+ min_expiry: str | None = typer.Option(
1149
+ None, "--min-expiry",
1150
+ help="有效期下限(如 30m/1h);qi 凭证无过期时间,只校验格式"),
1151
+ ) -> None:
1152
+ """打印可作 Bearer token 的凭证(对齐 pi:`pi auth print-bearer-token`)。
1153
+
1154
+ qi 的 auth store 只存 api_key(无 OAuth),故这里的“bearer token”即 API key ——
1155
+ 对配了 `authHeader: true` 的 provider 就是 `Authorization: Bearer <key>` 的值。
1156
+ """
1157
+ if min_expiry is not None and not re.fullmatch(r"\d+(ms|s|m|h)", min_expiry.strip()):
1158
+ _auth_fail("--min-expiry 需为时长,例如 30m 或 1h", 1)
1159
+ return
1160
+ try:
1161
+ name, _mdl, key = _auth_resolve(provider, model)
1162
+ except (_AuthError, ConfigError) as exc:
1163
+ _auth_fail(str(exc), 1)
1164
+ return
1165
+ if not key:
1166
+ _auth_fail(f"provider {name!r} 未配置可用的 bearer 凭证", 1)
1167
+ return
1168
+ sys.stdout.write(f"{key}\n")
1169
+
1170
+
1171
+ @auth_app.command("check")
1172
+ def auth_check(
1173
+ provider: str | None = typer.Option(None, "--provider", help="provider 名"),
1174
+ model: str | None = typer.Option(None, "--model", help="用模型反查 provider"),
1175
+ json_out: bool = typer.Option(False, "--json", help="以 JSON 输出结果"),
1176
+ credentials: bool = typer.Option(False, "--credentials", help="输出里带上凭证"),
1177
+ no_refresh: bool = typer.Option(False, "--no-refresh", help="不刷新凭证(qi 无 OAuth,接受即无操作)"),
1178
+ ) -> None:
1179
+ """检查某 provider 的凭证是否就绪。
1180
+
1181
+ 退出码对齐 pi:`ready`=0,`not_ready`=1,`invalid`=2。
1182
+ """
1183
+ del no_refresh # 语义与 pi 一致地接受,但 qi 没有 OAuth 需要刷新
1184
+ requested = (provider or model or "").strip()
1185
+ result: dict[str, str]
1186
+ try:
1187
+ name, _mdl, key = _auth_resolve(provider, model)
1188
+ cfg, _files = load_config()
1189
+ known = name in cfg.providers or name in DEFAULT_API_KEY_ENV
1190
+ if not known:
1191
+ result = {"status": "not_ready", "provider": name,
1192
+ "reason": "provider_not_found"}
1193
+ elif not key:
1194
+ result = {"status": "not_ready", "provider": name,
1195
+ "reason": "credentials_not_configured"}
1196
+ else:
1197
+ result = {"status": "ready", "provider": name, "authType": "api_key"}
1198
+ if credentials:
1199
+ result["credentials"] = key
1200
+ except (_AuthError, ConfigError):
1201
+ result = {"status": "invalid", "provider": requested, "reason": "invalid_state"}
1202
+
1203
+ if json_out:
1204
+ sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
1205
+ else:
1206
+ sys.stdout.write(result.get("credentials") or result["status"] + "\n")
1207
+ status = result["status"]
1208
+ raise typer.Exit(code=0 if status == "ready" else 1 if status == "not_ready" else 2)
1209
+
1210
+
1211
+ @auth_app.command("login")
1212
+ def auth_login(provider: str = typer.Argument(...)) -> None:
1213
+ """输入 API key 写入 auth store(provider 可自定义;输入可见)。"""
1214
+ provider = provider.strip().lower()
1215
+ key = prompt.text(f"{provider} API key", required=True)
1216
+ store = AuthStore()
1217
+ store.set_key(provider, key)
1218
+ console.print(f"[green]已保存 {provider} 到 {store.path}(0600): [/green]{escape(_mask(key))}")
1219
+
1220
+
1221
+ @auth_app.command("logout")
1222
+ def auth_logout(provider: str = typer.Argument(...)) -> None:
1223
+ store = AuthStore()
1224
+ if store.remove(provider.strip().lower()):
1225
+ console.print(f"[green]已删除 {provider}[/green]")
1226
+ else:
1227
+ console.print(f"[yellow]{provider} 无凭证。[/yellow]")
1228
+
1229
+
1230
+ @auth_app.command("list")
1231
+ def auth_list() -> None:
1232
+ store = AuthStore()
1233
+ providers = store.providers()
1234
+ if not providers:
1235
+ console.print("(auth store 为空)")
1236
+ return
1237
+ for p in providers:
1238
+ console.print(p)
1239
+
1240
+
1241
+ # ── qi config:settings.json(对齐 pi 的键与分层) ───────
1242
+
1243
+ def _open_resource_panel(cwd: Path) -> None:
1244
+ """TTY 下开资源启停面板(pi 的 `pi config` 位置);保存后把改动写成声明。
1245
+
1246
+ 面板只回“哪些保持启用”,写声明的活在 `packages.apply_resource_selection` ——
1247
+ 所以取消时**没有任何副作用**(调用方根本不调那个函数)。
1248
+ """
1249
+ from .packages import apply_resource_selection, list_resources
1250
+
1251
+ resources = list_resources(cwd)
1252
+ if not resources:
1253
+ console.print("[dim]没有可启停的资源(没装扩展、settings.packages 也是空的)。[/dim]")
1254
+ return
1255
+ try:
1256
+ from .tui import run_resource_panel
1257
+ except Exception as exc: # noqa: BLE001 textual 依赖问题 → 退化成表,不留下一个死命令
1258
+ console.print(f"[yellow]面板不可用:{escape(str(exc))}[/yellow]")
1259
+ _print_resources(cwd)
1260
+ return
1261
+ chosen = run_resource_panel([
1262
+ (item.name, "扩展" if item.kind == "extension" else "包", item.scope, item.enabled)
1263
+ for item in resources])
1264
+ if chosen is None:
1265
+ console.print("[dim]已取消,没有改动。[/dim]")
1266
+ return
1267
+ notes = apply_resource_selection(resources, chosen, cwd)
1268
+ if not notes:
1269
+ console.print("[dim]没有改动。[/dim]")
1270
+ return
1271
+ for note in notes:
1272
+ console.print(f"[green]{escape(note)}[/green]")
1273
+
1274
+
1275
+ def _print_resources(cwd: Path) -> None:
1276
+ """`qi config` 不带旗标:列出可启停的资源。
1277
+
1278
+ “关”写在该作用域的声明里(目录扩展→`-<路径>` 否定项;pip 包→对象形态 `extensions: []`),
1279
+ 所以**状态是被记住的** —— 这张表能区分“启用”与“主动关了”,而不是只能看到“存在”。
1280
+ """
1281
+ from .packages import list_resources
1282
+
1283
+ resources = list_resources(cwd)
1284
+ if not resources:
1285
+ console.print("[dim]没有可启停的资源(没装扩展、settings.packages 也是空的)。[/dim]")
1286
+ return
1287
+ table = Table(title="资源")
1288
+ table.add_column("资源"); table.add_column("类型"); table.add_column("作用域")
1289
+ table.add_column("状态"); table.add_column("位置")
1290
+ for item in resources:
1291
+ table.add_row(item.name, "扩展" if item.kind == "extension" else "包", item.scope,
1292
+ "[green]启用[/green]" if item.enabled else "[yellow]已关闭[/yellow]",
1293
+ item.detail)
1294
+ console.print(table)
1295
+ console.print("[dim]关/开写进该作用域的声明(目录扩展 `-<路径>`;包对象形态"
1296
+ "`extensions: []`),所以状态被记住。改:直接改那个 settings.json。[/dim]")
1297
+
1298
+
1299
+ @app.command("config")
1300
+ def config_cmd(
1301
+ local: bool = typer.Option(False, "--local", "-l",
1302
+ help=f"操作项目 {'.qi'}/{SETTINGS_FILE_NAME}(默认全局 agent 目录)"),
1303
+ set_: list[str] = typer.Option(None, "--set", help="写键:--set skills='[\"~/x\"]'(可重复)"),
1304
+ get_: str | None = typer.Option(None, "--get", help="读一个键(点号路径,如 compaction.enabled)"),
1305
+ unset: list[str] = typer.Option(None, "--unset", help="删键(可重复)"),
1306
+ json_out: bool = typer.Option(False, "--json", help="以 JSON 输出合并后的设置"),
1307
+ ) -> None:
1308
+ """查看/编辑 settings.json:全局 `~/.qi/agent/` > 项目 `<git根>/.qi/`。"""
1309
+ scope = "project" if local else "user"
1310
+ cwd = Path.cwd()
1311
+
1312
+ # `--get <键>`:读**该作用域自己**的那份(点号路径,与 --set 对称)
1313
+ if get_ is not None:
1314
+ from .settings import read_json
1315
+
1316
+ path = settings_scope_path(scope, cwd)
1317
+ value: Any = read_json(path) if path.is_file() else {}
1318
+ for part in [p for p in get_.split(".") if p]:
1319
+ if isinstance(value, dict) and part in value:
1320
+ value = value[part]
1321
+ else:
1322
+ err_console.print(f"[yellow]{scope} settings 里没有 {escape(get_)}[/yellow]")
1323
+ raise typer.Exit(code=1)
1324
+ console.print(json.dumps(value, ensure_ascii=False, indent=2)
1325
+ if isinstance(value, (dict, list)) else str(value))
1326
+ return
1327
+
1328
+ if not (set_ or unset or json_out):
1329
+ # 不带旗标 = pi 的"资源面板"位置:TTY 下开面板,否则给一张表(脚本 / CI 也能用)。
1330
+ # 看得见“哪些资源被关了”本来就是面板的一半价值。
1331
+ if sys.stdin.isatty() and sys.stdout.isatty():
1332
+ _open_resource_panel(cwd)
1333
+ return
1334
+ _print_resources(cwd)
1335
+ console.print() # 后面那段设置总览留着(不 return)
1336
+ if set_ or unset:
1337
+ for item in set_ or []:
1338
+ if "=" not in item:
1339
+ console.print(f"[red]--set 需要 K=V 形式:[/red] {escape(item)}")
1340
+ raise typer.Exit(code=2)
1341
+ key, _, raw_value = item.partition("=")
1342
+ try:
1343
+ path = set_value(scope, key.strip(), parse_value(raw_value), cwd)
1344
+ except SettingsError as exc:
1345
+ console.print(f"[red]写入失败:[/red] {escape(str(exc))}")
1346
+ raise typer.Exit(code=2) from exc
1347
+ console.print(f"[green]已写[/green] {path}: {key.strip()} = {escape(raw_value)}")
1348
+ for key in unset or []:
1349
+ try:
1350
+ path, removed = unset_value(scope, key.strip(), cwd)
1351
+ except SettingsError as exc:
1352
+ console.print(f"[red]删除失败:[/red] {escape(str(exc))}")
1353
+ raise typer.Exit(code=2) from exc
1354
+ tag = "[green]已删[/green]" if removed else "[yellow]无此键[/yellow]"
1355
+ console.print(f"{tag} {path}: {key.strip()}")
1356
+ return
1357
+
1358
+ try:
1359
+ merged, files = load_settings_raw(cwd)
1360
+ settings, _ = load_settings(cwd)
1361
+ except SettingsError as exc:
1362
+ console.print(f"[red]{escape(str(exc))}[/red]")
1363
+ raise typer.Exit(code=2) from exc
1364
+
1365
+ if json_out:
1366
+ console.print_json(data={"files": [str(p) for p in files], "settings": merged})
1367
+ return
1368
+
1369
+ console.print(f"[bold]{SETTINGS_FILE_NAME}[/bold](高 → 低):")
1370
+ if files:
1371
+ for path in files:
1372
+ console.print(f" {path}")
1373
+ else:
1374
+ console.print(" (无;均为默认值)")
1375
+ for name in SCOPES:
1376
+ console.print(f" {name:8} → {settings_scope_path(name, cwd)}")
1377
+
1378
+ if merged:
1379
+ table = Table(title="合并后设置")
1380
+ table.add_column("键")
1381
+ table.add_column("值")
1382
+ for key in sorted(merged):
1383
+ table.add_row(key, json.dumps(merged[key], ensure_ascii=False))
1384
+ console.print(table)
1385
+
1386
+ provider, model, source = default_model_spec(cwd)
1387
+ console.print(f"[bold]默认模型:[/bold] {provider or '-'}/{model or '-'} (来源:{source})")
1388
+ console.print(f"[bold]技能发现:[/bold] {'开' if settings.skillsEnabled else '关'}")
1389
+
1390
+ try:
1391
+ skills = load_top_level_skills(cwd, settings, enabled=settings.skillsEnabled)
1392
+ except LoadError as exc:
1393
+ console.print(f"[red]技能装载失败:[/red] {escape(str(exc))}")
1394
+ raise typer.Exit(code=1) from exc
1395
+ if skills:
1396
+ table = Table(title=f"顶层技能({len(skills)})")
1397
+ table.add_column("name")
1398
+ table.add_column("来源")
1399
+ table.add_column("description")
1400
+ for skill in skills:
1401
+ table.add_row(skill.name, skill.source or "-", skill.description[:60])
1402
+ console.print(table)
1403
+ else:
1404
+ console.print("[dim]顶层技能:无(把 SKILL.md 放进 ~/.qi/agent/skills/<name>/)[/dim]")
1405
+
1406
+
1407
+ # ── qi init ─────────────────────────────────────────────
1408
+
1409
+ def _mask(key: str) -> str:
1410
+ """部分遮蔽:首尾各留少量字符,便于确认写入成功。"""
1411
+ if not key:
1412
+ return "(未设置)"
1413
+ if len(key) <= 8:
1414
+ return "*" * len(key)
1415
+ return f"{key[:4]}...{key[-2:]}"
1416
+
1417
+
1418
+ def _provider_configured(entry: dict) -> bool:
1419
+ """已配置 = 有 baseUrl 且至少一个模型(QwenPaw 的 [✓]/[✗] 标记)。"""
1420
+ models = [m for m in entry.get("models", []) or []
1421
+ if isinstance(m, dict) and m.get("id")]
1422
+ return bool(entry.get("baseUrl")) and bool(models)
1423
+
1424
+
1425
+ def _provider_labels(providers: dict) -> list[tuple[str, str]]:
1426
+ """返回 (label, provider_name) 列表,label 带 [✓]/[✗]。"""
1427
+ out: list[tuple[str, str]] = []
1428
+ for name in sorted(providers):
1429
+ entry = providers[name]
1430
+ mark = "✓" if _provider_configured(entry) else "✗"
1431
+ out.append((f"{name} [{mark}]", name))
1432
+ return out
1433
+
1434
+
1435
+ def _select_existing_provider(providers: dict, default_provider: str | None) -> str | None:
1436
+ """选 provider;返回 None 表示要新建。
1437
+
1438
+ 三种都列在一张单子里:**已有**(models.json 里写着的,带 `[✓]`/`[✗]`)、
1439
+ **预置**(还没物化的,标 `[预置]` —— 选中即 `apply_presets` 写进 models.json)、
1440
+ **新建**(永远排最后)。预置不列出来的话,`qi init` 对零配置的新用户就只剩
1441
+ 「手抄 baseUrl」一条路 —— 那正是预置表想省掉的事。
1442
+ """
1443
+ from .presets import preset_names
1444
+
1445
+ pairs = _provider_labels(providers)
1446
+ preset_only = [name for name in preset_names() if name not in providers]
1447
+ labels = ([p[0] for p in pairs]
1448
+ + [f"{name} [预置]" for name in preset_only]
1449
+ + ["+ 新建 provider"])
1450
+ names: list[str | None] = [p[1] for p in pairs] + preset_only + [None]
1451
+ default = names.index(default_provider) if default_provider in names else len(names) - 1
1452
+ idx = prompt.select("选择 provider", labels, default=default)
1453
+ chosen = names[idx]
1454
+ return chosen if chosen is not None else None
1455
+
1456
+
1457
+ def _configure_provider(provider: str, entry: dict) -> None:
1458
+ """按 QwenPaw 流程配置单个 provider:baseUrl → api → API key。"""
1459
+ store = AuthStore()
1460
+ current_key = store.get(provider) or ""
1461
+
1462
+ base_default = entry.get("baseUrl") or None
1463
+ base = prompt.text("Base URL (OpenAI-compatible endpoint)", default=base_default, required=True)
1464
+ if base:
1465
+ entry["baseUrl"] = base
1466
+
1467
+ api_options: list[str] = list(SUPPORTED_APIS)
1468
+ api_current = entry.get("api") if entry.get("api") in SUPPORTED_APIS else DEFAULT_API
1469
+ api_index = next((i for i, a in enumerate(api_options) if a == api_current), 0)
1470
+ entry["api"] = SUPPORTED_APIS[prompt.select("API 类型", api_options, default=api_index)]
1471
+
1472
+ # 凭证:可见输入;已有则回车保留(QwenPaw 的 [set] 语义)。
1473
+ # apiKey 是环境变量引用(`$DEEPSEEK_API_KEY`,预置的默认形态)时允许回车跳过 ——
1474
+ # 已经 export 了就不用再粘一遍,空值也照样解析得出 key。
1475
+ env_ref = entry.get("apiKey")
1476
+ env_name = env_ref[1:] if isinstance(env_ref, str) and env_ref.startswith("$") else ""
1477
+ if current_key:
1478
+ suffix = " [set, 回车保留]"
1479
+ elif env_name:
1480
+ suffix = f" [回车跳过,用 ${env_name}]"
1481
+ else:
1482
+ suffix = ""
1483
+ key = prompt.text(f"{provider} API key", suffix=suffix,
1484
+ required=not current_key and not env_name)
1485
+ if key:
1486
+ store.set_key(provider, key)
1487
+ current_key = key
1488
+ shown = _mask(current_key) if current_key else (
1489
+ f"${env_name}(环境变量)" if env_name else "(未设置)")
1490
+ summary = f"[green]✓[/green] {provider} — API Key: {escape(shown)}"
1491
+ if entry.get("baseUrl"):
1492
+ summary += f", Base URL: {escape(entry['baseUrl'])}"
1493
+ console.print(summary)
1494
+
1495
+
1496
+ def _add_models_interactive(provider: str, entry: dict) -> None:
1497
+ """QwenPaw 的 Add Models 段:一张**同级**菜单 —— 添加 / 刷新 / 完成,循环。
1498
+
1499
+ `↻ Refresh model list` 就是 `qi init --refresh <名字>` 的那条路(共用
1500
+ `_refresh_entry_models`):预置种子会落后厂商接口,在交互流里就地能对齐,
1501
+ 不用退出去再敲命令。失败只报错,回到菜单不中断 init。
1502
+ """
1503
+ models: list = entry.setdefault("models", [])
1504
+
1505
+ def show() -> None:
1506
+ console.print("\n[bold]--- Add Models ---[/bold]")
1507
+ if models:
1508
+ console.print(f"Current models for {provider}:")
1509
+ for m in models:
1510
+ if isinstance(m, dict) and m.get("id"):
1511
+ console.print(f" - {m.get('name') or m['id']} ({m['id']})")
1512
+ else:
1513
+ console.print(f"No models configured for {provider}.")
1514
+
1515
+ actions = ["+ Add a model", "↻ Refresh model list (GET /models)", "✓ Done"]
1516
+ show()
1517
+ while True:
1518
+ # 没模型默认「添加」、已有模型默认「完成」—— 跟原来 `Add a model?` 的是/否同语义
1519
+ idx = prompt.select("Models", actions, default=0 if not models else 2)
1520
+ if idx == 2:
1521
+ return
1522
+ if idx == 1:
1523
+ _refresh_entry_models(provider, entry)
1524
+ show()
1525
+ continue
1526
+ mid = prompt.text("Model identifier", required=True)
1527
+ name = prompt.text("Model display name", default=mid).strip() or mid
1528
+ reasoning = prompt.confirm("Supports reasoning (扩展思考)?", default=False)
1529
+ ctx = prompt.integer("contextWindow", DEFAULT_CONTEXT_WINDOW)
1530
+ mx = prompt.integer("maxTokens", DEFAULT_MAX_TOKENS)
1531
+ new = {"id": mid, "name": name, "reasoning": reasoning,
1532
+ "contextWindow": ctx, "maxTokens": mx}
1533
+ for i, m in enumerate(models):
1534
+ if isinstance(m, dict) and m.get("id") == mid:
1535
+ models[i] = {**m, **new}
1536
+ break
1537
+ else:
1538
+ models.append(new)
1539
+ console.print(f"[green]✓[/green] Model '{escape(name)}' ({escape(mid)}) added.")
1540
+ show()
1541
+
1542
+
1543
+ def _activate_llm(providers: dict, current: tuple[str | None, str | None]) -> tuple[str, str]:
1544
+ """QwenPaw 的 --- Activate LLM Model ---:选 provider → 选 model。
1545
+
1546
+ 返回 `(provider, model)` —— 默认模型写进 settings.json,不再进 models.json。
1547
+ """
1548
+ console.print("\n[bold]--- Activate LLM Model ---[/bold]")
1549
+ eligible = [n for n in sorted(providers)
1550
+ if any(isinstance(m, dict) and m.get("id") for m in providers[n].get("models", []))]
1551
+ if not eligible:
1552
+ console.print("[red]没有可用模型,已取消。[/red]")
1553
+ raise typer.Exit(code=1)
1554
+
1555
+ def _prov_label(n: str) -> str:
1556
+ mark = "✓" if _provider_configured(providers[n]) else "✗"
1557
+ return f"{n} [{mark}]"
1558
+
1559
+ cur_prov, cur_model = current
1560
+ pidx = eligible.index(cur_prov) if cur_prov in eligible else 0
1561
+ p = eligible[prompt.select("Select provider for LLM", [_prov_label(n) for n in eligible], default=pidx)]
1562
+
1563
+ ids = [m["id"] for m in providers[p]["models"] if isinstance(m, dict) and m.get("id")]
1564
+ midx = ids.index(cur_model) if cur_model in ids and cur_prov == p else 0
1565
+ labels = [f"{m.get('name') or m['id']}" for m in providers[p]["models"]
1566
+ if isinstance(m, dict) and m.get("id")]
1567
+ m = ids[prompt.select("Select LLM model", labels, default=midx)]
1568
+ console.print(f"[green]✓[/green] LLM: {escape(p)} / {escape(m)}")
1569
+ return p, m
1570
+
1571
+
1572
+ def _strip_legacy_defaults(data: dict) -> None:
1573
+ """models.json 里的 defaultProvider/defaultModel 已不生效 —— 顺手剔掉(init 本来就在重写该文件)。"""
1574
+ dropped = [k for k in ("defaultProvider", "defaultModel") if k in data]
1575
+ for key in dropped:
1576
+ data.pop(key, None)
1577
+ if dropped:
1578
+ console.print(f"[dim]已从 {MODELS_FILE_NAME} 移除旧键: {', '.join(dropped)}"
1579
+ f"(现在只写在 {SETTINGS_FILE_NAME})[/dim]")
1580
+
1581
+
1582
+ def _write_default_model(provider: str, model: str, local: bool) -> Path:
1583
+ """把默认模型写进 settings.json(全局或项目),返回文件路径。"""
1584
+ scope = "project" if local else "user"
1585
+ set_value(scope, "defaultProvider", provider)
1586
+ path = set_value(scope, "defaultModel", model)
1587
+ console.print(f"[green]✓[/green] 默认模型已写入 {path}")
1588
+ return path
1589
+
1590
+
1591
+ def _current_default(local: bool) -> tuple[str | None, str | None]:
1592
+ """读当前默认模型(settings.json),供 init 交互预选。"""
1593
+ scope = "project" if local else "user"
1594
+ try:
1595
+ settings = load_settings_by_scope().get(scope)
1596
+ except SettingsError:
1597
+ return None, None
1598
+ if settings is None:
1599
+ return None, None
1600
+ return settings.defaultProvider, settings.defaultModel
1601
+
1602
+
1603
+ def _write_models(target_dir: Path, target_file: Path, data: dict) -> None:
1604
+ for sub in ("agents", "extensions", "sessions"):
1605
+ (target_dir / sub).mkdir(parents=True, exist_ok=True)
1606
+ save_models_file(target_file, data)
1607
+ console.print(f"[green]✓[/green] Configuration saved to {target_file}")
1608
+
1609
+
1610
+ def _init_interactive(local: bool) -> None:
1611
+ """QwenPaw 风格:Provider Configuration → Add Models → Activate LLM Model。"""
1612
+ from .presets import apply_presets, get_preset
1613
+
1614
+ target_dir = project_home() if local else global_home()
1615
+ target_file = target_dir / MODELS_FILE_NAME
1616
+ data = load_models_file(target_file)
1617
+ providers: dict = data.setdefault("providers", {})
1618
+ current = _current_default(local)
1619
+
1620
+ console.print(f"Working dir: {target_dir}")
1621
+ console.print("\n[bold]=== LLM Provider Configuration ===[/bold]")
1622
+ console.print("[bold]--- Provider Configuration ---[/bold]")
1623
+ console.print("[dim]标 [预置] 的是 qi 内置预置(全表 `qi init --list-presets`);"
1624
+ "选中即写进 models.json,之后归你改[/dim]")
1625
+ while True:
1626
+ provider = _select_existing_provider(providers, current[0])
1627
+ if provider is None:
1628
+ provider = prompt.text("Provider name", required=True)
1629
+ # 选中的是预置(或新建时敲了个预置名)→ 先物化:baseUrl / apiKey 引用 / 模型
1630
+ # 都有默认值,下面几问回车即保留。
1631
+ preset = get_preset(provider) if provider else None
1632
+ if preset is not None and preset.provider not in providers:
1633
+ apply_presets(data, [preset.provider])
1634
+ provider = preset.provider
1635
+ console.print(f"[dim]已套用预置 {preset.provider}({preset.label});"
1636
+ f"下面回车即保留预置值[/dim]")
1637
+ entry = providers.setdefault(provider, {})
1638
+ _configure_provider(provider, entry)
1639
+ _add_models_interactive(provider, entry)
1640
+ if not prompt.confirm("Configure another provider?", default=False):
1641
+ break
1642
+
1643
+ provider, model = _activate_llm(providers, current)
1644
+ _strip_legacy_defaults(data)
1645
+ _write_models(target_dir, target_file, data)
1646
+ _write_default_model(provider, model, local)
1647
+ console.print("\n[green]✓ Initialization complete![/green]")
1648
+
1649
+
1650
+ def _init_noninteractive(*, provider: str | None, model: str | None, base_url: str | None,
1651
+ api: str | None, api_key: str | None, api_key_env: str | None,
1652
+ reasoning: bool | None, context_window: int | None,
1653
+ max_tokens: int | None, local: bool) -> None:
1654
+ from .presets import apply_presets, get_preset
1655
+
1656
+ target_dir = project_home() if local else global_home()
1657
+ target_file = target_dir / MODELS_FILE_NAME
1658
+ data = load_models_file(target_file)
1659
+ providers: dict = data.setdefault("providers", {})
1660
+
1661
+ if not provider:
1662
+ provider = _current_default(local)[0]
1663
+ if not provider:
1664
+ console.print("[red]-y 模式需要 --provider(或先用交互模式配置)。[/red]")
1665
+ raise typer.Exit(code=2)
1666
+ provider = provider.strip()
1667
+
1668
+ # provider 名对得上预置就先物化(同 `--preset`):否则 `-y --provider deepseek`
1669
+ # 会写出一个没有 baseUrl 的 provider,拿到手就是坏的。
1670
+ preset = get_preset(provider)
1671
+ if preset is not None:
1672
+ provider = preset.provider
1673
+ if provider not in providers:
1674
+ apply_presets(data, [provider])
1675
+ console.print(f"[dim]已套用预置 {provider}({preset.label})[/dim]")
1676
+
1677
+ entry: dict = dict(providers.get(provider) or {})
1678
+ if base_url:
1679
+ entry["baseUrl"] = base_url
1680
+ if api:
1681
+ if api not in SUPPORTED_APIS:
1682
+ console.print(f"[red]未知 api {api};支持: {', '.join(SUPPORTED_APIS)}[/red]")
1683
+ raise typer.Exit(code=2)
1684
+ entry["api"] = api
1685
+ entry.setdefault("api", DEFAULT_API)
1686
+
1687
+ store = AuthStore()
1688
+ if api_key:
1689
+ store.set_key(provider, api_key)
1690
+ elif api_key_env:
1691
+ entry["apiKey"] = f"${api_key_env}"
1692
+ elif not entry.get("apiKey") and not store.get(provider):
1693
+ env_name = DEFAULT_API_KEY_ENV.get(provider)
1694
+ if env_name:
1695
+ entry["apiKey"] = f"${env_name}"
1696
+
1697
+ models: list = entry.setdefault("models", [])
1698
+ if not model:
1699
+ cur_provider, cur_model = _current_default(local)
1700
+ model = cur_model if cur_provider == provider else None
1701
+ if not model and models:
1702
+ model = models[0].get("id")
1703
+ if not model:
1704
+ console.print("[red]-y 模式需要 --model。[/red]")
1705
+ raise typer.Exit(code=2)
1706
+ model = model.strip()
1707
+
1708
+ new = {"id": model}
1709
+ for i, m in enumerate(models):
1710
+ if isinstance(m, dict) and m.get("id") == model:
1711
+ merged = {**m, **new}
1712
+ merged["reasoning"] = bool(reasoning) if reasoning is not None else m.get("reasoning", False)
1713
+ merged["contextWindow"] = (context_window if context_window is not None
1714
+ else m.get("contextWindow", DEFAULT_CONTEXT_WINDOW))
1715
+ merged["maxTokens"] = (max_tokens if max_tokens is not None
1716
+ else m.get("maxTokens", DEFAULT_MAX_TOKENS))
1717
+ models[i] = merged
1718
+ break
1719
+ else:
1720
+ models.append({
1721
+ "id": model,
1722
+ "reasoning": bool(reasoning) if reasoning is not None else False,
1723
+ "contextWindow": context_window if context_window is not None else DEFAULT_CONTEXT_WINDOW,
1724
+ "maxTokens": max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS,
1725
+ })
1726
+
1727
+ providers[provider] = entry
1728
+ _strip_legacy_defaults(data)
1729
+ _write_models(target_dir, target_file, data)
1730
+ _write_default_model(provider, model, local)
1731
+ console.print(f"[bold]默认模型:[/bold] {provider}/{model}")
1732
+ console.print("[yellow]下一步:qi doctor 校验。[/yellow]")
1733
+
1734
+
1735
+ @app.command("init")
1736
+ def init(
1737
+ provider: str | None = typer.Option(None, "--provider", help="provider 名(已有或新建)"),
1738
+ model: str | None = typer.Option(None, "--model", help="模型 id(已有或新建)"),
1739
+ base_url: str | None = typer.Option(None, "--base-url", help="provider 的 API endpoint"),
1740
+ api: str | None = typer.Option(None, "--api", help="api 类型: " + "/".join(SUPPORTED_APIS)),
1741
+ api_key: str | None = typer.Option(None, "--api-key", help="API key(写入 auth.json)"),
1742
+ api_key_env: str | None = typer.Option(None, "--api-key-env", help="在 models.json 引用该环境变量"),
1743
+ reasoning: bool | None = typer.Option(None, "--reasoning/--no-reasoning", help="模型支持 reasoning"),
1744
+ context_window: int | None = typer.Option(None, "--context-window", help="上下文窗口 token"),
1745
+ max_tokens: int | None = typer.Option(None, "--max-tokens", help="最大输出 token"),
1746
+ local: bool = typer.Option(False, "--local", "-l", help="写入项目 .qi/models.json"),
1747
+ yes: bool = typer.Option(False, "--yes", "-y", help="非交互:需配合 --provider/--model"),
1748
+ preset: str | None = typer.Option(None, "--preset",
1749
+ help="用预置 provider(逗号分隔,如 deepseek,moonshot)"),
1750
+ list_presets: bool = typer.Option(False, "--list-presets", help="列出所有预置 provider 后退出"),
1751
+ refresh: str | None = typer.Option(None, "--refresh",
1752
+ help="从 provider 的 /models 接口拉模型列表写回(逗号分隔)"),
1753
+ refresh_all: bool = typer.Option(False, "--refresh-all",
1754
+ help="刷新所有已配置(有凭证)的 provider"),
1755
+ ) -> None:
1756
+ """引导默认模型:Provider Config → Add Models → Activate LLM,写 models.json + auth.json。
1757
+
1758
+ `--preset` 是一条显式捷径:把预置的国产 provider(baseUrl/api/模型)直接写进
1759
+ `models.json`(已有的值不动),并把它的第一个模型设为默认。
1760
+
1761
+ `--refresh` 走**另一条路**:预置表是离线种子,而模型 id 会漂(实例:DeepSeek 从
1762
+ `deepseek-v4-flash` 换成 `deepseek-flash`)—— 直接问厂商的 `/models` 接口最省事。
1763
+ """
1764
+ if list_presets:
1765
+ _print_presets()
1766
+ return
1767
+ if refresh or refresh_all:
1768
+ _refresh_models(refresh or "", all_providers=refresh_all, local=local)
1769
+ return
1770
+ if preset:
1771
+ _init_from_presets(preset, local=local, default_model=model)
1772
+ return
1773
+ if yes:
1774
+ _init_noninteractive(provider=provider, model=model, base_url=base_url, api=api,
1775
+ api_key=api_key, api_key_env=api_key_env, reasoning=reasoning,
1776
+ context_window=context_window, max_tokens=max_tokens, local=local)
1777
+ else:
1778
+ _init_interactive(local)
1779
+
1780
+
1781
+ def _print_presets() -> None:
1782
+ """`qi init --list-presets`:把预置表打印成人能看的样子。"""
1783
+ from .presets import PRESETS
1784
+
1785
+ for name in sorted(PRESETS):
1786
+ preset = PRESETS[name]
1787
+ console.print(f"[bold]{name}[/bold] [dim]{preset.label}[/dim]")
1788
+ console.print(f" baseUrl {preset.base_url}")
1789
+ console.print(f" apiKey ${preset.api_key_env}(或 `qi auth login {preset.provider}`)")
1790
+ console.print(" 模型 " + ", ".join(
1791
+ f"{m.id}(ctx {m.context_window:,} / max {m.max_output:,})"
1792
+ for m in preset.models))
1793
+ if preset.note:
1794
+ console.print(f" [yellow]注意[/yellow] {preset.note}")
1795
+ console.print("\n用法:[bold]qi init --preset <名字>[,<名字>][/bold]")
1796
+ console.print("[dim]预置是离线种子;要跟厂商接口对齐用 `qi init --refresh <名字>`"
1797
+ "(只加不删)。[/dim]")
1798
+
1799
+
1800
+ def _init_from_presets(preset: str, *, local: bool, default_model: str | None) -> None:
1801
+ """`qi init --preset …`:把预置写进 models.json(不覆盖已有值),再设默认模型。"""
1802
+ from .presets import apply_presets, get_preset
1803
+
1804
+ names = [item.strip() for item in preset.split(",") if item.strip()]
1805
+ missing = [name for name in names if get_preset(name) is None]
1806
+ if missing:
1807
+ console.print(f"[red]未知预置: {', '.join(missing)}[/red]")
1808
+ console.print("[dim]可用预置:`qi init --list-presets`[/dim]")
1809
+ raise typer.Exit(code=2)
1810
+ # 解析一次(下面多处要用;`get_preset` 已保证这里都不是 None)
1811
+ chosen_list = [item for item in (get_preset(name) for name in names) if item is not None]
1812
+
1813
+ target_dir = project_home() if local else global_home()
1814
+ target_file = target_dir / MODELS_FILE_NAME
1815
+ data = load_models_file(target_file)
1816
+ _data, changed = apply_presets(data, names)
1817
+ _strip_legacy_defaults(data)
1818
+ _write_models(target_dir, target_file, data)
1819
+ console.print(f"已写入 {len(changed)} 项:" + (", ".join(changed) if changed else "(都已存在,无改动)"))
1820
+
1821
+ # 默认模型:显式 `--model` 优先,否则取**第一个预置**的第一个模型
1822
+ first = chosen_list[0] if chosen_list else None
1823
+ chosen_model = default_model or (first.default_model if first else None)
1824
+ if first is not None and chosen_model:
1825
+ _write_default_model(first.provider, chosen_model, local)
1826
+
1827
+ providers = ", ".join(item.provider for item in chosen_list)
1828
+ console.print(f"[dim]下一步:给 {providers} 配 key —— 环境变量(见上表)或 "
1829
+ f"`qi auth login <provider>`;然后 `qi doctor` 校验。[/dim]")
1830
+
1831
+
1832
+ def _session_reason(*, no_session: bool, fork_id: str | None,
1833
+ session_id: str | None, cont: bool) -> str:
1834
+ """`session_start` 的 reason(对齐 pi 的 startup / new / resume / fork)。
1835
+
1836
+ `ephemeral` 是 qi 自己的:`--no-session` 不落盘,扩展据此可以跳过“往会话里存状态”。
1837
+ """
1838
+ if no_session:
1839
+ return "ephemeral"
1840
+ if fork_id:
1841
+ return "fork"
1842
+ if session_id or cont:
1843
+ return "resume"
1844
+ return "new"
1845
+
1846
+
1847
+ def _trust_flag(approve: bool, no_approve: bool) -> bool | None:
1848
+ """`-a` / `-na` → 三态信任参数(None = 没表态,交给 settings 与默认策略)。
1849
+
1850
+ 两个都给是矛盾输入 → 报错退出,不猜(与 `-t`/`-xt` 的处理同形)。
1851
+ """
1852
+ if approve and no_approve:
1853
+ console.print("[red]-a 与 -na 不能同时给[/red]")
1854
+ raise typer.Exit(code=2)
1855
+ if approve:
1856
+ return True
1857
+ if no_approve:
1858
+ return False
1859
+ return None
1860
+
1861
+
1862
+ def _launch_tui(initial_prompt: str | None = None, *, session_id: str | None = None,
1863
+ cont: bool = False, fork_id: str | None = None,
1864
+ no_session: bool = False, name: str | None = None,
1865
+ approve_project: bool | None = None,
1866
+ extension_flags: list[str] | None = None,
1867
+ extra_extension_paths: list[Path] | None = None,
1868
+ tools: str | None = None, exclude_tools: str | None = None,
1869
+ no_tools: bool = False, no_builtin_tools: bool = False,
1870
+ append_system_prompt: list[str] | None = None,
1871
+ resume: bool = False, exact_session_id: str | None = None,
1872
+ base_prompt_override: str | None = None,
1873
+ no_extensions: bool = False, no_context_files: bool = False,
1874
+ session_dir_path: str | None = None,
1875
+ model_override: str | None = None, api_key: str | None = None,
1876
+ scoped_models: list[str] | None = None,
1877
+ tui_mode: str | None = None) -> None:
1878
+ """启动 TUI(顶层 `qi` 的默认去向)。
1879
+
1880
+ 刻意不做成子命令:`pi` 也没有 `pi tui` —— 裸 `qi` 就是交互界面。
1881
+ `initial_prompt` 来自 `qi "问题"`:进界面后立刻提交这条消息;
1882
+ 会话选择参数透传给 TUI(否则 `qi -c` 进界面后会失效)。
1883
+ """
1884
+ try:
1885
+ from .tui import run_tui
1886
+ except Exception as exc: # textual 依赖问题
1887
+ console.print(f"[red]TUI 不可用: {escape(str(exc))}[/red]")
1888
+ raise typer.Exit(code=1) from exc
1889
+ run_tui(initial_prompt, session_id=session_id, cont=cont, fork_id=fork_id,
1890
+ no_session=no_session, name=name, approve_project=approve_project,
1891
+ extension_flags=extension_flags,
1892
+ extra_extension_paths=extra_extension_paths,
1893
+ tools=tools, exclude_tools=exclude_tools,
1894
+ no_tools=no_tools, no_builtin_tools=no_builtin_tools,
1895
+ append_system_prompt=append_system_prompt,
1896
+ resume=resume, exact_session_id=exact_session_id,
1897
+ base_prompt_override=base_prompt_override,
1898
+ no_extensions=no_extensions, no_context_files=no_context_files,
1899
+ session_dir_path=session_dir_path, model_override=model_override,
1900
+ api_key=api_key, scoped_models=scoped_models, tui_mode=tui_mode)
1901
+
1902
+
1903
+
1904
+ def _core_subcommand_names() -> set[str]:
1905
+ """core 自己的子命令名(从 typer 的登记表拿;拿不到就退回空集,只是少了这层保护)。"""
1906
+ names: set[str] = set()
1907
+ for holder in ("registered_commands", "registered_groups"):
1908
+ for item in getattr(app, holder, []) or []:
1909
+ name = getattr(item, "name", None)
1910
+ if name:
1911
+ names.add(str(name))
1912
+ return names
1913
+
1914
+
1915
+ def _discover_cli_commands(cwd: Path | None = None) -> CliCommandRegistry:
1916
+ """只装载扩展、只要它们的 CLI 子命令表(轻量发现:没有 runtime / 会话 / 模型)。
1917
+
1918
+ **未信任的项目目录不扫**(§5.3):CLI 子命令会在 typer 之前执行扩展的代码,而项目目录是
1919
+ 仓库控制的。
1920
+
1921
+ **登记处必须给齐**(与 `_extension_report` 那份一样):装载器的策略是"扩展坏 → 启动报错,
1922
+ 不静默"(registry.py 里那句 `扩展 X 装载失败`),所以少给一个登记处不是"那个扩展少注册
1923
+ 一样东西",而是**整个发现过程中断**。曾因缺 `commands`/`flags` 使 qi-mcp(`api.registerCommand`)
1924
+ 与 qi-agents(`api.registerFlag`)当场倒下,连带 qi-web 的 `qi web` 从未注册。
1925
+ 这里只消费 `cli_commands`,其余两个只是让兄弟们能正常走完 `register()`。
1926
+ """
1927
+ registry = CliCommandRegistry()
1928
+ # 登记处从 `.extensions` 取(与 `_extension_report` 同源);局部导入避免 CLI 模块顶部就拖上宿主
1929
+ from .extensions import CommandRegistry, FlagRegistry
1930
+
1931
+ try:
1932
+ discover_extensions(ToolCatalog(), CapabilityRegistry(), cwd,
1933
+ bus=ExtensionBus(),
1934
+ commands=CommandRegistry(), flags=FlagRegistry(),
1935
+ cli_commands=registry,
1936
+ project_trusted=False)
1937
+ except Exception as exc: # noqa: BLE001 一个扩展装坏不该让整个 CLI 不可用
1938
+ err_console.print(f"[yellow]扩展发现失败(忽略): {escape(str(exc))}[/yellow]")
1939
+ return registry
1940
+
1941
+
1942
+ #: 官方扩展提供的 CLI 子命令 → 提供它的包。
1943
+ #: 与 design/extensions-design.md 的迁移清单同源:core 不内置它们,所以这份名单必须写在 core 里 ——
1944
+ #: 否则"没装那个扩展的人"永远只能看到 `No such command`,而不知道要装什么。
1945
+ _OFFICIAL_EXTENSION_COMMANDS = {"web": "qi-web"}
1946
+
1947
+
1948
+ def _reject_unimplemented_flags(*, prompt_template, no_prompt_templates, theme_file,
1949
+ use_theme, no_themes, mode) -> None:
1950
+ """接受 pi 有、qi **还没有对应功能**的旗标,但明确报出来(退出码 2)。
1951
+
1952
+ 为什么接受:pi 的命令行迁过来时,"未知选项"看不出问题在哪 —— 用户会以为是拼写错。
1953
+ 为什么报错而不是静默忽略:这几条都**改行为**,忽略了就等于"我说的没生效"而没任何提示。
1954
+ 每一行写清缺的是**什么功能**、以及现在能用什么替代。
1955
+ """
1956
+ missing: list[str] = []
1957
+ if prompt_template or no_prompt_templates:
1958
+ missing.append("prompt 模板 -- qi 没有模板发现 / 注入这套机制; "
1959
+ "要固定前缀就写进 `.qi/SYSTEM.md` 或做成技能")
1960
+ if theme_file or use_theme or no_themes:
1961
+ missing.append("自定义主题文件 -- qi 只有内置 dark / light / auto; "
1962
+ "选主题用 `QI_THEME=light` 或 `qi config --set theme=`")
1963
+ if str(mode or "").strip().lower() == "rpc":
1964
+ missing.append("`--mode rpc` -- qi 的输出模式只有 text | json; "
1965
+ "stdio JSON-RPC 还没实现(见 design/PLAN.md 的 v2 清单)")
1966
+ if not missing:
1967
+ return
1968
+ for item in missing:
1969
+ err_console.print(f"[red]还没实现:{escape(item)}[/red]")
1970
+ err_console.print("[dim]qi 接受这条旗标是为了让 pi 的命令行能迁过来,但不会假装它生效。[/dim]")
1971
+ raise typer.Exit(code=2)
1972
+
1973
+
1974
+ def _model_spec(provider: str | None, model: str | None) -> str | None:
1975
+ """`--provider` / `--model` 合成一个 `provider/模型`(pi 允许两者分开写)。
1976
+
1977
+ 只给 `--provider` 时用它 models.json 里的**第一个**模型(pi 用该 provider 的默认模型)。
1978
+ 拼不出来的情况**报错**,不猜。
1979
+ """
1980
+ if not provider and not model:
1981
+ return None
1982
+ if model and "/" in model:
1983
+ return model
1984
+ if not provider:
1985
+ raise ValueError('只给 `--model` 时要写成 "provider/模型"')
1986
+ if model:
1987
+ return f"{provider}/{model}"
1988
+ from .config import load_config
1989
+
1990
+ cfg, _files = load_config()
1991
+ entry = cfg.providers.get(provider)
1992
+ if entry is None or not entry.models:
1993
+ raise ValueError(f"models.json 里没有 provider {provider!r}(或它没声明模型)")
1994
+ return f"{provider}/{entry.models[0].id}"
1995
+
1996
+
1997
+ def _text_or_file(value: str) -> str:
1998
+ """旗标值:是**可读文件**就取文件内容,否则就当文本(pi 的 "text or file contents")。
1999
+
2000
+ 为什么这样判:pi 就这么做,而这两种输入在命令行上没法区分 —— 用户写
2001
+ `--append-system-prompt ./house-rules.md` 时期待的是文件内容。读不到就当字面文本。
2002
+ """
2003
+ if not value:
2004
+ return value
2005
+ maybe = Path(value).expanduser()
2006
+ try:
2007
+ if maybe.is_file():
2008
+ return maybe.read_text(encoding="utf-8")
2009
+ except OSError:
2010
+ return value
2011
+ return value
2012
+
2013
+
2014
+ def _open_session(store: Any, ref: str) -> Any:
2015
+ """`--session <path|id>` / `--fork <path|id>`:先当**文件路径**,再当 id / 前缀。
2016
+
2017
+ 以前只走 `store.get()`,而它按 header id / 文件名 stem 前缀匹配 —— 传路径永远不命中,
2018
+ 与帮助文字里写的 `path|id` 不符。
2019
+ """
2020
+ maybe = Path(ref).expanduser()
2021
+ if maybe.is_file():
2022
+ opened = store.open_file(maybe)
2023
+ if opened is not None:
2024
+ return opened
2025
+ return store.get(ref)
2026
+
2027
+
2028
+ def _hint_missing_extension_command(argv: list[str]) -> None:
2029
+ """`qi <官方扩展子命令>` 但那个扩展没装 → 报装法(而不是 `No such command`)。
2030
+
2031
+ 代价故意压到零:只有 `argv[0]` 命中那份小名单时才去发现扩展命令表(那步要装载)。
2032
+ """
2033
+ if not argv or argv[0].startswith("-") or argv[0] in _core_subcommand_names():
2034
+ return
2035
+ package = _OFFICIAL_EXTENSION_COMMANDS.get(argv[0])
2036
+ if package is None or _discover_cli_commands().find(argv[0]) is not None:
2037
+ return
2038
+ err_console.print(f"[red]`qi {argv[0]}` 需要 **{package}** 扩展(这条子命令由它提供,"
2039
+ f"core 不内置)。[/red]")
2040
+ for hint in install_hints(f"pip:{package}"):
2041
+ err_console.print(f" [dim]{escape(hint)}[/dim]")
2042
+ err_console.print("[dim]装完用 `qi doctor` 确认宿主真的收到了它。[/dim]")
2043
+ raise typer.Exit(code=2)
2044
+
2045
+
2046
+ def _dispatch_extension_command(argv: list[str]) -> bool:
2047
+ """`qi <扩展子命令> …` → 交给扩展。返回 True 表示已处理。
2048
+
2049
+ **必须在 `app()` 之前**:typer 的子命令表是静态的,动态加会踩坑(E19)。
2050
+ **core 自己的名字优先** —— 扩展撞了 core 的子命令名要被看见,而不是悄悄接管。
2051
+ """
2052
+ if not argv or argv[0].startswith("-") or argv[0] in _core_subcommand_names():
2053
+ return False
2054
+ command = _discover_cli_commands().find(argv[0])
2055
+ if command is None:
2056
+ return False
2057
+ raise SystemExit(command.handler(argv[1:]) or 0)
2058
+
2059
+
2060
+
2061
+ def _extension_report(cwd: Path | None = None) -> tuple[list[str], list[str]]:
2062
+ """`qi doctor` 的扩展一节:`(每个扩展一行, 依赖警告)`。
2063
+
2064
+ 信息来自**注册面的反查**,不是扩展自报 —— 这样"扩展说它注册了 X"与"宿主真收到了 X"
2065
+ 不会各说各话(doctor 的价值正在此)。所以这里宁可做一次轻量装载,也不读扩展自己的日志。
2066
+ """
2067
+ from .extensions import CliCommandRegistry, CommandRegistry, ExtensionBus, FlagRegistry
2068
+ from .registry import (
2069
+ HOST_DISTRIBUTION,
2070
+ CapabilityRegistry,
2071
+ ToolCatalog,
2072
+ discover_extensions,
2073
+ )
2074
+
2075
+ catalog, caps = ToolCatalog(), CapabilityRegistry()
2076
+ commands, flags, cli_commands = CommandRegistry(), FlagRegistry(), CliCommandRegistry()
2077
+ warnings: list[str] = []
2078
+ loaded = discover_extensions(catalog, caps, cwd or Path.cwd(), bus=ExtensionBus(),
2079
+ commands=commands, flags=flags, cli_commands=cli_commands,
2080
+ on_warning=warnings.append, project_trusted=False)
2081
+
2082
+ tools: dict[str, int] = {}
2083
+ origins: dict[str, str] = {}
2084
+ for tool in catalog.all():
2085
+ info = tool.source_info or {}
2086
+ name = str(info.get("source") or "?")
2087
+ tools[name] = tools.get(name, 0) + 1
2088
+ if name not in origins:
2089
+ origin = str(info.get("origin") or "")
2090
+ scope = str(info.get("scope") or "")
2091
+ origins[name] = f"{origin or '?'}" + (f" · {scope}" if scope else "")
2092
+
2093
+ owned: dict[str, list[str]] = {name: [] for name in loaded}
2094
+ for name in commands.names:
2095
+ command = commands.find(name)
2096
+ if command is not None:
2097
+ owned.setdefault(command.source, []).append(f"/{name}")
2098
+ for name in cli_commands.names:
2099
+ command = cli_commands.find(name)
2100
+ if command is not None:
2101
+ owned.setdefault(command.source, []).append(f"qi {name}")
2102
+ for name in loaded: # 配置种类的归属(反查)
2103
+ kinds = sorted(k for k in caps.kinds if name in caps.providers(k))
2104
+ if kinds:
2105
+ owned[name].append("配置种类 " + ", ".join(kinds))
2106
+ if tools.get(name):
2107
+ owned[name].append(f"工具 {tools[name]}")
2108
+
2109
+ lines: list[str] = []
2110
+ for name in loaded:
2111
+ what = owned.get(name) or ["(装载了但没有注册任何东西)"]
2112
+ lines.append(f"{name} [dim]{origins.get(name, '?')}[/dim] " + " · ".join(what))
2113
+ if flags.names:
2114
+ # 旗标登记处没有归属面,所以单独列(不硬凑成"某扩展的旗标")
2115
+ lines.append("旗标: " + ", ".join(flags.names))
2116
+ return lines, warnings
2117
+
2118
+
2119
+ #: pi 的多字符短旗标 → qi 的长旗标。click 的短选项是**按字符**解析的(它只在 `_short_opt`
2120
+ #: 里查 `-n`、`-x` 这类单字符键),所以 `-nt` 会被拆成 `-n t` —— 会话名悄悄变成 `"t"`。
2121
+ #: 多字符短选项因此必须在 typer 看到 argv **之前**展开。pi 的 CLI 是手写 argv 循环
2122
+ #: (`cli/args.ts`),没有这个问题;qi 的这套展开就是那个循环的最小替代(见 E19)。
2123
+ _SHORT_FLAG_ALIASES = {"-nt": "--no-tools", "-nbt": "--no-builtin-tools",
2124
+ "-t": "--tools", "-xt": "--exclude-tools",
2125
+ "-ne": "--no-extensions", "-nc": "--no-context-files",
2126
+ "-np": "--no-prompt-templates"}
2127
+ #: 会**吃掉下一个 token** 的选项:预处理时不能把它们的值当旗标改写
2128
+ #:(`-n -nt` 里的 `-nt` 是会话名,不是旗标)。
2129
+ _VALUE_FLAGS = {"--session", "--fork", "--name", "-n", "--export", "--mode", "--skill",
2130
+ "--ext", "-e", "--extension", "--thinking", "--tools", "--exclude-tools"}
2131
+
2132
+
2133
+ def normalize_short_flags(argv: list[str]) -> list[str]:
2134
+ """把 pi 的短旗标展开成长旗标;`--` 之后一律不动(那是消息原文)。"""
2135
+ out: list[str] = []
2136
+ i = 0
2137
+ while i < len(argv):
2138
+ token = argv[i]
2139
+ if token == "--":
2140
+ out.extend(argv[i:])
2141
+ break
2142
+ if token in ("-t", "-xt"):
2143
+ out.append(_SHORT_FLAG_ALIASES[token])
2144
+ if i + 1 < len(argv): # 缺值就交给 click 报错(不在这里猜)
2145
+ i += 1
2146
+ out.append(argv[i])
2147
+ elif token in _SHORT_FLAG_ALIASES:
2148
+ out.append(_SHORT_FLAG_ALIASES[token])
2149
+ elif token in _VALUE_FLAGS:
2150
+ out.append(token)
2151
+ if "=" not in token and i + 1 < len(argv):
2152
+ i += 1
2153
+ out.append(argv[i])
2154
+ else:
2155
+ out.append(token)
2156
+ i += 1
2157
+ return out
2158
+
2159
+
2160
+ def main() -> None:
2161
+ """CLI 入口:先确保目录布局(旧扁平布局 → `~/.qi/agent/`),再交给 typer。"""
2162
+ try:
2163
+ moved = paths.ensure_layout()
2164
+ except OSError as exc:
2165
+ err_console.print(f"[yellow]目录初始化失败(继续): {escape(str(exc))}[/yellow]")
2166
+ moved = []
2167
+ if moved:
2168
+ console.print("[green]已迁移到 agent 目录(对齐 pi):[/green]")
2169
+ for src, dst in moved:
2170
+ console.print(f" {src} → {dst}")
2171
+ # pi 的短旗标先展开再交给 typer(见 `normalize_short_flags`)
2172
+ sys.argv = [sys.argv[0], *normalize_short_flags(list(sys.argv[1:]))]
2173
+ # 「装了但没装那个扩展」的官方子命令:先给装法,别让 typer 报 No such command
2174
+ _hint_missing_extension_command(list(sys.argv[1:]))
2175
+ # 扩展子命令要在 typer 之前认出来(它的子命令表是静态的)
2176
+ if _dispatch_extension_command(list(sys.argv[1:])):
2177
+ return
2178
+ app()