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/settings.py ADDED
@@ -0,0 +1,501 @@
1
+ """应用设置 `settings.json`(对齐 pi)。
2
+
3
+ 位置(高 → 低):
4
+
5
+ <git根>/.qi/settings.json 项目覆盖(可提交共享;与 `~/.qi/agent` 配对)
6
+ ~/.qi/agent/settings.json 全局(对齐 pi 的 `~/.pi/agent/settings.json`)
7
+
8
+ 合并规则同 pi:键级深合并,项目覆盖全局;数组整体替换(不逐项合并)。
9
+ 路径类字段(extensions / skills)相对**各自 settings.json 所在目录**
10
+ 解析,绝对路径与 `~` 都支持;`!pat` / `-pat` 为排除项,`+path` 为强制纳入。
11
+
12
+ 本模块只负责"JSON 文件的装载/合并/写入"这一层,models.json 的语义在 config.py。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from pydantic import BaseModel, Field
22
+
23
+ from .paths import SETTINGS_FILE_NAME, settings_file_candidates
24
+
25
+ SCOPES = ("user", "project")
26
+
27
+
28
+ class SettingsError(Exception):
29
+ """settings.json 读取/校验/写入失败。"""
30
+
31
+
32
+ # ── 通用 JSON 装载与合并 ────────────────────────────────
33
+
34
+ def read_json(path: Path) -> dict[str, Any]:
35
+ """读取 JSON 对象文件;空文件视为 {};失败抛 SettingsError(带路径)。"""
36
+ try:
37
+ with path.open("rb") as fh:
38
+ raw = fh.read()
39
+ if not raw.strip():
40
+ return {}
41
+ data = json.loads(raw.decode("utf-8"))
42
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
43
+ raise SettingsError(f"读取 {path} 失败: {exc}") from exc
44
+ return data if isinstance(data, dict) else {}
45
+
46
+
47
+ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
48
+ """键级深合并:override 覆盖 base;嵌套 dict 递归合并;数组整体替换。"""
49
+ out = dict(base)
50
+ for key, value in override.items():
51
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
52
+ out[key] = deep_merge(out[key], value)
53
+ else:
54
+ out[key] = value
55
+ return out
56
+
57
+
58
+ # ── 设置模型 ────────────────────────────────────────────
59
+
60
+ class QiSettings(BaseModel):
61
+ """settings.json 的已知字段;未知字段原样保留(extra=allow)。
62
+
63
+ 字段名与 pi 一致,便于两边共享同一个文件;`skillsEnabled` 是 qi 扩展
64
+ (pi 用 `--no-skills` 关闭技能)。
65
+ """
66
+
67
+ model_config = {"extra": "allow"} # type: ignore[assignment]
68
+
69
+ # 模型
70
+ defaultProvider: str | None = None
71
+ defaultModel: str | None = None
72
+ defaultThinkingLevel: str | None = None
73
+ #: 按模型的思考级别(pi 同名段):`{"provider/模型": "high"}`。
74
+ #: 优先级见 `QiRuntime.__init__`:`--thinking` > `--model :级别` > 这里 > 全局默认。
75
+ modelThinkingLevels: dict[str, str] = Field(default_factory=dict)
76
+ #: 每个思考级别的 token 预算(pi 同名段)。**只对 Anthropic 形态生效**(litellm 只在那里
77
+ #: 有对应参数);不配就不带(pi 另有内置默认表,qi 没有 —— 差异见 design/pi-alignment.md)。
78
+ thinkingBudgets: dict[str, int] = Field(default_factory=dict)
79
+ #: 分支摘要(`/tree` 跳转时):`{"skipPrompt": true}` —— 不问也不摘要(pi 同名段)。
80
+ #: pi 的 `reserveTokens` **未接**:qi 的摘要预算固定,加一个不生效的旋钮不如不加。
81
+ branchSummary: dict[str, Any] = Field(default_factory=dict)
82
+ enabledModels: list[str] | None = None
83
+
84
+ # 界面
85
+ theme: str | None = None
86
+ quietStartup: bool = False
87
+ #: TUI 模式(pi 同名键 `tuiMode`):`regular` = inline(不占全屏、终端自己管回滚缓冲);
88
+ #: `fullscreen` = 备用屏(qi 拥有视口,滚轮只滚 transcript)。
89
+ #: **qi 默认 fullscreen,与 pi 的默认(regular)相反** —— 取舍与理由见 `design/internals.md` 的终端界面一节。
90
+ tuiMode: str | None = None
91
+ defaultProjectTrust: str = "ask"
92
+ doubleEscapeAction: str = "tree"
93
+ hideThinkingBlock: bool = False
94
+ # 编辑器左侧内边距:pi 默认 0,qi 视觉基线用 1
95
+ editorPaddingX: int = 1
96
+ outputPad: int = 1 # 助手输出的左侧缩进(pi 默认 1)
97
+ autocompleteMaxVisible: int = 5 # 补全面板最多几行(pi 默认 5)
98
+
99
+ # 会话 / 工具
100
+ sessionDir: str | None = None
101
+ defaultTools: list[str] | None = None
102
+ # bash 用哪个 shell(对齐 pi 的 shellPath);空 = 按平台解析(见 tools/shell.py)
103
+ shellPath: str | None = None
104
+
105
+ # 运行行为
106
+ compaction: dict[str, Any] | None = None
107
+ retry: dict[str, Any] | None = None
108
+
109
+ # 资源
110
+ packages: list[Any] = Field(default_factory=list)
111
+ extensions: list[str] = Field(default_factory=list)
112
+ skills: list[str] = Field(default_factory=list)
113
+ # `prompts` / `themes` **删掉了**:qi 没有 prompt 模板、也不支持自定义主题文件 ——
114
+ # 留着字段却没人读,用户写进去毫无反应,而 `extra: allow` 又让人分不出
115
+ # "我拼错了"与"qi 没实现"。要做那两个功能就把字段一起加回来。
116
+ extensions: list[str] = Field(default_factory=list)
117
+ enableSkillCommands: bool = True
118
+ skillsEnabled: bool = True
119
+
120
+
121
+ # ── 装载 ────────────────────────────────────────────────
122
+
123
+ def settings_scope_path(scope: str, cwd: Path | None = None) -> Path:
124
+ """某作用域的 settings.json 路径(写入用)。"""
125
+ for path, name in settings_file_candidates(cwd):
126
+ if name == scope:
127
+ return path
128
+ raise SettingsError(f"未知作用域 {scope!r}(可选:{', '.join(SCOPES)})")
129
+
130
+
131
+ def load_settings_raw(cwd: Path | None = None) -> tuple[dict[str, Any], list[Path]]:
132
+ """按优先级装载并深合并,返回 (merged, 实际读取的文件列表(高→低))。"""
133
+ merged: dict[str, Any] = {}
134
+ loaded: list[Path] = []
135
+ for path, _scope in reversed(settings_file_candidates(cwd)): # 低 → 高
136
+ if not path.is_file():
137
+ continue
138
+ merged = deep_merge(merged, read_json(path)) # read_json 失败已带路径报错
139
+ loaded.append(path)
140
+ return merged, list(reversed(loaded))
141
+
142
+
143
+ def load_settings(cwd: Path | None = None) -> tuple[QiSettings, list[Path]]:
144
+ """装载校验后的设置;无文件时返回默认值。"""
145
+ raw, loaded = load_settings_raw(cwd)
146
+ try:
147
+ return QiSettings.model_validate(raw), loaded
148
+ except Exception as exc: # pydantic.ValidationError
149
+ raise SettingsError(f"{SETTINGS_FILE_NAME} 校验失败: {exc}") from exc
150
+
151
+
152
+ def load_settings_by_scope(cwd: Path | None = None) -> dict[str, QiSettings]:
153
+ """每个作用域**各自**解析后的设置(不合并),缺失的作用域不出现在结果里。
154
+
155
+ 资源路径必须按这个结果解析:pi 的语义是 `~/.pi/agent/settings.json` 里的路径
156
+ 相对于 `~/.pi/agent`,`<cwd>/.pi/settings.json` 里的相对于 `.pi`。用合并后的
157
+ 数组会让一侧的路径落到另一侧的基准目录上。
158
+ """
159
+ out: dict[str, QiSettings] = {}
160
+ for path, scope in settings_file_candidates(cwd):
161
+ if not path.is_file():
162
+ continue
163
+ try:
164
+ out[scope] = QiSettings.model_validate(read_json(path))
165
+ except Exception as exc: # pydantic.ValidationError
166
+ raise SettingsError(f"{path} 校验失败: {exc}") from exc
167
+ return out
168
+
169
+
170
+ def save_settings(scope: str, data: dict[str, Any], cwd: Path | None = None) -> Path:
171
+ """整份写入某作用域的 settings.json(保留未知字段)。"""
172
+ path = settings_scope_path(scope, cwd)
173
+ path.parent.mkdir(parents=True, exist_ok=True)
174
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
175
+ return path
176
+
177
+
178
+ # ── 点号键读写(qi config --set / --unset) ──────────────
179
+
180
+ def parse_value(text: str) -> Any:
181
+ """`--set` 的值:先按 JSON 解析,失败则当字符串(便于 theme=light)。"""
182
+ try:
183
+ return json.loads(text)
184
+ except json.JSONDecodeError:
185
+ return text
186
+
187
+
188
+ def set_value(scope: str, dotted: str, value: Any, cwd: Path | None = None) -> Path:
189
+ """写某个点号路径(如 skills / compaction.enabled);中间层不存在则建。"""
190
+ path = settings_scope_path(scope, cwd)
191
+ data = read_json(path) if path.is_file() else {}
192
+ keys = [k for k in dotted.split(".") if k]
193
+ if not keys:
194
+ raise SettingsError("键不能为空")
195
+ cursor = data
196
+ for key in keys[:-1]:
197
+ nxt = cursor.get(key)
198
+ if not isinstance(nxt, dict):
199
+ nxt = {}
200
+ cursor[key] = nxt
201
+ cursor = nxt
202
+ cursor[keys[-1]] = value
203
+ return save_settings(scope, data, cwd)
204
+
205
+
206
+ def unset_value(scope: str, dotted: str, cwd: Path | None = None) -> tuple[Path, bool]:
207
+ """删除某个点号路径;返回 (文件, 是否真的删掉了键)。"""
208
+ path = settings_scope_path(scope, cwd)
209
+ data = read_json(path) if path.is_file() else {}
210
+ keys = [k for k in dotted.split(".") if k]
211
+ cursor: Any = data
212
+ for key in keys[:-1]:
213
+ cursor = cursor.get(key) if isinstance(cursor, dict) else None
214
+ if not isinstance(cursor, dict):
215
+ return path, False
216
+ removed = False
217
+ if isinstance(cursor, dict) and keys and keys[-1] in cursor:
218
+ del cursor[keys[-1]]
219
+ removed = True
220
+ if removed:
221
+ save_settings(scope, data, cwd)
222
+ return path, removed
223
+
224
+
225
+ # ── 资源路径解析 ────────────────────────────────────────
226
+
227
+ def split_resource_values(values: list[str]) -> tuple[list[str], list[str]]:
228
+ """把资源数组拆成 (纳入, 排除);`!pat`/`-pat` 为排除,`+path` 为强制纳入。"""
229
+ includes: list[str] = []
230
+ excludes: list[str] = []
231
+ for value in values:
232
+ if not isinstance(value, str) or not value.strip():
233
+ continue
234
+ if value.startswith(("!", "-")):
235
+ excludes.append(value[1:])
236
+ elif value.startswith("+"):
237
+ includes.append(value[1:])
238
+ else:
239
+ includes.append(value)
240
+ return includes, excludes
241
+
242
+
243
+ def resolve_resource_paths(value: str, base_dir: Path) -> list[Path]:
244
+ """把一条资源路径解析成实际目录/文件;支持 `~`、相对路径与 glob。"""
245
+ expanded = value.strip().replace("\\", "/")
246
+ if expanded.startswith("~"):
247
+ root = Path(expanded).expanduser()
248
+ else:
249
+ root = Path(expanded)
250
+ if not root.is_absolute():
251
+ root = base_dir / root
252
+ if any(ch in str(root) for ch in "*?["):
253
+ try:
254
+ return sorted(p for p in base_dir.glob(expanded) if p.exists())
255
+ except (OSError, ValueError):
256
+ return []
257
+ return [root] if root.exists() else []
258
+
259
+
260
+ def settings_include_paths(settings: QiSettings | None, scope: str, key: str,
261
+ cwd: Path | None = None) -> list[Path]:
262
+ """某作用域某资源字段(skills/extensions/...)的**纳入**路径(已展开 glob)。
263
+
264
+ `settings` 必须是 `load_settings_by_scope()[scope]` —— 即该作用域**自己**的
265
+ 那份,不能传合并后的结果(否则相对路径基准会错)。
266
+ """
267
+ if settings is None:
268
+ return []
269
+ includes, _excludes = split_resource_values(list(getattr(settings, key, None) or []))
270
+ base = settings_scope_path(scope, cwd).parent
271
+ paths: list[Path] = []
272
+ for item in includes:
273
+ for path in resolve_resource_paths(item, base):
274
+ if path not in paths:
275
+ paths.append(path)
276
+ return paths
277
+
278
+
279
+ def settings_exclude_paths(settings: QiSettings | None, scope: str, key: str,
280
+ cwd: Path | None = None) -> list[Path]:
281
+ """某作用域某资源字段的**排除**路径(`!pat` / `-pat`),同样各自基准解析。
282
+
283
+ 对齐 pi:排除项作用于**整个发现集**,而不只是数组里纳入的根 —— 所以默认目录
284
+ (`~/.qi/agent/skills` 等)里扫出来的技能也能被单独关掉。
285
+ """
286
+ if settings is None:
287
+ return []
288
+ _includes, excludes = split_resource_values(list(getattr(settings, key, None) or []))
289
+ base = settings_scope_path(scope, cwd).parent
290
+ removed: list[Path] = []
291
+ for item in excludes:
292
+ for path in resolve_resource_paths(item, base):
293
+ if path not in removed:
294
+ removed.append(path)
295
+ return removed
296
+
297
+
298
+ #: `/settings` 面板能改的键 —— **只收值域有限且已接线**的偏好键。
299
+ #: 数字/路径类(`editorPaddingX` / `outputPad` / `autocompleteMaxVisible` /
300
+ #: `shellPath` / `sessionDir`)不放面板:在 TUI 里敲数字和路径的体验比 `qi config --set` 差。
301
+ #: 字段未设时面板显示什么 —— 显示**生效值**而不是 "None"(那是内部表示,不是用户看到的语义)
302
+ #: `defaultThinkingLevel` 的生效值同 `llm.DEFAULT_THINKING_LEVEL`(pi 默认 medium);
303
+ #: 这里不 import llm —— 两模块有静态环(settings 里就写了延迟导入的原因),有测试锁住两边一致。
304
+ UNSET_DISPLAY: dict[str, str] = {"theme": "auto", "defaultThinkingLevel": "medium"}
305
+
306
+ SETTING_CHOICES: dict[str, tuple[str, ...]] = {
307
+ "theme": ("dark", "light", "auto"),
308
+ "defaultThinkingLevel": ("off", "minimal", "low", "medium", "high", "xhigh", "max"),
309
+ "doubleEscapeAction": ("tree", "fork", "none"),
310
+ "hideThinkingBlock": ("false", "true"),
311
+ "quietStartup": ("false", "true"),
312
+ "skillsEnabled": ("false", "true"),
313
+ }
314
+
315
+
316
+ def setting_choices(settings: QiSettings) -> list[tuple[str, str]]:
317
+ """`/settings` 面板的行:`(键, 当前值)` —— 值域来自 `SETTING_CHOICES`。
318
+
319
+ 当前值不在候选里(比如手写了一个不认识的主题)时**原样显示** —— 面板不该假装它不存在,
320
+ 也不该在用户没动它的时候把值改掉。
321
+ """
322
+ rows: list[tuple[str, str]] = []
323
+ for key in SETTING_CHOICES:
324
+ current = getattr(settings, key, None)
325
+ if current is None and key in UNSET_DISPLAY:
326
+ current = UNSET_DISPLAY[key] # 未设 → 显示**生效值**,不显示 "None"
327
+ rows.append((key, str(current).lower() if isinstance(current, bool) else str(current)))
328
+ return rows
329
+
330
+
331
+ def next_choice(key: str, current: str) -> str:
332
+ """循环到下一个候选值(面板里按一下走一格)。未知键/未知值都**原样返回**。"""
333
+ values = SETTING_CHOICES.get(key)
334
+ if not values:
335
+ return current
336
+ try:
337
+ index = list(values).index(current)
338
+ except ValueError:
339
+ return values[0] # 手写的怪值:按一下回到第一个合法值
340
+ return values[(index + 1) % len(values)]
341
+
342
+
343
+ #: TUI 模式取值(pi `tuiMode` 的两个值,语义同 pi)。
344
+ TUI_MODES: tuple[str, ...] = ("regular", "fullscreen")
345
+ #: **qi 的默认**是 fullscreen(pi 默认 regular)。为什么不同:pi 的 regular 把滚动权交给终端,
346
+ #: 滚轮/PageUp 会翻到启动 qi 之前的 shell 输出;qi 要的是“滚动只在本界面内”(见 design/internals.md 的终端界面一节)。
347
+ DEFAULT_TUI_MODE = "fullscreen"
348
+
349
+
350
+ def tui_mode(settings: QiSettings | None) -> str:
351
+ """`settings.tuiMode` 归一:认 `regular` / `fullscreen`;没写或写歪 → `DEFAULT_TUI_MODE`。
352
+
353
+ 与 `normalize_thinking_level` 同理:写歪的值不报错也不生效,回落默认 —— 一个错字
354
+ 不该让界面起不来。
355
+ """
356
+ raw = str(getattr(settings, "tuiMode", None) or "").strip().lower()
357
+ return raw if raw in TUI_MODES else DEFAULT_TUI_MODE
358
+
359
+
360
+ def branch_summary_skip_prompt(settings: QiSettings) -> bool:
361
+ """`branchSummary.skipPrompt`(pi 同名段,默认 `false`)。
362
+
363
+ pi 的定义是 "Skip \"Summarize branch?\" prompt on `/tree` navigation (**defaults to no
364
+ summary**)" —— 所以它为真时**不问也不摘要**,不是"自动摘要"。写歪的值一律当 `false`
365
+ (即"照常问"),不因为一个错字把摘要静默关掉。
366
+ """
367
+ section = getattr(settings, "branchSummary", None)
368
+ if not isinstance(section, dict):
369
+ return False
370
+ # 严格布尔:写歪的值(`"yes"` / `1`)一律当 false —— 不因为一个错字把摘要静默关掉。
371
+ # (同 `qi_web/app.py` 里 `disabled` 的写法:避开 identity comparison 的静态检查。)
372
+ value = section.get("skipPrompt")
373
+ return isinstance(value, bool) and value
374
+
375
+
376
+ def model_thinking_level(settings: QiSettings, model: Any) -> str | None:
377
+ """`modelThinkingLevels` 里该模型的档(pi 同名段:`{"provider/模型": "high"}`)。
378
+
379
+ 键写 **`provider/模型` 最明确**,也认**裸模型 id**(只写 id 时任何 provider 下同名模型都命中);
380
+ **更具体的那条赢**(先找带 provider 的)。值不认识(不在 `THINKING_LEVELS` 里)时返回 `None`
381
+ 并按"没配"处理 —— 一个写歪的值不该让启动失败。
382
+ """
383
+ from .llm import THINKING_LEVELS # 延迟导入:避免 settings ↔ llm 的静态环
384
+
385
+ table = dict(getattr(settings, "modelThinkingLevels", None) or {})
386
+ mid = str(getattr(model, "model", "") or "")
387
+ if not table or not mid:
388
+ return None
389
+ provider = str(getattr(model, "provider", "") or "")
390
+ for key in (f"{provider}/{mid}", mid):
391
+ raw = table.get(key)
392
+ if raw is None:
393
+ continue
394
+ level = str(raw).strip().lower()
395
+ if level in THINKING_LEVELS:
396
+ return level
397
+ return None
398
+
399
+
400
+ def default_tools(settings: QiSettings) -> list[str] | None:
401
+ """`defaultTools` 规范化(None = 用框架默认)。"""
402
+ if settings.defaultTools is None:
403
+ return None
404
+ return [str(t) for t in settings.defaultTools]
405
+
406
+
407
+ def project_trust_default(settings: QiSettings) -> str:
408
+ """`defaultProjectTrust` 取值归一(ask/always/never);未知值按 ask。"""
409
+ value = (settings.defaultProjectTrust or "ask").strip().lower()
410
+ return value if value in ("ask", "always", "never") else "ask"
411
+
412
+
413
+ def resolve_project_trust(settings: QiSettings | None, *,
414
+ approve: bool | None = None,
415
+ has_ui: bool = False,
416
+ stored: tuple[bool, str] | None = None) -> tuple[bool, str]:
417
+ """项目信任判定 → `(trusted, reason)`(docs/extensions.md §2 / design/extensions-design.md 的 E16)。
418
+
419
+ 优先级(对齐 pi 的 "saved decisions ... apply before the global default"):
420
+
421
+ 1. CLI 显式(`-a` / `-na`);
422
+ 2. `trust.json` 里**当前目录或父目录上最近的**决定(`stored`,由 `trust.TrustStore` 查);
423
+ 3. `settings.defaultProjectTrust`(**只从用户级读** —— 项目级那份不能自称可信)。
424
+
425
+ `ask` 表示"问用户",而 qi 的交互询问尚未实现,所以那样时**保守判不信任** ——
426
+ 扩展是仓库控制的任意代码,fail-safe 只能是"不执行"。
427
+ """
428
+ if approve is not None: # 三态:None = 没在 CLI 上表态
429
+ return approve, ("CLI -a" if approve else "CLI -na")
430
+ if stored is not None:
431
+ trusted, where = stored
432
+ kind = "已信任" if trusted else "已拒绝"
433
+ return trusted, f"trust.json 记住了这个{kind}({where})"
434
+ if settings is None:
435
+ return False, "无 settings"
436
+ default = project_trust_default(settings)
437
+ if default == "always":
438
+ return True, "settings.defaultProjectTrust=always"
439
+ if default == "never":
440
+ return False, "settings.defaultProjectTrust=never"
441
+ where = "有 UI 但询问未实现" if has_ui else "无 UI"
442
+ return False, f"defaultProjectTrust=ask({where};用 -a 信任)"
443
+
444
+
445
+ def extension_dirs(cwd: Path | None = None, *, trusted: bool,
446
+ extra: list[Path] | None = None) -> list[tuple[Path, str]]:
447
+ """`settings.extensions[]` 解析出的附加扩展目录 → `[(路径, 作用域)]`。
448
+
449
+ 作用域(`user` / `project` / `temporary`)会进工具的 `source_info.scope`,
450
+ 所以必须按**各自 settings.json 所在的作用域**标注 —— 一律记 temporary 会让
451
+ `getAllTools()` 的诊断面撒谎(那是它存在的唯一理由)。
452
+
453
+ 项目级那份**只在信任时**返回 —— 它随仓库走,未信任时它不是用户的意图。
454
+ `extra` 是调用方自己的追加路径(如 CLI 的 `-e`),记 temporary。
455
+
456
+ **排除项**(`!pat` / `-path`)在这里就减掉 —— `-e` 显式给的 `extra` 不减
457
+ (显式意图胜过配置),与 `registry._iter_extension_loaders` 同一口径。
458
+ """
459
+ scopes = load_settings_by_scope(cwd)
460
+ out: list[tuple[Path, str]] = [(Path(p), "temporary") for p in (extra or ())]
461
+ for scope in ("user", "project"):
462
+ if scope == "project" and not trusted:
463
+ continue
464
+ settings = scopes.get(scope)
465
+ excluded = {p for p in settings_exclude_paths(settings, scope, "extensions", cwd)}
466
+ out += [(p, scope)
467
+ for p in settings_include_paths(settings, scope, "extensions", cwd)
468
+ if p not in excluded]
469
+ return out
470
+
471
+
472
+ DOUBLE_ESCAPE_ACTIONS = ("tree", "fork", "none")
473
+ """`doubleEscapeAction` 的合法取值(对齐 pi 的 settings-manager)。"""
474
+
475
+
476
+ def double_escape_action(settings: QiSettings | None) -> str:
477
+ """空编辑器连按两次 escape 干什么:tree / fork / none。默认 tree(pi 同款)。"""
478
+ value = (getattr(settings, "doubleEscapeAction", None) or "tree").strip().lower()
479
+ return value if value in DOUBLE_ESCAPE_ACTIONS else "tree"
480
+
481
+
482
+ def session_dir(settings: QiSettings, cwd: Path | None = None,
483
+ scopes: dict[str, QiSettings] | None = None) -> Path | None:
484
+ """`sessionDir` 解析;项目优先于全局,各按自己 settings.json 所在目录解析。"""
485
+ scopes = scopes if scopes is not None else load_settings_by_scope(cwd)
486
+ for scope in ("project", "user"):
487
+ raw = (getattr(scopes.get(scope), "sessionDir", None) or "").strip()
488
+ if not raw:
489
+ continue
490
+ base = settings_scope_path(scope, cwd).parent
491
+ resolved = resolve_resource_paths(raw, base)
492
+ if resolved:
493
+ return resolved[0]
494
+ return (Path(raw).expanduser() if Path(raw).is_absolute()
495
+ else base / raw).resolve()
496
+ raw = (settings.sessionDir or "").strip()
497
+ if not raw:
498
+ return None
499
+ base = settings_scope_path("user", cwd).parent
500
+ resolved = resolve_resource_paths(raw, base)
501
+ return resolved[0] if resolved else Path(raw).expanduser()
qi_agent/share.py ADDED
@@ -0,0 +1,111 @@
1
+ """把会话的 HTML 传成**私有** GitHub gist(pi 的 `/share` 的**一条**路径)。
2
+
3
+ **读完全部 183 行后修正的两处**(第一版我写错了,已改):
4
+
5
+ 1. pi 的 gist 路径**就是 shell 出 `gh`**(`spawnSync("gh", ["auth","status"])` 先查登录,
6
+ 再 `gh gist create --public=false <file>`);它用 `getAuthCredential` 取的 token 是给
7
+ **另一条路径**(pi 自建的 Radius 服务)的,不是给 gist 的。
8
+ 2. pi 的 `/share` 有**两条**路径:先试 Radius(上传 **JSONL** + 注入分享元数据
9
+ `systemPrompt` / 工具定义),失败才退 gist(上传 **HTML**)。半径那条是 pi 的托管服务,
10
+ qi 没有对应物 —— 所以 qi 只做 gist 这条,并且在 §10 记档。
11
+
12
+ qi 与 pi 的**刻意偏离**:qi **直调 API**(stdlib `urllib`),不 shell 出 `gh` —— 少一个外部
13
+ 依赖,也不用要求用户先 `gh auth login`。代价是没有 pi 的 viewer 预览链接(那需要 pi 的
14
+ 服务),qi 只能给 gist 链接本身。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import urllib.error
22
+ import urllib.request
23
+ from pathlib import Path
24
+ from typing import Any, Callable
25
+
26
+ GIST_API = "https://api.github.com/gists"
27
+ #: 环境变量顺序(与 qi 的凭证总原则一致:先约定环境变量)
28
+ _TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN")
29
+
30
+
31
+ class ShareError(Exception):
32
+ """分享失败 —— 消息是给用户看的(说清下一步该做什么)。"""
33
+
34
+
35
+ def resolve_token(store: Any = None) -> tuple[str, str] | None:
36
+ """→ `(token, 来源)`;都没有则 None。来源要报出来(用户需要知道用的是哪一个)。
37
+
38
+ **顺序照 qi 的凭证总原则**(`docs/security.md`):auth store → 约定环境变量。
39
+ (pi 也是从自己的凭证库取 —— 这一点是读它的实现确认的,不是猜的。)
40
+ """
41
+ try:
42
+ from .auth import AuthStore
43
+
44
+ key = (store or AuthStore()).get("github")
45
+ except Exception: # noqa: BLE001 凭证库读不了不该让分享崩在别处
46
+ key = None
47
+ if key:
48
+ return str(key), "auth store(qi auth login github)"
49
+ for name in _TOKEN_ENVS:
50
+ value = (os.environ.get(name) or "").strip()
51
+ if value:
52
+ return value, f"环境变量 {name}"
53
+ return None
54
+
55
+
56
+ def share_session(session: Any, *, token: str | None = None, title: str | None = None,
57
+ opener: Callable[[Any], Any] | None = None) -> str:
58
+ """把会话的当前分支传成私有 gist,返回可分享的 URL。
59
+
60
+ `opener` 只为测试可注入(默认 `urllib.request.urlopen`)—— **测试里绝不联网**。
61
+ """
62
+ from .export_html import render_session_html
63
+
64
+ if token is None:
65
+ found = resolve_token()
66
+ if found is None:
67
+ raise ShareError(
68
+ "没有 GitHub token:设 `GITHUB_TOKEN`(需要一个带 `gist` 权限的 token),"
69
+ "或 `qi auth login github`")
70
+ token = found[0]
71
+ body = json.dumps({
72
+ "description": title or f"qi 会话 {getattr(session, 'id', '') or ''}".strip(),
73
+ "public": False, # 私有 —— pi 同
74
+ "files": {"session.html": {"content": render_session_html(session, title=title)}},
75
+ }).encode("utf-8")
76
+ request = urllib.request.Request(
77
+ GIST_API, data=body, method="POST",
78
+ headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json",
79
+ "Content-Type": "application/json", "User-Agent": "qi"})
80
+ try:
81
+ with (opener or urllib.request.urlopen)(request) as response:
82
+ payload = json.loads(response.read().decode("utf-8") or "{}")
83
+ except urllib.error.HTTPError as exc:
84
+ # 这段里**不用** `or` / `and`(ast-grep 的 no-boolean-in-except 会拦;而且
85
+ # 三元表达式把意图写得更直白)
86
+ detail = ""
87
+ try:
88
+ message = json.loads(exc.read().decode("utf-8")).get("message")
89
+ detail = str(message) if message else ""
90
+ except Exception: # noqa: BLE001 响应体不是 JSON 时只留状态码
91
+ detail = ""
92
+ if exc.code in (401, 403):
93
+ why = detail if detail else "权限不足"
94
+ raise ShareError(f"GitHub 拒绝了({exc.code} {why}):"
95
+ "token 需要 `gist` 权限") from exc
96
+ suffix = f" {detail}" if detail else ""
97
+ raise ShareError(f"GitHub 返回 {exc.code}{suffix}") from exc
98
+ except urllib.error.URLError as exc:
99
+ raise ShareError(f"连不上 GitHub(离线或代理问题):{exc.reason}") from exc
100
+ url = str(payload.get("html_url") or "")
101
+ if not url:
102
+ raise ShareError("GitHub 没回可分享的链接(响应里没有 html_url)")
103
+ return url
104
+
105
+
106
+ def share_path(session: Any, out: Path, *, title: str | None = None, **kw: Any) -> str:
107
+ """先写一份本地 HTML 再把**它**传上去(便于用户留档与分享同一份内容)。"""
108
+ from .export_html import write_session_html
109
+
110
+ write_session_html(session, out, title=title)
111
+ return share_session(session, title=title, **kw)